Merge branch 'spencer/transaction-list' into 'master'

User Interface Layout.

See merge request grassrootseconomics/cic-staff-client!1
This commit is contained in:
Spencer Ofwiti 2021-03-16 10:19:32 +00:00
commit 03be46e169
195 changed files with 32161 additions and 1183 deletions

View File

@ -1,9 +1,13 @@
# CICADA # CICADA
Angular web client for managing users and transactions in the CIC network. An angular admin web client for managing users and transactions in the CIC network.
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 10.2.0. This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 10.2.0.
## Angular CLI
Run `npm install -g @angular/cli` to install the angular CLI.
## Development server ## Development server
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files. Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files.
@ -28,6 +32,12 @@ Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.
Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/). Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/).
## Environment variables
Environment variables are contained in the directory `src/environments/`
It contains environment variables for development on `environment.ts` and production on `environment.prod.ts`.
## Further help ## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page. To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page.

View File

@ -7,7 +7,8 @@
"projectType": "application", "projectType": "application",
"schematics": { "schematics": {
"@schematics/angular:component": { "@schematics/angular:component": {
"style": "scss" "style": "scss",
"changeDetection": "OnPush"
} }
}, },
"root": "", "root": "",
@ -29,9 +30,16 @@
], ],
"styles": [ "styles": [
"./node_modules/@angular/material/prebuilt-themes/indigo-pink.css", "./node_modules/@angular/material/prebuilt-themes/indigo-pink.css",
"src/styles.scss" "src/styles.scss",
"node_modules/datatables.net-dt/css/jquery.dataTables.css",
"node_modules/bootstrap/dist/css/bootstrap.min.css"
], ],
"scripts": [] "scripts": [
"node_modules/jquery/dist/jquery.js",
"node_modules/datatables.net/js/jquery.dataTables.js",
"node_modules/bootstrap/dist/js/bootstrap.js",
"node_modules/block-syncer/dist/worker_ondemand.js"
]
}, },
"configurations": { "configurations": {
"production": { "production": {
@ -88,6 +96,7 @@
"polyfills": "src/polyfills.ts", "polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.spec.json", "tsConfig": "tsconfig.spec.json",
"karmaConfig": "karma.conf.js", "karmaConfig": "karma.conf.js",
"codeCoverage": true,
"assets": [ "assets": [
"src/favicon.ico", "src/favicon.ico",
"src/assets" "src/assets"

26025
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,8 @@
"build": "ng build", "build": "ng build",
"test": "ng test", "test": "ng test",
"lint": "ng lint", "lint": "ng lint",
"e2e": "ng e2e" "e2e": "ng e2e",
"postinstall": "node patch-webpack.js"
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
@ -21,17 +22,39 @@
"@angular/platform-browser": "~10.2.0", "@angular/platform-browser": "~10.2.0",
"@angular/platform-browser-dynamic": "~10.2.0", "@angular/platform-browser-dynamic": "~10.2.0",
"@angular/router": "~10.2.0", "@angular/router": "~10.2.0",
"@popperjs/core": "^2.5.4",
"angular-datatables": "^9.0.2",
"block-syncer": "^0.2.4",
"bootstrap": "^4.5.3",
"chart.js": "^2.9.4",
"cic-client": "^0.1.1",
"cic-client-meta": "0.0.7-alpha.3",
"datatables.net": "^1.10.22",
"datatables.net-dt": "^1.10.22",
"ethers": "^5.0.31",
"jquery": "^3.5.1",
"mocha": "^8.2.1",
"moolb": "^0.1.0",
"ng2-charts": "^2.4.2",
"ngx-logger": "^4.2.1",
"openpgp": "^4.10.10", "openpgp": "^4.10.10",
"popper.js": "^1.16.1",
"rxjs": "~6.6.0", "rxjs": "~6.6.0",
"sha3": "^2.1.4",
"tslib": "^2.0.0", "tslib": "^2.0.0",
"vcard-parser": "^1.0.0",
"vcards-js": "^2.10.0",
"web3": "^1.3.0",
"zone.js": "~0.10.2" "zone.js": "~0.10.2"
}, },
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "~0.1002.0", "@angular-devkit/build-angular": "~0.1002.0",
"@angular/cli": "~10.2.0", "@angular/cli": "~10.2.0",
"@angular/compiler-cli": "~10.2.0", "@angular/compiler-cli": "~10.2.0",
"@types/datatables.net": "^1.10.19",
"@types/jasmine": "~3.5.0", "@types/jasmine": "~3.5.0",
"@types/jasminewd2": "~2.0.3", "@types/jasminewd2": "~2.0.3",
"@types/jquery": "^3.5.4",
"@types/node": "^12.19.14", "@types/node": "^12.19.14",
"codelyzer": "^6.0.0", "codelyzer": "^6.0.0",
"jasmine-core": "~3.6.0", "jasmine-core": "~3.6.0",
@ -43,6 +66,7 @@
"karma-jasmine-html-reporter": "^1.5.0", "karma-jasmine-html-reporter": "^1.5.0",
"karma-junit-reporter": "^2.0.1", "karma-junit-reporter": "^2.0.1",
"protractor": "~7.0.0", "protractor": "~7.0.0",
"secp256k1": "^4.0.2",
"ts-node": "~8.3.0", "ts-node": "~8.3.0",
"tslint": "~6.1.0", "tslint": "~6.1.0",
"typescript": "~4.0.2" "typescript": "~4.0.2"

13
patch-webpack.js Normal file
View File

@ -0,0 +1,13 @@
const fs = require('fs');
const f = 'node_modules/@angular-devkit/build-angular/src/angular-cli-files/models/webpack-configs/browser.js';
fs.readFile(f, 'utf8', function (err,data) {
if (err) {
return console.log(err);
}
let result = data.replace(/node: false/g, "node: {crypto: true, stream: true, fs: 'empty', net: 'empty'}");
fs.writeFile(f, result, 'utf8', function (err) {
if (err) return console.log(err);
});
});

View File

@ -0,0 +1,53 @@
// @ts-ignore
import * as accountIndex from '@src/assets/js/block-sync/data/AccountRegistry.json';
import {environment} from '@src/environments/environment';
const Web3 = require('web3');
const web3 = new Web3(environment.web3Provider);
const abi = accountIndex.default;
export class AccountIndex {
contractAddress: string;
signerAddress: string;
contract: any;
constructor(contractAddress: string, signerAddress?: string) {
this.contractAddress = contractAddress;
this.contract = new web3.eth.Contract(abi, this.contractAddress);
if (signerAddress) {
this.signerAddress = signerAddress;
} else {
this.signerAddress = web3.eth.accounts[0];
}
}
public async totalAccounts(): Promise<number> {
return await this.contract.methods.count().call();
}
public async haveAccount(address: string): Promise<boolean> {
return await this.contract.methods.accountIndex(address).call() !== 0;
}
public async last(numberOfAccounts: number): Promise<Array<string>> {
const count = await this.totalAccounts();
let lowest = count - numberOfAccounts - 1;
if (lowest < 0) {
lowest = 0;
}
let accounts = [];
for (let i = count - 1; i > lowest; i--) {
const account = await this.contract.methods.accounts(i).call();
accounts.push(account);
}
return accounts;
}
public async addToAccountRegistry(address: string): Promise<boolean> {
if (!await this.haveAccount(address)) {
return await this.contract.methods.add(address).send({from: this.signerAddress});
} else {
return await this.haveAccount(address);
}
}
}

3
src/app/_eth/index.ts Normal file
View File

@ -0,0 +1,3 @@
export * from '@app/_eth/accountIndex';
export * from '@app/_eth/registry';
export * from '@app/_eth/token-registry';

View File

@ -0,0 +1,8 @@
import { Registry } from '@app/_eth/registry';
import {environment} from '@src/environments/environment';
describe('Registry', () => {
it('should create an instance', () => {
expect(new Registry(environment.registryAddress)).toBeTruthy();
});
});

32
src/app/_eth/registry.ts Normal file
View File

@ -0,0 +1,32 @@
// @ts-ignore
import * as registry from '@src/assets/js/block-sync/data/Registry.json';
import {environment} from '@src/environments/environment';
const Web3 = require('web3');
const web3 = new Web3(environment.web3Provider);
const abi = registry.default;
export class Registry {
contractAddress: string;
signerAddress: string;
contract: any;
constructor(contractAddress: string, signerAddress?: string) {
this.contractAddress = contractAddress;
this.contract = new web3.eth.Contract(abi, contractAddress);
if (signerAddress) {
this.signerAddress = signerAddress;
} else {
this.signerAddress = web3.eth.accounts[0];
}
}
public async owner(): Promise<string> {
return await this.contract.methods.owner().call();
}
public async addressOf(identifier: string): Promise<string> {
const id = '0x' + web3.utils.padRight(new Buffer(identifier).toString('hex'), 64);
return await this.contract.methods.addressOf(id).call();
}
}

View File

@ -0,0 +1,8 @@
import { TokenRegistry } from '@app/_eth/token-registry';
import {environment} from '@src/environments/environment';
describe('TokenRegistry', () => {
it('should create an instance', () => {
expect(new TokenRegistry(environment.registryAddress)).toBeTruthy();
});
});

View File

@ -0,0 +1,36 @@
// @ts-ignore
import * as registryClient from '@src/assets/js/block-sync/data/RegistryClient.json';
import Web3 from 'web3';
import {environment} from '@src/environments/environment';
const web3 = new Web3(environment.web3Provider);
const abi = registryClient.default;
export class TokenRegistry {
contractAddress: string;
signerAddress: string;
contract: any;
constructor(contractAddress: string, signerAddress?: string) {
this.contractAddress = contractAddress;
this.contract = new web3.eth.Contract(abi, this.contractAddress);
if (signerAddress) {
this.signerAddress = signerAddress;
} else {
this.signerAddress = web3.eth.accounts[0];
}
}
public async totalTokens(): Promise<number> {
return await this.contract.methods.registryCount().call();
}
public async entry(serial: number): Promise<string> {
return await this.contract.methods.entry(serial).call();
}
public async addressOf(identifier: string): Promise<string> {
const id = '0x' + web3.utils.padRight(new Buffer(identifier).toString('hex'), 64);
return await this.contract.methods.addressOf(id).call();
}
}

View File

@ -12,11 +12,11 @@ export class AuthGuard implements CanActivate {
canActivate( canActivate(
route: ActivatedRouteSnapshot, route: ActivatedRouteSnapshot,
state: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree { state: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
if (localStorage.getItem(atob('CICADA_USER'))) { if (sessionStorage.getItem(btoa('CICADA_SESSION_TOKEN'))) {
return true; return true;
} }
this.router.navigate(['/auth'], { queryParams: { returnUrl: state.url }}); this.router.navigate(['/auth']);
return false; return false;
} }

View File

@ -0,0 +1,5 @@
export class ArraySum {
static arraySum(arr: any[]): number {
return arr.reduce((accumulator, current) => accumulator + current, 0);
}
}

View File

@ -0,0 +1,7 @@
import { GlobalErrorHandler } from './global-error-handler';
describe('GlobalErrorHandler', () => {
it('should create an instance', () => {
// expect(new GlobalErrorHandler()).toBeTruthy();
});
});

View File

@ -0,0 +1,60 @@
import {ErrorHandler, Injectable} from '@angular/core';
import {LoggingService} from '@app/_services/logging.service';
import {HttpErrorResponse} from '@angular/common/http';
@Injectable()
export class GlobalErrorHandler extends ErrorHandler {
private sentencesForWarningLogging: string[] = [];
constructor(private loggingService: LoggingService) {
super();
}
handleError(error: any): void {
this.logError(error);
const message = error.message ? error.message : error.toString();
if (error.status) {
error = new Error(message);
}
const errorTraceString = `Error message:\n${message}.\nStack trace: ${error.stack}`;
const isWarning = this.isWarning(errorTraceString);
if (isWarning) {
this.loggingService.sendWarnLevelMessage(errorTraceString, {error});
} else {
this.loggingService.sendErrorLevelMessage(errorTraceString, this, {error});
}
throw error;
}
logError(error: any): void {
if (error instanceof HttpErrorResponse) {
this.loggingService.sendErrorLevelMessage(
`There was an HTTP error. ${error.message}, Status code: ${(error as HttpErrorResponse).status}`, this, {error});
} else if (error instanceof TypeError) {
this.loggingService.sendErrorLevelMessage(`There was a Type error. ${error.message}`, this, {error});
} else if (error instanceof Error) {
this.loggingService.sendErrorLevelMessage(`There was a general error. ${error.message}`, this, {error});
} else {
this.loggingService.sendErrorLevelMessage('Nobody threw an error but something happened!', this, {error});
}
}
private isWarning(errorTraceString: string): boolean {
let isWarning = true;
if (errorTraceString.includes('/src/app/')) {
isWarning = false;
}
this.sentencesForWarningLogging.forEach((whiteListSentence) => {
if (errorTraceString.includes(whiteListSentence)) {
isWarning = true;
}
});
return isWarning;
}
}

View File

@ -0,0 +1,18 @@
function HttpGetter(): void {}
HttpGetter.prototype.get = filename => new Promise((whohoo, doh) => {
const xhr = new XMLHttpRequest();
xhr.addEventListener('load', (e) => {
if (xhr.status === 200) {
whohoo(xhr.responseText);
return;
}
doh('failed with status ' + xhr.status + ': ' + xhr.statusText);
});
xhr.open('GET', filename);
xhr.send();
});
export {
HttpGetter
};

View File

@ -1,4 +1,6 @@
export * from '@app/_helpers/custom.validator'; export * from '@app/_helpers/custom.validator';
export * from '@app/_helpers/error.interceptor';
export * from '@app/_helpers/custom-error-state-matcher'; export * from '@app/_helpers/custom-error-state-matcher';
export * from '@app/_helpers/pgp-key-store'; export * from '@app/_helpers/mock-backend';
export * from '@app/_helpers/array-sum';
export * from '@app/_helpers/http-getter';
export * from '@app/_helpers/global-error-handler';

View File

@ -0,0 +1,219 @@
import {HTTP_INTERCEPTORS, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest, HttpResponse} from '@angular/common/http';
import {Injectable} from '@angular/core';
import {Observable, of, throwError} from 'rxjs';
import {delay, dematerialize, materialize, mergeMap} from 'rxjs/operators';
const accounts = [
{id: 1, name: 'John Doe', phone: '+25412345678', address: '0xc86ff893ac40d3950b4d5f94a9b837258b0a9865', type: 'user', age: 43, created: '08/16/2020', balance: '12987', failedPinAttempts: 1, status: 'active', bio: 'Bodaboda', category: 'transport', gender: 'male', location: 'Bofu', locationType: 'Rural', token: 'RSV', referrer: '+25412341234'},
{id: 2, name: 'Jane Buck', phone: '+25412341234', address: '0xc86ff893ac40d3950b4d5f94a9b837258b0a9866', type: 'vendor', age: 25, created: '04/02/2020', balance: '56281', failedPinAttempts: 0, status: 'active', bio: 'Groceries', category: 'food/water', gender: 'female', location: 'Lindi', locationType: 'Urban', token: 'ERN', referrer: ''},
{id: 3, name: 'Mc Donald', phone: '+25498765432', address: '0xc86ff893ac40d3950b4d5f94a9b837258b0a9867', type: 'group', age: 31, created: '11/16/2020', balance: '450', failedPinAttempts: 2, status: 'blocked', bio: 'Food', category: 'food/water', gender: 'male', location: 'Miyani', locationType: 'Rural', token: 'RSV', referrer: '+25498769876'},
{id: 4, name: 'Hera Cles', phone: '+25498769876', address: '0xc86ff893ac40d3950b4d5f94a9b837258b0a9868', type: 'user', age: 38, created: '05/28/2020', balance: '5621', failedPinAttempts: 3, status: 'active', bio: 'Shop', category: 'shop', gender: 'female', location: 'Kayaba', locationType: 'Urban', token: 'BRT', referrer: '+25412341234'},
{id: 5, name: 'Silver Fia', phone: '+25462518374', address: '0xc86ff893ac40d3950b4d5f94a9b837258b0a9869', type: 'tokenAgent', age: 19, created: '10/10/2020', balance: '817', failedPinAttempts: 0, status: 'blocked', bio: 'Electronics', category: 'shop', gender: 'male', location: 'Mkanyeni', locationType: 'Rural', token: 'RSV', referrer: '+25412345678'},
];
const actions = [
{ id: 1, user: 'Tom', role: 'enroller', action: 'Disburse RSV 100', approval: false },
{ id: 2, user: 'Christine', role: 'admin', action: 'Change user phone number', approval: true },
{ id: 3, user: 'Will', role: 'superadmin', action: 'Reclaim RSV 1000', approval: true },
{ id: 4, user: 'Vivian', role: 'enroller', action: 'Complete user profile', approval: true },
{ id: 5, user: 'Jack', role: 'enroller', action: 'Reclaim RSV 200', approval: false },
{ id: 6, user: 'Patience', role: 'enroller', action: 'Change user information', approval: false }
];
const histories = [
{ id: 1, userId: 3, userName: 'Mc Donald', action: 'Receive RSV 100', staff: 'Tom', timestamp: Date.now() },
{ id: 2, userId: 5, userName: 'Silver Fia', action: 'Change phone number from +25412345678 to +25498765432', staff: 'Christine', timestamp: Date.now()},
{ id: 3, userId: 4, userName: 'Hera Cles', action: 'Completed user profile', staff: 'Vivian', timestamp: Date.now() },
];
const locations: any = [
{ name: 'Kwale',
districts: [
{ name: 'Kinango',
locations: [
{ name: 'Bofu', villages: ['Bofu', 'Chidzuvini', 'Mkanyeni']},
{ name: 'Mnyenzeni', villages: ['Miloeni', 'Miyani', 'Mnyenzeni', 'Vikolani', 'Vitangani']}
]
}
]
},
{ name: 'Nairobi',
districts: [
{ name: 'Dagorreti',
locations: [
{ name: 'Kawangware', villages: ['Congo']},
]
},
{ name: 'Ngong',
locations: [
{ name: 'Kibera', villages: ['Kibera', 'Lindi']},
]
},
{ name: 'South B',
locations: [
{ name: 'Mukuru', villages: ['Kayaba']},
{ name: 'South B', villages: ['South B']},
]
},
]
}
];
const staffMembers = [
{ id: 1, name: 'admin@acme.org', accountType: 'Admin', created: '17/11/2020', status: 'activated'},
{ id: 2, name: 'will@grassecon.org', accountType: 'SuperAdmin', created: '17/11/2020', status: 'activated'},
{ id: 3, name: 'spence@grassecon.org', accountType: 'Enroller', created: '17/11/2020', status: 'activated'},
{ id: 4, name: 'admin@redcross.org', accountType: 'View', created: '17/11/2020', status: 'activated'}
];
const tokens = [
{name: 'Giftable Reserve', symbol: 'GRZ', address: '0xa686005CE37Dce7738436256982C3903f2E4ea8E', supply: '1000000001000000000000000000', decimals: '18', reserves: {}},
{name: 'Demo Token', symbol: 'DEMO', address: '0xc80D6aFF8194114c52AEcD84c9f15fd5c8abb187', supply: '99999999999999998976', decimals: '18', reserves: {'0xa686005CE37Dce7738436256982C3903f2E4ea8E': {weight: '1000000', balance: '99999999999999998976'}}, reserveRatio: '1000000', owner: '0x3Da99AAD2D9CA01D131eFc3B17444b832B31Ff4a'},
{name: 'Foo Token', symbol: 'FOO', address: '0x9ceD86089f7aBB5A97B40eb0E7521e7aa308d354', supply: '1000000000000000001014', decimals: '18', reserves: {'0xa686005CE37Dce7738436256982C3903f2E4ea8E': {weight: '1000000', balance: '1000000000000000001014'}}, reserveRatio: '1000000', owner: '0x3Da99AAD2D9CA01D131eFc3B17444b832B31Ff4a'},
{name: 'testb', symbol: 'tstb', address: '0xC63cFA91A3BFf41cE31Ff436f67D3ACBC977DB95', supply: '99000', decimals: '18', reserves: {'0xa686005CE37Dce7738436256982C3903f2E4ea8E': {weight: '1000000', balance: '99000'}}, reserveRatio: '1000000', owner: '0x3Da99AAD2D9CA01D131eFc3B17444b832B31Ff4a'},
{name: 'testa', symbol: 'tsta', address: '0x8fA4101ef19D0a078239d035659e92b278bD083C', supply: '9981', decimals: '18', reserves: {'0xa686005CE37Dce7738436256982C3903f2E4ea8E': {weight: '1000000', balance: '9981'}}, reserveRatio: '1000000', owner: '0x3Da99AAD2D9CA01D131eFc3B17444b832B31Ff4a'},
{name: 'testc', symbol: 'tstc', address: '0x4A6fA6bc3BfE4C9661bC692D9798425350C9e3D4', supply: '100990', decimals: '18', reserves: {'0xa686005CE37Dce7738436256982C3903f2E4ea8E': {weight: '1000000', balance: '100990'}}, reserveRatio: '1000000', owner: '0x3Da99AAD2D9CA01D131eFc3B17444b832B31Ff4a'}
];
@Injectable()
export class MockBackendInterceptor implements HttpInterceptor {
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const { url, method, headers, body } = request;
// wrap in delayed observable to simulate server api call\
// call materialize and dematerialize to ensure delay even is thrown
return of(null)
.pipe(mergeMap(handleRoute))
.pipe(materialize())
.pipe(delay(500))
.pipe(dematerialize());
function handleRoute(): Observable<any> {
switch (true) {
case url.endsWith('/accounts') && method === 'GET':
return getAccounts();
case url.match(/\/accounts\/\d+$/) && method === 'GET':
return getAccountById();
case url.endsWith('/actions') && method === 'GET':
return getActions();
case url.match(/\/actions\/\d+$/) && method === 'GET':
return getActionById();
case url.match(/\/actions\/\d+$/) && method === 'POST':
return approveAction();
case url.match(/\/history\/\d+$/) && method === 'GET':
return getHistoryByUser();
case url.endsWith('/locations') && method === 'GET':
return getLocations();
case url.endsWith('/staff') && method === 'GET':
return getStaff();
case url.match(/\/staff\/\d+$/) && method === 'GET':
return getStaffById();
case url.match(/\/staff\/\d+$/) && method === 'POST' && body.status !== undefined:
return changeStaffStatus();
case url.match(/\/staff\/\d+$/) && method === 'POST' && body.accountType !== undefined:
return changeStaffType();
case url.endsWith('/tokens') && method === 'GET':
return getTokens();
case url.match(/\/tokens\/\w+$/) && method === 'GET':
return getTokenBySymbol();
default:
// pass through any requests not handled above
return next.handle(request);
}
}
// route functions
function getAccounts(): Observable<any> {
return ok(accounts);
}
function getAccountById(): Observable<any> {
const queriedAccount = accounts.find(account => account.id === idFromUrl());
return ok(queriedAccount);
}
function getActions(): Observable<any> {
return ok(actions);
}
function getActionById(): Observable<any> {
const queriedAction = actions.find(action => action.id === idFromUrl());
return ok(queriedAction);
}
function approveAction(): Observable<any> {
const queriedAction = actions.find(action => action.id === idFromUrl());
queriedAction.approval = body.approval;
const message = `Action approval status set to ${body.approval} successfully!`;
return ok(message);
}
function getHistoryByUser(): Observable<any> {
const queriedUserHistory = histories.filter(history => history.userId === idFromUrl());
return ok(queriedUserHistory);
}
function getLocations(): Observable<any> {
return ok(locations);
}
function getStaff(): Observable<any> {
return ok(staffMembers);
}
function getStaffById(): Observable<any> {
const queriedStaff = staffMembers.find(staff => staff.id === idFromUrl());
return ok(queriedStaff);
}
function changeStaffStatus(): Observable<any> {
const queriedStaff = staffMembers.find(staff => staff.id === idFromUrl());
queriedStaff.status = body.status;
const message = `Staff account status changed to ${body.status} successfully!`;
return ok(message);
}
function changeStaffType(): Observable<any> {
const queriedStaff = staffMembers.find(staff => staff.id === idFromUrl());
queriedStaff.accountType = body.accountType;
const message = `Staff account type changed to ${body.accountType} successfully!`;
return ok(message);
}
function getTokens(): Observable<any> {
return ok(tokens);
}
function getTokenBySymbol(): Observable<any> {
const queriedToken = tokens.find(token => token.symbol === stringFromUrl());
return ok(queriedToken);
}
// helper functions
function ok(body): Observable<any> {
return of(new HttpResponse({ status: 200, body }));
}
function error(message): Observable<any> {
return throwError({ status: 400, error: { message } });
}
function idFromUrl(): number {
const urlParts = url.split('/');
return parseInt(urlParts[urlParts.length - 1], 10);
}
function stringFromUrl(): string {
const urlParts = url.split('/');
return urlParts[urlParts.length - 1];
}
}
}
export const MockBackendProvider = {
provide: HTTP_INTERCEPTORS,
useClass: MockBackendInterceptor,
multi: true
};

View File

@ -1,6 +1,6 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { ErrorInterceptor } from '@app/_helpers/error.interceptor'; import { ErrorInterceptor } from '@app/_interceptors/error.interceptor';
describe('ErrorInterceptor', () => { describe('ErrorInterceptor', () => {
beforeEach(() => TestBed.configureTestingModule({ beforeEach(() => TestBed.configureTestingModule({

View File

@ -3,23 +3,27 @@ import {
HttpRequest, HttpRequest,
HttpHandler, HttpHandler,
HttpEvent, HttpEvent,
HttpInterceptor HttpInterceptor, HttpErrorResponse
} from '@angular/common/http'; } from '@angular/common/http';
import {Observable, throwError} from 'rxjs'; import {Observable, throwError} from 'rxjs';
import {catchError} from 'rxjs/operators'; import {catchError} from 'rxjs/operators';
import {ErrorDialogService} from '@app/_services';
@Injectable() @Injectable()
export class ErrorInterceptor implements HttpInterceptor { export class ErrorInterceptor implements HttpInterceptor {
constructor() {} constructor(private errorDialogService: ErrorDialogService) {}
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> { intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
return next.handle(request).pipe(catchError(err => { return next.handle(request).pipe(catchError((err: HttpErrorResponse) => {
this.errorDialogService.openDialog({
message: err.error.message || err.statusText,
status: err.status
});
if ([401, 403].indexOf(err.status) !== -1) { if ([401, 403].indexOf(err.status) !== -1) {
location.reload(true); location.reload(true);
} }
const error = err.error.message || err.statusText; return throwError(err);
return throwError(error);
})); }));
} }
} }

View File

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { HttpConfigInterceptor } from './http-config.interceptor';
describe('HttpConfigInterceptor', () => {
beforeEach(() => TestBed.configureTestingModule({
providers: [
HttpConfigInterceptor
]
}));
it('should be created', () => {
const interceptor: HttpConfigInterceptor = TestBed.inject(HttpConfigInterceptor);
expect(interceptor).toBeTruthy();
});
});

View File

@ -0,0 +1,29 @@
import { Injectable } from '@angular/core';
import {
HttpRequest,
HttpHandler,
HttpEvent,
HttpInterceptor
} from '@angular/common/http';
import { Observable } from 'rxjs';
@Injectable()
export class HttpConfigInterceptor implements HttpInterceptor {
constructor() {}
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
const token = sessionStorage.getItem(btoa('CICADA_SESSION_TOKEN'));
if (token) {
request = request.clone({headers: request.headers.set('Authorization', 'Bearer ' + token)});
}
if (!request.headers.has('Content-Type')) {
request = request.clone({headers: request.headers.set('Content-Type', 'application/json')});
}
request = request.clone({headers: request.headers.set('Accept', 'application/json')});
return next.handle(request);
}
}

View File

@ -0,0 +1,3 @@
export * from '@app/_interceptors/error.interceptor';
export * from '@app/_interceptors/http-config.interceptor';
export * from '@app/_interceptors/logging.interceptor';

View File

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { LoggingInterceptor } from './logging.interceptor';
describe('LoggingInterceptor', () => {
beforeEach(() => TestBed.configureTestingModule({
providers: [
LoggingInterceptor
]
}));
it('should be created', () => {
const interceptor: LoggingInterceptor = TestBed.inject(LoggingInterceptor);
expect(interceptor).toBeTruthy();
});
});

View File

@ -0,0 +1,37 @@
import { Injectable } from '@angular/core';
import {
HttpRequest,
HttpHandler,
HttpEvent,
HttpInterceptor,
HttpResponse
} from '@angular/common/http';
import {Observable} from 'rxjs';
import {LoggingService} from '@app/_services/logging.service';
import {finalize, tap} from 'rxjs/operators';
@Injectable()
export class LoggingInterceptor implements HttpInterceptor {
constructor(
private loggingService: LoggingService
) {}
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
this.loggingService.sendInfoLevelMessage(request);
const startTime = Date.now();
let status: string;
return next.handle(request).pipe(tap(event => {
status = '';
if (event instanceof HttpResponse) {
status = 'succeeded';
}
}, error => status = 'failed'),
finalize(() => {
const elapsedTime = Date.now() - startTime;
const message = `${request.method} request for ${request.urlWithParams} ${status} in ${elapsedTime} ms`;
this.loggingService.sendInfoLevelMessage(message);
}));
}
}

View File

@ -0,0 +1,96 @@
export interface AccountDetails {
date_registered: number;
gender: string;
age?: string;
type?: string;
identities: {
evm: {
'bloxberg:8996': string[];
'oldchain:1': string[];
};
latitude: number;
longitude: number;
};
location: {
area?: string;
area_name: string;
area_type?: string;
};
products: string[];
category?: string;
vcard: {
email: [{
value: string;
}];
fn: [{
value: string;
}];
n: [{
value: string[];
}];
tel: [{
meta: {
TYP: string[];
},
value: string;
}],
version: [{
value: string;
}];
};
}
export interface Signature {
algo: string;
data: string;
digest: string;
engine: string;
}
export interface Meta {
data: AccountDetails;
id: string;
signature: Signature;
}
export interface MetaResponse {
id: string;
m: Meta;
}
export const defaultAccount: AccountDetails = {
date_registered: Date.now(),
gender: 'other',
identities: {
evm: {
'bloxberg:8996': [''],
'oldchain:1': [''],
},
latitude: 0,
longitude: 0,
},
location: {
area_name: 'Kilifi',
},
products: [],
vcard: {
email: [{
value: '',
}],
fn: [{
value: 'GE',
}],
n: [{
value: ['GE'],
}],
tel: [{
meta: {
TYP: [],
},
value: '',
}],
version: [{
value: '3.0',
}],
},
};

4
src/app/_models/index.ts Normal file
View File

@ -0,0 +1,4 @@
export * from '@app/_models/transaction';
export * from '@app/_models/settings';
export * from '@app/_models/user';
export * from '@app/_models/account';

View File

@ -0,0 +1,18 @@
export class Settings {
w3: W3 = {
engine: undefined,
provider: undefined,
};
scanFilter: any;
registry: any;
txHelper: any;
constructor(scanFilter: any) {
this.scanFilter = scanFilter;
}
}
export class W3 {
engine: any;
provider: any;
}

2
src/app/_models/staff.ts Normal file
View File

@ -0,0 +1,2 @@
export interface Staff {
}

15
src/app/_models/token.ts Normal file
View File

@ -0,0 +1,15 @@
export interface Token {
name: string;
symbol: string;
address: string;
supply: string;
decimals: string;
reserves: {
'0xa686005CE37Dce7738436256982C3903f2E4ea8E'?: {
weight: string;
balance: string;
}
};
reserveRatio?: string;
owner?: string;
}

View File

@ -0,0 +1,43 @@
import {User} from '@app/_models/user';
export class BlocksBloom {
low: number;
blockFilter: string;
blocktxFilter: string;
alg: string;
filterRounds: number;
}
export class Token {
address: string;
name: string;
symbol: string;
}
export class Tx {
block: number;
success: boolean;
timestamp: number;
txHash: string;
txIndex: number;
}
export class Transaction {
from: string;
sender: User;
to: string;
recipient: User;
token: Token;
tx: Tx;
value: number;
}
export class Conversion {
destinationToken: Token;
fromValue: number;
sourceToken: Token;
toValue: number;
trader: string;
user: User;
tx: Tx;
}

22
src/app/_models/user.ts Normal file
View File

@ -0,0 +1,22 @@
export class User {
dateRegistered: number;
vcard: {
fn: string;
version: string;
tel: [{
meta: {
TYP: string;
};
value: string[];
}];
};
key: {
ethereum: string[];
};
location: {
latitude: string;
longitude: string;
external: {};
};
selling: string[];
}

2
src/app/_pgp/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from '@app/_pgp/pgp-key-store';
export * from '@app/_pgp/pgp-signer';

View File

@ -1,4 +1,4 @@
import { MutablePgpKeyStore } from '@app/_helpers/pgp-key-store'; import { MutablePgpKeyStore } from '@app/_pgp/pgp-key-store';
describe('PgpKeyStore', () => { describe('PgpKeyStore', () => {
it('should create an instance', () => { it('should create an instance', () => {

View File

@ -1,6 +1,6 @@
import { KeyStore } from 'cic-client-meta';
const openpgp = require('openpgp'); const openpgp = require('openpgp');
const keyring = new openpgp.Keyring(); const keyring = new openpgp.Keyring();
import { KeyStore } from '@src/assets/js/cic-meta/auth';
interface MutableKeyStore extends KeyStore { interface MutableKeyStore extends KeyStore {
loadKeyring(): Promise<void>; loadKeyring(): Promise<void>;

View File

@ -0,0 +1,9 @@
import { PGPSigner } from '@app/_pgp/pgp-signer';
import {MutableKeyStore, MutablePgpKeyStore} from '@app/_pgp/pgp-key-store';
const keystore: MutableKeyStore = new MutablePgpKeyStore();
describe('PgpSigner', () => {
it('should create an instance', () => {
expect(new PGPSigner(keystore)).toBeTruthy();
});
});

110
src/app/_pgp/pgp-signer.ts Normal file
View File

@ -0,0 +1,110 @@
import {MutableKeyStore} from '@app/_pgp/pgp-key-store';
import {LoggingService} from '@app/_services/logging.service';
const openpgp = require('openpgp');
interface Signable {
digest(): string;
}
type Signature = {
engine: string
algo: string
data: string
digest: string;
};
interface Signer {
onsign(signature: Signature): void;
onverify(flag: boolean): void;
fingerprint(): string;
prepare(material: Signable): boolean;
verify(digest: string, signature: Signature): void;
sign(digest: string): Promise<void>;
}
class PGPSigner implements Signer {
engine = 'pgp';
algo = 'sha256';
dgst: string;
signature: Signature;
keyStore: MutableKeyStore;
onsign: (signature: Signature) => void;
onverify: (flag: boolean) => void;
loggingService: LoggingService;
constructor(keyStore: MutableKeyStore) {
this.keyStore = keyStore;
this.onsign = (signature: Signature) => {};
this.onverify = (flag: boolean) => {};
}
public fingerprint(): string {
return this.keyStore.getFingerprint();
}
public prepare(material: Signable): boolean {
this.dgst = material.digest();
return true;
}
public verify(digest: string, signature: Signature): void {
openpgp.signature.readArmored(signature.data).then((sig) => {
const opts = {
message: openpgp.cleartext.fromText(digest),
publicKeys: this.keyStore.getTrustedKeys(),
signature: sig,
};
openpgp.verify(opts).then((v) => {
let i = 0;
for (i = 0; i < v.signatures.length; i++) {
const s = v.signatures[i];
if (s.valid) {
this.onverify(s);
return;
}
}
this.loggingService.sendErrorLevelMessage(`Checked ${i} signature(s) but none valid`, this, {error: '404 Not found!'});
this.onverify(false);
});
}).catch((e) => {
this.loggingService.sendErrorLevelMessage(e.message, this, {error: e});
this.onverify(false);
});
}
public async sign(digest: string): Promise<void> {
const m = openpgp.cleartext.fromText(digest);
const pk = this.keyStore.getPrivateKey();
if (!pk.isDecrypted()) {
const password = window.prompt('password');
await pk.decrypt(password);
}
const opts = {
message: m,
privateKeys: [pk],
detached: true,
};
openpgp.sign(opts).then((s) => {
this.signature = {
engine: this.engine,
algo: this.algo,
data: s.signature,
// TODO: fix for browser later
digest,
};
this.onsign(this.signature);
}).catch((e) => {
this.loggingService.sendErrorLevelMessage(e.message, this, {error: e});
this.onsign(undefined);
});
}
}
export {
Signable,
Signature,
Signer,
PGPSigner
};

View File

@ -1,11 +1,12 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import {MutableKeyStore, MutablePgpKeyStore} from '@app/_helpers';
import { hobaParseChallengeHeader } from '@src/assets/js/hoba.js'; import { hobaParseChallengeHeader } from '@src/assets/js/hoba.js';
import { signChallenge } from '@src/assets/js/hoba-pgp.js'; import { signChallenge } from '@src/assets/js/hoba-pgp.js';
import {environment} from '@src/environments/environment'; import {environment} from '@src/environments/environment';
import {HttpClient} from '@angular/common/http'; import {LoggingService} from '@app/_services/logging.service';
import {HttpWrapperService} from '@app/_services/http-wrapper.service';
const origin = 'http://localhost:4444'; import {MutableKeyStore, MutablePgpKeyStore} from '@app/_pgp';
import {ErrorDialogService} from '@app/_services/error-dialog.service';
import {first} from 'rxjs/operators';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -17,7 +18,9 @@ export class AuthService {
mutableKeyStore: MutableKeyStore = new MutablePgpKeyStore(); mutableKeyStore: MutableKeyStore = new MutablePgpKeyStore();
constructor( constructor(
private http: HttpClient private httpWrapperService: HttpWrapperService,
private loggingService: LoggingService,
private errorDialogService: ErrorDialogService
) { ) {
if (sessionStorage.getItem(btoa('CICADA_SESSION_TOKEN'))) { if (sessionStorage.getItem(btoa('CICADA_SESSION_TOKEN'))) {
this.sessionToken = sessionStorage.getItem(btoa('CICADA_SESSION_TOKEN')); this.sessionToken = sessionStorage.getItem(btoa('CICADA_SESSION_TOKEN'));
@ -28,13 +31,13 @@ export class AuthService {
} }
setState(s): void { setState(s): void {
(document.getElementById('state') as HTMLInputElement).value = s; document.getElementById('state').innerHTML = s;
} }
getWithToken(): void { getWithToken(): void {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.responseType = 'text'; xhr.responseType = 'text';
xhr.open('GET', origin + window.location.search.substring(1)); xhr.open('GET', environment.cicAuthUrl + window.location.search.substring(1));
xhr.setRequestHeader('Authorization', 'Bearer ' + this.sessionToken); xhr.setRequestHeader('Authorization', 'Bearer ' + this.sessionToken);
xhr.setRequestHeader('Content-Type', 'application/json'); xhr.setRequestHeader('Content-Type', 'application/json');
xhr.setRequestHeader('x-cic-automerge', 'none'); xhr.setRequestHeader('x-cic-automerge', 'none');
@ -43,8 +46,7 @@ export class AuthService {
throw new Error('login rejected'); throw new Error('login rejected');
} }
this.sessionLoginCount++; this.sessionLoginCount++;
this.setState('click to perform login ' + this.sessionLoginCount + ' with token ' + this.sessionToken); this.setState('Click button to perform login ' + this.sessionLoginCount + ' with token ' + this.sessionToken);
console.log('received', xhr.responseText);
return; return;
}); });
xhr.send(); xhr.send();
@ -53,7 +55,7 @@ export class AuthService {
sendResponse(hobaResponseEncoded): void { sendResponse(hobaResponseEncoded): void {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.responseType = 'text'; xhr.responseType = 'text';
xhr.open('GET', origin + window.location.search.substring(1)); xhr.open('GET', environment.cicAuthUrl + window.location.search.substring(1));
xhr.setRequestHeader('Authorization', 'HOBA ' + hobaResponseEncoded); xhr.setRequestHeader('Authorization', 'HOBA ' + hobaResponseEncoded);
xhr.setRequestHeader('Content-Type', 'application/json'); xhr.setRequestHeader('Content-Type', 'application/json');
xhr.setRequestHeader('x-cic-automerge', 'none'); xhr.setRequestHeader('x-cic-automerge', 'none');
@ -64,8 +66,7 @@ export class AuthService {
this.sessionToken = xhr.getResponseHeader('Token'); this.sessionToken = xhr.getResponseHeader('Token');
sessionStorage.setItem(btoa('CICADA_SESSION_TOKEN'), this.sessionToken); sessionStorage.setItem(btoa('CICADA_SESSION_TOKEN'), this.sessionToken);
this.sessionLoginCount++; this.sessionLoginCount++;
this.setState('click to perform login ' + this.sessionLoginCount + ' with token ' + this.sessionToken); this.setState('Click button to perform login ' + this.sessionLoginCount + ' with token ' + this.sessionToken);
console.log('received', xhr.responseText);
return; return;
}); });
xhr.send(); xhr.send();
@ -74,7 +75,7 @@ export class AuthService {
getChallenge(): void { getChallenge(): void {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.responseType = 'arraybuffer'; xhr.responseType = 'arraybuffer';
xhr.open('GET', origin + window.location.search.substring(1)); xhr.open('GET', environment.cicAuthUrl + window.location.search.substring(1));
xhr.onload = (e) => { xhr.onload = (e) => {
if (xhr.status === 401) { if (xhr.status === 401) {
const authHeader = xhr.getResponseHeader('WWW-Authenticate'); const authHeader = xhr.getResponseHeader('WWW-Authenticate');
@ -92,14 +93,13 @@ export class AuthService {
this.getWithToken(); this.getWithToken();
return true; return true;
} catch (e) { } catch (e) {
console.error('login token failed', e); this.loggingService.sendErrorLevelMessage('Login token failed', this, {error: e});
} }
} else { } else {
try { try {
const o = this.getChallenge(); this.getChallenge();
return true;
} catch (e) { } catch (e) {
console.error('login challenge failed', e); this.loggingService.sendErrorLevelMessage('Login challenge failed', this, {error: e});
} }
} }
return false; return false;
@ -107,23 +107,26 @@ export class AuthService {
async loginResponse(o): Promise<any> { async loginResponse(o): Promise<any> {
const r = await signChallenge(o.challenge, o.realm, origin, this.mutableKeyStore); const r = await signChallenge(o.challenge, o.realm, environment.cicAuthUrl, this.mutableKeyStore);
this.sendResponse(r); this.sendResponse(r);
} }
loginView(): void { loginView(): void {
document.getElementById('one').style.display = 'none'; document.getElementById('one').style.display = 'none';
document.getElementById('two').style.display = 'block'; document.getElementById('two').style.display = 'block';
this.setState('click to log in with PGP key ' + this.mutableKeyStore.getPrivateKeyId()); this.setState('Click button to log in with PGP key ' + this.mutableKeyStore.getPrivateKeyId());
} }
async setKey(privateKeyArmored): Promise<boolean> { async setKey(privateKeyArmored): Promise<boolean> {
console.log('settings pk' + privateKeyArmored);
try { try {
await this.mutableKeyStore.importPrivateKey(privateKeyArmored); await this.mutableKeyStore.importPrivateKey(privateKeyArmored);
localStorage.setItem(btoa('CICADA_PRIVATE_KEY'), privateKeyArmored); localStorage.setItem(btoa('CICADA_PRIVATE_KEY'), privateKeyArmored);
} catch (e) { } catch (err) {
console.error('failed setting key', e); this.loggingService.sendErrorLevelMessage('Failed setting key', this, {error: err});
this.errorDialogService.openDialog({
message: `Failed to set key, Enter your private key again. Reason: ${err.error.message || err.statusText}`,
status: err.status
});
return false; return false;
} }
this.loginView(); this.loginView();
@ -136,10 +139,10 @@ export class AuthService {
} }
async getPublicKeys(): Promise<void> { async getPublicKeys(): Promise<void> {
this.http.get(`${environment.publicKeysUrl}/keys.asc`).subscribe(async res => { this.httpWrapperService.get(`${environment.publicKeysUrl}`).pipe(first()).subscribe(async res => {
await this.mutableKeyStore.importPublicKey(res); await this.mutableKeyStore.importPublicKey(res.body);
}, error => { }, error => {
console.error('There was an error!', error); this.loggingService.sendErrorLevelMessage('There was an error fetching public keys!', this, {error});
}); });
if (this.privateKey !== undefined) { if (this.privateKey !== undefined) {
await this.mutableKeyStore.importPrivateKey(this.privateKey); await this.mutableKeyStore.importPrivateKey(this.privateKey);

View File

@ -0,0 +1,22 @@
import { TestBed } from '@angular/core/testing';
import { BlockSyncService } from '@app/_services/block-sync.service';
import {TransactionService} from '@app/_services/transaction.service';
import {TransactionServiceStub} from '@src/testing';
describe('BlockSyncService', () => {
let service: BlockSyncService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
{ provide: TransactionService, useClass: TransactionServiceStub }
]
});
service = TestBed.inject(BlockSyncService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,116 @@
import {Injectable} from '@angular/core';
import {Settings} from '@app/_models';
import Web3 from 'web3';
import {CICRegistry, TransactionHelper} from 'cic-client';
import {first} from 'rxjs/operators';
import {TransactionService} from '@app/_services/transaction.service';
import {environment} from '@src/environments/environment';
import {HttpGetter} from '@app/_helpers';
import {LoggingService} from '@app/_services/logging.service';
@Injectable({
providedIn: 'root'
})
export class BlockSyncService {
readyStateTarget: number = 2;
readyState: number = 0;
fileGetter = new HttpGetter();
constructor(
private transactionService: TransactionService,
private loggingService: LoggingService
) { }
blockSync(address: string = null, offset: number = 0, limit: number = 100): any {
this.transactionService.resetTransactionsList();
const settings = new Settings(this.scan);
const provider = environment.web3Provider;
const readyStateElements = { network: 2 };
settings.w3.provider = provider;
settings.w3.engine = new Web3(provider);
settings.registry = new CICRegistry(settings.w3.engine, environment.registryAddress, this.fileGetter,
['../../assets/js/block-sync/data']);
settings.registry.declaratorHelper.addTrust(environment.trustedDeclaratorAddress);
settings.txHelper = new TransactionHelper(settings.w3.engine, settings.registry);
settings.txHelper.ontransfer = async (transaction: any): Promise<void> => {
window.dispatchEvent(this.newTransferEvent(transaction));
};
settings.txHelper.onconversion = async (transaction: any): Promise<any> => {
window.dispatchEvent(this.newConversionEvent(transaction));
};
settings.registry.onload = (addressReturned: number): void => {
this.loggingService.sendInfoLevelMessage(`Loaded network contracts ${addressReturned}`);
this.readyStateProcessor(settings, readyStateElements.network, address, offset, limit);
};
settings.registry.load();
}
readyStateProcessor(settings: Settings, bit: number, address: string, offset: number, limit: number): void {
this.readyState |= bit;
if (this.readyStateTarget === this.readyState && this.readyStateTarget) {
const wHeadSync = new Worker('./../assets/js/block-sync/head.js');
wHeadSync.onmessage = (m) => {
settings.txHelper.processReceipt(m.data);
};
wHeadSync.postMessage({
w3_provider: settings.w3.provider,
});
if (address === null) {
this.transactionService.getAllTransactions(offset, limit).pipe(first()).subscribe(res => {
this.fetcher(settings, res.body);
});
} else {
this.transactionService.getAddressTransactions(address, offset, limit).pipe(first()).subscribe(res => {
this.fetcher(settings, res.body);
});
}
}
}
newTransferEvent(tx): any {
return new CustomEvent('cic_transfer', {
detail: {
tx,
},
});
}
newConversionEvent(tx): any {
return new CustomEvent('cic_convert', {
detail: {
tx,
},
});
}
async scan(settings, lo, hi, bloomBlockBytes, bloomBlocktxBytes, bloomRounds): Promise<void> {
const w = new Worker('./../assets/js/block-sync/ondemand.js');
w.onmessage = (m) => {
settings.txHelper.processReceipt(m.data);
};
w.postMessage({
w3_provider: settings.w3.provider,
lo,
hi,
filters: [
bloomBlockBytes,
bloomBlocktxBytes,
],
filter_rounds: bloomRounds,
});
}
fetcher(settings: Settings, transactionsInfo: any): void {
const blockFilterBinstr = window.atob(transactionsInfo.block_filter);
const bOne = new Uint8Array(blockFilterBinstr.length);
bOne.map((e, i, v) => v[i] = blockFilterBinstr.charCodeAt(i));
const blocktxFilterBinstr = window.atob(transactionsInfo.blocktx_filter);
const bTwo = new Uint8Array(blocktxFilterBinstr.length);
bTwo.map((e, i, v) => v[i] = blocktxFilterBinstr.charCodeAt(i));
settings.scanFilter(settings, transactionsInfo.low, transactionsInfo.high, bOne, bTwo, transactionsInfo.filter_rounds);
}
}

View File

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { ErrorDialogService } from './error-dialog.service';
describe('ErrorDialogService', () => {
let service: ErrorDialogService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(ErrorDialogService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,33 @@
import { Injectable } from '@angular/core';
import {MatDialog} from '@angular/material/dialog';
import {ErrorDialogComponent} from '@app/shared/error-dialog/error-dialog.component';
import {LoggingService} from '@app/_services/logging.service';
@Injectable({
providedIn: 'root'
})
export class ErrorDialogService {
public isDialogOpen: boolean = false;
constructor(
public dialog: MatDialog,
private loggingService: LoggingService
) { }
openDialog(data): any {
if (this.isDialogOpen) {
return false;
}
this.isDialogOpen = true;
const dialogRef = this.dialog.open(ErrorDialogComponent, {
width: '300px',
data
});
dialogRef.afterClosed().subscribe(result => {
this.loggingService.sendInfoLevelMessage('The dialog was closed');
this.isDialogOpen = false;
const res = result;
});
}
}

View File

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { HttpWrapperService } from './http-wrapper.service';
describe('HttpWrapperService', () => {
let service: HttpWrapperService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(HttpWrapperService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,60 @@
import { Injectable } from '@angular/core';
import {HttpClient, HttpRequest} from '@angular/common/http';
import {Observable} from 'rxjs';
import * as moment from 'moment';
import { Moment } from 'moment';
import {LoggingService} from '@app/_services/logging.service';
@Injectable({
providedIn: 'root'
})
export class HttpWrapperService {
constructor(
private http: HttpClient,
private loggingService: LoggingService,
) { }
get(url: string, options?: any): Observable<Response> {
return this.request('GET', url, null, options);
}
post(url: string, body: any, options?: any): Observable<Response> {
return this.request('POST', url, body, options);
}
put(url: string, body: any, options?: any): Observable<Response> {
return this.request('PUT', url, body, options);
}
delete(url: string, options?: any): Observable<Response> {
return this.request('DELETE', url, null, options);
}
private logTime(startMoment: Moment, url: string, method: string): void {
const requestDuration = moment().diff(startMoment, 'milliseconds');
this.loggingService.sendInfoLevelMessage(`HTTP ${method}, URL: ${url}, Duration: ${requestDuration} ms`);
}
private request(method: string, url: string, body?: any, options?: any): Observable<any> {
this.loggingService.sendInfoLevelMessage(`Options: ${options}`);
return Observable.create((observer: any) => {
const requestBeginTime = moment();
this.http.request(new HttpRequest(method, url, body, options)).subscribe((response) => {
this.loggingService.sendInfoLevelMessage(response);
this.logTime(requestBeginTime, `${url}`, method);
observer.next(response);
observer.complete();
}, (error) => {
switch (error.status) {
case 403:
observer.complete();
break;
default:
observer.error(error);
break;
}
});
});
}
}

View File

@ -1 +1,9 @@
export * from '@app/_services/auth.service'; export * from '@app/_services/auth.service';
export * from '@app/_services/transaction.service';
export * from '@app/_services/user.service';
export * from '@app/_services/token.service';
export * from '@app/_services/block-sync.service';
export * from '@app/_services/location.service';
export * from '@app/_services/logging.service';
export * from '@app/_services/http-wrapper.service';
export * from '@app/_services/error-dialog.service';

View File

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { LocationService } from '@app/_services/location.service';
describe('LocationService', () => {
let service: LocationService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(LocationService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,22 @@
import { Injectable } from '@angular/core';
import {BehaviorSubject} from 'rxjs';
import {environment} from '@src/environments/environment';
import {first} from 'rxjs/operators';
import {HttpWrapperService} from '@app/_services/http-wrapper.service';
@Injectable({
providedIn: 'root'
})
export class LocationService {
locations: any = '';
private locationsList = new BehaviorSubject<any>(this.locations);
locationsSubject = this.locationsList.asObservable();
constructor(
private httpWrapperService: HttpWrapperService,
) { }
getLocations(): void {
this.httpWrapperService.get(`${environment.cicCacheUrl}/locations`).pipe(first()).subscribe(res => this.locationsList.next(res.body));
}
}

View File

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { LoggingService } from './logging.service';
describe('LoggingService', () => {
let service: LoggingService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(LoggingService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,48 @@
import {Injectable} from '@angular/core';
import {NGXLogger} from 'ngx-logger';
import {environment} from '@src/environments/environment';
@Injectable({
providedIn: 'root'
})
export class LoggingService {
env: string;
canDebug: boolean;
constructor(private logger: NGXLogger) {
// TRACE|DEBUG|INFO|LOG|WARN|ERROR|FATAL|OFF
this.env = environment.production ? 'Production' : 'Development';
if (this.env === 'Development') {
this.sendInfoLevelMessage('Dropping into debug mode');
}
}
sendTraceLevelMessage(message, source, error): void {
this.logger.trace(message, source, error);
}
sendDebugLevelMessage(message, source, error): void {
this.logger.debug(message, source, error);
}
sendInfoLevelMessage(message): void {
this.logger.info(message);
}
sendLogLevelMessage(message, source, error): void {
this.logger.log(message, source, error);
}
sendWarnLevelMessage(message, error): void {
this.logger.warn(message, error);
}
sendErrorLevelMessage(message, source, error): void {
this.logger.error(message, source, error);
}
sendFatalLevelMessage(message, source, error): void {
this.logger.fatal(message, source, error);
}
}

View File

@ -0,0 +1,24 @@
import { TestBed } from '@angular/core/testing';
import { TokenService } from '@app/_services/token.service';
describe('TokenService', () => {
let service: TokenService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(TokenService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should return token for available token', () => {
expect(service.getTokenBySymbol('RSV')).toEqual({ name: 'Reserve', symbol: 'RSV' });
});
it('should not return token for unavailable token', () => {
expect(service.getTokenBySymbol('ABC')).toBeUndefined();
});
});

View File

@ -0,0 +1,41 @@
import { Injectable } from '@angular/core';
import {environment} from '@src/environments/environment';
import {BehaviorSubject, Observable} from 'rxjs';
import {HttpGetter} from '@app/_helpers';
import {CICRegistry} from 'cic-client';
import Web3 from 'web3';
import {HttpWrapperService} from '@app/_services/http-wrapper.service';
import {Registry, TokenRegistry} from '@app/_eth';
@Injectable({
providedIn: 'root'
})
export class TokenService {
web3 = new Web3(environment.web3Provider);
fileGetter = new HttpGetter();
registry = new Registry(environment.registryAddress);
cicRegistry = new CICRegistry(this.web3, environment.registryAddress, this.fileGetter, ['../../assets/js/block-sync/data']);
tokens: any = '';
private tokensList = new BehaviorSubject<any>(this.tokens);
tokensSubject = this.tokensList.asObservable();
constructor(
private httpWrapperService: HttpWrapperService,
) { }
async getTokens(): Promise<any> {
const tokenRegistryQuery = new TokenRegistry(await this.registry.addressOf('TokenRegistry'));
const count = await tokenRegistryQuery.totalTokens();
return Array.from({length: count}, async (v, i) => await tokenRegistryQuery.entry(i));
}
getTokenBySymbol(symbol: string): Observable<any> {
return this.httpWrapperService.get(`${environment.cicCacheUrl}/tokens/${symbol}`);
}
async getTokenBalance(address: string): Promise<number> {
const tokenRegistryQuery = new TokenRegistry(await this.registry.addressOf('TokenRegistry'));
const sarafuToken = await this.cicRegistry.addToken(await tokenRegistryQuery.entry(0));
return await sarafuToken.methods.balanceOf(address).call();
}
}

View File

@ -0,0 +1,24 @@
import { TestBed } from '@angular/core/testing';
import { TransactionService } from '@app/_services/transaction.service';
import {HttpClient} from '@angular/common/http';
import {HttpClientTestingModule, HttpTestingController} from '@angular/common/http/testing';
describe('TransactionService', () => {
let httpClient: HttpClient;
let httpTestingController: HttpTestingController;
let service: TransactionService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule]
});
httpClient = TestBed.inject(HttpClient);
httpTestingController = TestBed.inject(HttpTestingController);
service = TestBed.inject(TransactionService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,135 @@
import { Injectable } from '@angular/core';
import {first} from 'rxjs/operators';
import {BehaviorSubject, Observable} from 'rxjs';
import {environment} from '@src/environments/environment';
import {Envelope, User} from 'cic-client-meta';
import {UserService} from '@app/_services/user.service';
import { Keccak } from 'sha3';
import { utils } from 'ethers';
import {add0x, fromHex, strip0x, toHex} from '@src/assets/js/ethtx/dist/hex';
import {Tx} from '@src/assets/js/ethtx/dist';
import {toValue} from '@src/assets/js/ethtx/dist/tx';
import * as secp256k1 from 'secp256k1';
import {AuthService} from '@app/_services/auth.service';
import {defaultAccount} from '@app/_models';
import {LoggingService} from '@app/_services/logging.service';
import {HttpWrapperService} from '@app/_services/http-wrapper.service';
import {Registry} from '@app/_eth';
const Web3 = require('web3');
const vCard = require('vcard-parser');
@Injectable({
providedIn: 'root'
})
export class TransactionService {
transactions: any[] = [];
private transactionList = new BehaviorSubject<any[]>(this.transactions);
transactionsSubject = this.transactionList.asObservable();
userInfo: any;
web3 = new Web3(environment.web3Provider);
registry = new Registry(environment.registryAddress);
constructor(
private httpWrapperService: HttpWrapperService,
private authService: AuthService,
private userService: UserService,
private loggingService: LoggingService
) { }
getAllTransactions(offset: number, limit: number): Observable<any> {
return this.httpWrapperService.get(`${environment.cicCacheUrl}/tx/${offset}/${limit}`);
}
getAddressTransactions(address: string, offset: number, limit: number): Observable<any> {
return this.httpWrapperService.get(`${environment.cicCacheUrl}/tx/${address}/${offset}/${limit}`);
}
async setTransaction(transaction, cacheSize: number): Promise<void> {
if (this.transactions.find(cachedTx => cachedTx.tx.txHash === transaction.tx.txHash)) { return; }
transaction.value = Number(transaction.value);
transaction.type = 'transaction';
try {
this.userService.getAccountDetailsFromMeta(await User.toKey(transaction.from)).pipe(first()).subscribe((res) => {
transaction.sender = this.getAccountInfo(res.body);
}, error => {
transaction.sender = defaultAccount;
});
this.userService.getAccountDetailsFromMeta(await User.toKey(transaction.to)).pipe(first()).subscribe((res) => {
transaction.recipient = this.getAccountInfo(res.body);
}, error => {
transaction.recipient = defaultAccount;
});
} finally {
this.addTransaction(transaction, cacheSize);
}
}
async setConversion(conversion, cacheSize): Promise<void> {
if (this.transactions.find(cachedTx => cachedTx.tx.txHash === conversion.tx.txHash)) { return; }
conversion.type = 'conversion';
conversion.fromValue = Number(conversion.fromValue);
conversion.toValue = Number(conversion.toValue);
try {
this.userService.getAccountDetailsFromMeta(await User.toKey(conversion.trader)).pipe(first()).subscribe((res) => {
conversion.sender = conversion.recipient = this.getAccountInfo(res.body);
}, error => {
conversion.sender = conversion.recipient = defaultAccount;
});
} finally {
this.addTransaction(conversion, cacheSize);
}
}
addTransaction(transaction, cacheSize: number): void {
this.transactions.unshift(transaction);
if (this.transactions.length > cacheSize) {
this.transactions.length = cacheSize;
}
this.transactionList.next(this.transactions);
}
resetTransactionsList(): void {
this.transactions = [];
this.transactionList.next(this.transactions);
}
getAccountInfo(account: string): any {
let accountInfo = Envelope.fromJSON(JSON.stringify(account)).unwrap().m.data;
accountInfo.vcard = vCard.parse(atob(accountInfo.vcard));
return accountInfo;
}
async transferRequest(tokenAddress: string, senderAddress: string, recipientAddress: string, value: number): Promise<any> {
const transferAuthAddress = await this.registry.addressOf('TransferAuthorization');
const hashFunction = new Keccak(256);
hashFunction.update('createRequest(address,address,address,uint256)');
const hash = hashFunction.digest();
const methodSignature = hash.toString('hex').substring(0, 8);
const abiCoder = new utils.AbiCoder();
const abi = await abiCoder.encode(['address', 'address', 'address', 'uint256'], [senderAddress, recipientAddress, tokenAddress, value]);
const data = fromHex(methodSignature + strip0x(abi));
const tx = new Tx(environment.bloxbergChainId);
tx.nonce = await this.web3.eth.getTransactionCount(senderAddress);
tx.gasPrice = await this.web3.eth.getGasPrice();
tx.gasLimit = 8000000;
tx.to = fromHex(strip0x(transferAuthAddress));
tx.value = toValue(value);
tx.data = data;
const txMsg = tx.message();
const privateKey = this.authService.mutableKeyStore.getPrivateKey();
if (!privateKey.isDecrypted()) {
const password = window.prompt('password');
await privateKey.decrypt(password);
}
const signatureObject = secp256k1.ecdsaSign(txMsg, privateKey.keyPacket.privateParams.d);
const r = signatureObject.signature.slice(0, 32);
const s = signatureObject.signature.slice(32);
const v = signatureObject.recid;
tx.setSignature(r, s, v);
const txWire = add0x(toHex(tx.serializeRLP()));
const result = await this.web3.eth.sendSignedTransaction(txWire);
this.loggingService.sendInfoLevelMessage(`Result: ${result}`);
const transaction = await this.web3.eth.getTransaction(result.transactionHash);
this.loggingService.sendInfoLevelMessage(`Transaction: ${transaction}`);
}
}

View File

@ -0,0 +1,80 @@
import { TestBed } from '@angular/core/testing';
import { UserService } from '@app/_services/user.service';
import {HttpClient} from '@angular/common/http';
import {HttpClientTestingModule, HttpTestingController} from '@angular/common/http/testing';
describe('UserService', () => {
let httpClient: HttpClient;
let httpTestingController: HttpTestingController;
let service: UserService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule]
});
httpClient = TestBed.inject(HttpClient);
httpTestingController = TestBed.inject(HttpTestingController);
service = TestBed.inject(UserService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should return user for available id', () => {
expect(service.getAccountById(1)).toEqual({
id: 1,
name: 'John Doe',
phone: '+25412345678',
address: '0xc86ff893ac40d3950b4d5f94a9b837258b0a9865',
type: 'user',
created: '08/16/2020',
balance: '12987',
failedPinAttempts: 1,
status: 'approved',
bio: 'Bodaboda',
gender: 'male'
});
});
it('should not return user for unavailable id', () => {
expect(service.getAccountById(9999999999)).toBeUndefined();
});
it('should return action for available id', () => {
expect(service.getActionById('1')).toEqual({
id: 1,
user: 'Tom',
role: 'enroller',
action: 'Disburse RSV 100',
approval: false
});
});
it('should not return action for unavailable id', () => {
expect(service.getActionById('9999999999')).toBeUndefined();
});
it('should switch action approval from false to true', () => {
service.approveAction('1');
expect(service.getActionById('1')).toEqual({
id: 1,
user: 'Tom',
role: 'enroller',
action: 'Disburse RSV 100',
approval: true
});
});
it('should switch action approval from true to false', () => {
service.revokeAction('2');
expect(service.getActionById('2')).toEqual({
id: 2,
user: 'Christine',
role: 'admin',
action: 'Change user phone number',
approval: false
});
});
});

View File

@ -0,0 +1,201 @@
import {Injectable} from '@angular/core';
import {BehaviorSubject, Observable} from 'rxjs';
import {HttpClient, HttpHeaders, HttpParams} from '@angular/common/http';
import {environment} from '@src/environments/environment';
import {first} from 'rxjs/operators';
import {ArgPair, Envelope, Syncable, User} from 'cic-client-meta';
import {MetaResponse} from '@app/_models';
import {LoggingService} from '@app/_services/logging.service';
import {HttpWrapperService} from '@app/_services/http-wrapper.service';
import {TokenService} from '@app/_services/token.service';
import {AccountIndex, Registry} from '@app/_eth';
import {MutableKeyStore, MutablePgpKeyStore, PGPSigner, Signer} from '@app/_pgp';
const vCard = require('vcard-parser');
@Injectable({
providedIn: 'root'
})
export class UserService {
headers: HttpHeaders = new HttpHeaders({'x-cic-automerge': 'client'});
keystore: MutableKeyStore = new MutablePgpKeyStore();
signer: Signer = new PGPSigner(this.keystore);
registry = new Registry(environment.registryAddress);
accountsMeta = [];
accounts: any = [];
private accountsList = new BehaviorSubject<any>(this.accounts);
accountsSubject = this.accountsList.asObservable();
actions: any = '';
private actionsList = new BehaviorSubject<any>(this.actions);
actionsSubject = this.actionsList.asObservable();
staff: any = '';
private staffList = new BehaviorSubject<any>(this.staff);
staffSubject = this.staffList.asObservable();
constructor(
private http: HttpClient,
private httpWrapperService: HttpWrapperService,
private loggingService: LoggingService,
private tokenService: TokenService
) {
}
resetPin(phone: string): Observable<any> {
const params = new HttpParams().set('phoneNumber', phone);
return this.httpWrapperService.get(`${environment.cicUssdUrl}/pin`, {params});
}
getAccountStatus(phone: string): any {
const params = new HttpParams().set('phoneNumber', phone);
return this.httpWrapperService.get(`${environment.cicUssdUrl}/pin`, {params});
}
getLockedAccounts(offset: number, limit: number): any {
return this.httpWrapperService.get(`${environment.cicUssdUrl}/accounts/locked/${offset}/${limit}`);
}
async changeAccountInfo(address: string, name: string, phoneNumber: string, age: string, type: string, bio: string, gender: string,
businessCategory: string, userLocation: string, location: string, locationType: string, metaAccount: MetaResponse
): Promise<any> {
let reqBody = metaAccount;
let accountInfo = reqBody.m.data;
accountInfo.vcard.fn[0].value = name;
accountInfo.vcard.n[0].value = name.split(' ');
accountInfo.vcard.tel[0].value = phoneNumber;
accountInfo.products = [bio];
accountInfo.gender = gender;
accountInfo.age = age;
accountInfo.type = type;
accountInfo.category = businessCategory;
accountInfo.location.area = location;
accountInfo.location.area_name = userLocation;
accountInfo.location.area_type = locationType;
accountInfo.vcard = vCard.generate(accountInfo.vcard);
reqBody.m.data = accountInfo;
const accountKey = await User.toKey(address);
this.httpWrapperService.get(`${environment.cicMetaUrl}/${accountKey}`, { headers: this.headers }).pipe(first()).subscribe(async res => {
const syncableAccount: Syncable = Envelope.fromJSON(JSON.stringify(res.body)).unwrap();
let update = [];
for (const prop in reqBody) {
update.push(new ArgPair(prop, reqBody[prop]));
}
syncableAccount.update(update, 'client-branch');
await this.updateMeta(syncableAccount, accountKey, this.headers);
}, async error => {
this.loggingService.sendErrorLevelMessage('Can\'t find account info in meta service', this, {error});
const syncableAccount: Syncable = new Syncable(accountKey, accountInfo);
await this.updateMeta(syncableAccount, accountKey, this.headers);
});
return accountKey;
}
async updateMeta(syncableAccount: Syncable, accountKey: string, headers: HttpHeaders): Promise<any> {
const envelope = await this.wrap(syncableAccount , this.signer);
const reqBody = envelope.toJSON();
this.httpWrapperService.put(`${environment.cicMetaUrl}/${accountKey}`, reqBody , { headers }).pipe(first()).subscribe(res => {
this.loggingService.sendInfoLevelMessage(`Response: ${res.body}`);
});
}
getAccounts(): void {
this.httpWrapperService.get(`${environment.cicCacheUrl}/accounts`).pipe(first()).subscribe(res => this.accountsList.next(res.body));
}
getAccountById(id: number): Observable<any> {
return this.httpWrapperService.get(`${environment.cicCacheUrl}/accounts/${id}`);
}
getActions(): void {
this.httpWrapperService.get(`${environment.cicCacheUrl}/actions`).pipe(first()).subscribe(res => this.actionsList.next(res.body));
}
getActionById(id: string): any {
return this.httpWrapperService.get(`${environment.cicCacheUrl}/actions/${id}`);
}
approveAction(id: string): Observable<any> {
return this.httpWrapperService.post(`${environment.cicCacheUrl}/actions/${id}`, { approval: true });
}
revokeAction(id: string): Observable<any> {
return this.httpWrapperService.post(`${environment.cicCacheUrl}/actions/${id}`, { approval: false });
}
getHistoryByUser(id: string): Observable<any> {
return this.httpWrapperService.get(`${environment.cicCacheUrl}/history/${id}`);
}
getStaff(): void {
this.httpWrapperService.get(`${environment.cicCacheUrl}/staff`).pipe(first()).subscribe(res => this.staffList.next(res.body));
}
getStaffById(id: string): Observable<any> {
return this.httpWrapperService.get(`${environment.cicCacheUrl}/staff/${id}`);
}
activateStaff(id: string): Observable<any> {
return this.httpWrapperService.post(`${environment.cicCacheUrl}/staff/${id}`, {status: 'activated'});
}
deactivateStaff(id: string): Observable<any> {
return this.httpWrapperService.post(`${environment.cicCacheUrl}/staff/${id}`, {status: 'deactivated'});
}
changeStaffType(id: string, type: string): Observable<any> {
return this.httpWrapperService.post(`${environment.cicCacheUrl}/staff/${id}`, {accountType: type});
}
getAccountDetailsFromMeta(userKey: string): Observable<any> {
return this.http.get(`${environment.cicMetaUrl}/${userKey}`, { headers: this.headers });
}
getUser(userKey: string): any {
return this.httpWrapperService.get(`${environment.cicMetaUrl}/${userKey}`, { headers: this.headers })
.pipe(first()).subscribe(async res => {
return Envelope.fromJSON(JSON.stringify(res.body)).unwrap();
});
}
wrap(syncable: Syncable, signer: Signer): Promise<Envelope> {
return new Promise<Envelope>(async (whohoo, doh) => {
syncable.setSigner(signer);
syncable.onwrap = async (env) => {
if (env === undefined) {
doh();
return;
}
whohoo(env);
};
await syncable.sign();
});
}
async loadAccounts(limit: number = 100, offset: number = 0): Promise<void> {
this.resetAccountsList();
const accountIndexAddress = await this.registry.addressOf('AccountRegistry');
const accountIndexQuery = new AccountIndex(accountIndexAddress);
const accountAddresses = await accountIndexQuery.last(await accountIndexQuery.totalAccounts());
this.loggingService.sendInfoLevelMessage(accountAddresses);
for (const accountAddress of accountAddresses.slice(offset, offset + limit)) {
this.getAccountDetailsFromMeta(await User.toKey(accountAddress)).pipe(first()).subscribe(async res => {
const account = Envelope.fromJSON(JSON.stringify(res)).unwrap();
this.accountsMeta.push(account);
const accountInfo = account.m.data;
accountInfo.balance = await this.tokenService.getTokenBalance(accountInfo.identities.evm['bloxberg:8996'][0]);
accountInfo.vcard = vCard.parse(atob(accountInfo.vcard));
this.accounts.unshift(accountInfo);
if (this.accounts.length > limit) {
this.accounts.length = limit;
}
this.accountsList.next(this.accounts);
});
}
}
resetAccountsList(): void {
this.accounts = [];
this.accountsList.next(this.accounts);
}
}

View File

@ -1,13 +1,17 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router'; import {Routes, RouterModule, PreloadAllModules} from '@angular/router';
import {AuthGuard} from '@app/_guards';
const routes: Routes = [ const routes: Routes = [
{ path: 'auth', loadChildren: () => import('@app/auth/auth.module').then(m => m.AuthModule) }, { path: 'auth', loadChildren: () => import('@app/auth/auth.module').then(m => m.AuthModule) },
{ path: '**', redirectTo: 'auth', pathMatch: 'full' } { path: '', loadChildren: () => import('@pages/pages.module').then(m => m.PagesModule), canActivate: [AuthGuard] },
{ path: '**', redirectTo: '', pathMatch: 'full' }
]; ];
@NgModule({ @NgModule({
imports: [RouterModule.forRoot(routes)], imports: [RouterModule.forRoot(routes, {
preloadingStrategy: PreloadAllModules
})],
exports: [RouterModule] exports: [RouterModule]
}) })
export class AppRoutingModule { } export class AppRoutingModule { }

View File

@ -1 +1 @@
<router-outlet></router-outlet> <router-outlet (activate)="onResize(mediaQuery)"></router-outlet>

View File

@ -1,6 +1,8 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing'; import { RouterTestingModule } from '@angular/router/testing';
import { AppComponent } from '@app/app.component'; import { AppComponent } from '@app/app.component';
import {TransactionService} from '@app/_services';
import {FooterStubComponent, SidebarStubComponent, TopbarStubComponent, TransactionServiceStub} from '@src/testing';
describe('AppComponent', () => { describe('AppComponent', () => {
beforeEach(async () => { beforeEach(async () => {
@ -9,8 +11,14 @@ describe('AppComponent', () => {
RouterTestingModule RouterTestingModule
], ],
declarations: [ declarations: [
AppComponent AppComponent,
FooterStubComponent,
SidebarStubComponent,
TopbarStubComponent
], ],
providers: [
{ provide: TransactionService, useClass: TransactionServiceStub }
]
}).compileComponents(); }).compileComponents();
}); });
@ -25,11 +33,4 @@ describe('AppComponent', () => {
const app = fixture.componentInstance; const app = fixture.componentInstance;
expect(app.title).toEqual('cic-staff-client'); expect(app.title).toEqual('cic-staff-client');
}); });
it('should render title', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.content span').textContent).toContain('cic-staff-client app is running!');
});
}); });

View File

@ -1,15 +1,66 @@
import { Component } from '@angular/core'; import {ChangeDetectionStrategy, Component, HostListener} from '@angular/core';
import {AuthService} from '@app/_services'; import {AuthService, LoggingService, TokenService, TransactionService} from '@app/_services';
import {NGXLogger} from 'ngx-logger';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
templateUrl: './app.component.html', templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'] styleUrls: ['./app.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class AppComponent { export class AppComponent {
title = 'cic-staff-client'; title = 'CICADA';
readyStateTarget: number = 3;
readyState: number = 0;
mediaQuery = window.matchMedia('(max-width: 768px)');
constructor(private authService: AuthService) { constructor(
private authService: AuthService,
private tokenService: TokenService,
private transactionService: TransactionService,
private logger: NGXLogger,
private loggingService: LoggingService,
) {
this.authService.mutableKeyStore.loadKeyring().then(r => this.authService.getPublicKeys().then()); this.authService.mutableKeyStore.loadKeyring().then(r => this.authService.getPublicKeys().then());
this.tokenService.getTokens().then(async r => loggingService.sendInfoLevelMessage(await r));
this.mediaQuery.addListener(this.onResize);
this.onResize(this.mediaQuery);
}
// Load resize
onResize(e): void {
const sidebar = document.getElementById('sidebar');
const content = document.getElementById('content');
const sidebarCollapse = document.getElementById('sidebarCollapse');
if (sidebarCollapse?.classList.contains('active')) {
sidebarCollapse?.classList.remove('active');
}
if (e.matches) {
if (!sidebar?.classList.contains('active')) {
sidebar?.classList.add('active');
}
if (!content?.classList.contains('active')) {
content?.classList.add('active');
}
} else {
if (sidebar?.classList.contains('active')) {
sidebar?.classList.remove('active');
}
if (content?.classList.contains('active')) {
content?.classList.remove('active');
}
}
}
@HostListener('window:cic_transfer', ['$event'])
cicTransfer(event: CustomEvent): void {
const transaction = event.detail.tx;
this.transactionService.setTransaction(transaction, 100).then();
}
@HostListener('window:cic_convert', ['$event'])
cicConvert(event: CustomEvent): void {
const conversion = event.detail.tx;
this.transactionService.setConversion(conversion, 100).then();
} }
} }

View File

@ -1,23 +1,52 @@
import { BrowserModule } from '@angular/platform-browser'; import {BrowserModule} from '@angular/platform-browser';
import { NgModule } from '@angular/core'; import {ErrorHandler, NgModule} from '@angular/core';
import { AppRoutingModule } from '@app/app-routing.module'; import {AppRoutingModule} from '@app/app-routing.module';
import { AppComponent } from '@app/app.component'; import {AppComponent} from '@app/app.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import {HttpClientModule} from '@angular/common/http'; import {HTTP_INTERCEPTORS, HttpClientModule} from '@angular/common/http';
import {MutablePgpKeyStore} from '@app/_helpers'; import {
GlobalErrorHandler,
MockBackendProvider,
} from '@app/_helpers';
import {DataTablesModule} from 'angular-datatables';
import {SharedModule} from '@app/shared/shared.module';
import {MatTableModule} from '@angular/material/table';
import {AuthGuard} from '@app/_guards';
import {LoggerModule} from 'ngx-logger';
import {environment} from '@src/environments/environment';
import {ErrorInterceptor, HttpConfigInterceptor, LoggingInterceptor} from '@app/_interceptors';
import {MutablePgpKeyStore} from '@app/_pgp';
@NgModule({ @NgModule({
declarations: [ declarations: [
AppComponent, AppComponent
], ],
imports: [ imports: [
BrowserModule, BrowserModule,
AppRoutingModule, AppRoutingModule,
BrowserAnimationsModule, BrowserAnimationsModule,
HttpClientModule HttpClientModule,
DataTablesModule,
SharedModule,
MatTableModule,
LoggerModule.forRoot({
level: environment.level,
serverLogLevel: environment.serverLogLevel,
serverLoggingUrl: `${environment.loggingUrl}/api/logs/`,
disableConsoleLogging: false
})
],
providers: [
AuthGuard,
MutablePgpKeyStore,
MockBackendProvider,
GlobalErrorHandler,
{ provide: ErrorHandler, useClass: GlobalErrorHandler },
{ provide: HTTP_INTERCEPTORS, useClass: HttpConfigInterceptor, multi: true },
{ provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true },
{ provide: HTTP_INTERCEPTORS, useClass: LoggingInterceptor, multi: true },
], ],
providers: [MutablePgpKeyStore],
bootstrap: [AppComponent] bootstrap: [AppComponent]
}) })
export class AppModule { } export class AppModule { }

View File

@ -17,8 +17,8 @@
<mat-form-field appearance="outline" class="full-width"> <mat-form-field appearance="outline" class="full-width">
<mat-label>Private Key</mat-label> <mat-label>Private Key</mat-label>
<textarea matInput style="height: 30rem" name="privateKeyAsc" id="privateKeyAsc" formControlName="key" <textarea matInput style="height: 30rem" formControlName="key" placeholder="Enter your private key..."
placeholder="Enter your private key..." [errorStateMatcher]="matcher"></textarea> [errorStateMatcher]="matcher"></textarea>
<div *ngIf="submitted && keyFormStub.key.errors" class="invalid-feedback"> <div *ngIf="submitted && keyFormStub.key.errors" class="invalid-feedback">
<mat-error *ngIf="keyFormStub.key.errors.required">Private Key is required.</mat-error> <mat-error *ngIf="keyFormStub.key.errors.required">Private Key is required.</mat-error>
</div> </div>
@ -34,28 +34,10 @@
<div id="two" style="display: none" class="card-body p-4 align-items-center"> <div id="two" style="display: none" class="card-body p-4 align-items-center">
<div class="text-center w-75 m-auto"> <div class="text-center w-75 m-auto">
<h4 class="text-dark-50 text-center font-weight-bold">Enter Passphrase</h4> <h4 id="state" class="text-dark-50 text-center font-weight-bold"></h4>
<button mat-raised-button matRipple color="primary" type="submit" (click)="login()"> Login </button>
</div> </div>
<form [formGroup]="passphraseForm" (ngSubmit)="login()">
<mat-form-field appearance="outline" class="full-width">
<mat-label>Passphrase</mat-label>
<input matInput name="state" id="state" formControlName="passphrase"
placeholder="Enter your passphrase..." [errorStateMatcher]="matcher">
<div *ngIf="submitted && passphraseFormStub.passphrase.errors" class="invalid-feedback">
<mat-error *ngIf="passphraseFormStub.passphrase.errors.required">Passphrase is required.</mat-error>
</div>
</mat-form-field>
<div class="form-group mb-0 text-center">
<button mat-raised-button matRipple color="primary" type="submit">
<!-- <span *ngIf="loading" class="spinner-border spinner-border-sm mr-1"></span>-->
Login
</button>
</div>
</form>
<div class="row mt-3"> <div class="row mt-3">
<div class="col-12 text-center"> <div class="col-12 text-center">
<p class="text-muted">Change private key? <a (click)="switchWindows()" class="text-muted ml-1"><b>Enter private key</b></a></p> <p class="text-muted">Change private key? <a (click)="switchWindows()" class="text-muted ml-1"><b>Enter private key</b></a></p>

View File

@ -1,16 +1,17 @@
import {AfterViewInit, Component, OnInit} from '@angular/core'; import {ChangeDetectionStrategy, Component, OnInit} from '@angular/core';
import {FormBuilder, FormGroup, Validators} from '@angular/forms'; import {FormBuilder, FormGroup, Validators} from '@angular/forms';
import {CustomErrorStateMatcher} from '@app/_helpers'; import {CustomErrorStateMatcher} from '@app/_helpers';
import {AuthService} from '@app/_services'; import {AuthService} from '@app/_services';
import {Router} from '@angular/router';
@Component({ @Component({
selector: 'app-auth', selector: 'app-auth',
templateUrl: './auth.component.html', templateUrl: './auth.component.html',
styleUrls: ['./auth.component.scss'] styleUrls: ['./auth.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class AuthComponent implements OnInit, AfterViewInit { export class AuthComponent implements OnInit {
keyForm: FormGroup; keyForm: FormGroup;
passphraseForm: FormGroup;
submitted: boolean = false; submitted: boolean = false;
loading: boolean = false; loading: boolean = false;
matcher = new CustomErrorStateMatcher(); matcher = new CustomErrorStateMatcher();
@ -18,42 +19,24 @@ export class AuthComponent implements OnInit, AfterViewInit {
constructor( constructor(
private authService: AuthService, private authService: AuthService,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private router: Router
) { } ) { }
ngOnInit(): void { ngOnInit(): void {
this.keyForm = this.formBuilder.group({ this.keyForm = this.formBuilder.group({
key: ['', Validators.required], key: ['', Validators.required],
}); });
this.passphraseForm = this.formBuilder.group({
passphrase: ['', Validators.required],
});
if (this.authService.privateKey !== undefined ) { if (this.authService.privateKey !== undefined ) {
this.authService.setKey(this.authService.privateKey).then(r => { this.authService.setKey(this.authService.privateKey).then(r => {
if (this.authService.sessionToken !== undefined) { if (this.authService.sessionToken !== undefined) {
this.authService.setState( this.authService.setState(
'click to perform login ' + this.authService.sessionLoginCount + ' with token ' + this.authService.sessionToken); 'Click button to perform login ' + this.authService.sessionLoginCount + ' with token ' + this.authService.sessionToken);
} }
}); });
} }
} }
ngAfterViewInit(): void {
console.log(window.location);
const xhr = new XMLHttpRequest();
xhr.responseType = 'text';
xhr.open('GET', window.location.origin + '/privatekey.asc');
xhr.onload = (e) => {
if (xhr.status !== 200) {
console.warn('failed to autoload private key ciphertext');
return;
}
(document.getElementById('privateKeyAsc') as HTMLInputElement).value = xhr.responseText;
};
xhr.send();
}
get keyFormStub(): any { return this.keyForm.controls; } get keyFormStub(): any { return this.keyForm.controls; }
get passphraseFormStub(): any { return this.passphraseForm.controls; }
onSubmit(): void { onSubmit(): void {
this.submitted = true; this.submitted = true;
@ -66,8 +49,11 @@ export class AuthComponent implements OnInit, AfterViewInit {
} }
login(): void { login(): void {
// if (this.passphraseForm.invalid) { return; } const loginStatus = this.authService.login();
this.authService.login(); console.log(loginStatus);
if (loginStatus) {
this.router.navigate(['/home']);
}
} }
switchWindows(): void { switchWindows(): void {

View File

@ -0,0 +1,432 @@
<!-- Begin page -->
<div class="wrapper">
<app-sidebar></app-sidebar>
<!-- ============================================================== -->
<!-- Start Page Content here -->
<!-- ============================================================== -->
<div id="content">
<app-topbar></app-topbar>
<!-- Start Content-->
<div class="container-fluid" appMenuSelection>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a routerLink="/home">Home</a></li>
<li class="breadcrumb-item"><a routerLink="/accounts">Accounts</a></li>
<!-- <li *ngIf="account" class="breadcrumb-item active" aria-current="page">{{account?.vcard?.fn[0].value}}</li>-->
</ol>
</nav>
<div *ngIf="!account" class="text-center">
<div class="spinner-grow text-primary m-1" role="status" style="width: 3rem; height: 3rem;">
<span class="sr-only">Loading...</span>
</div>
<div class="spinner-grow text-primary m-1" role="status" style="width: 3rem; height: 3rem;">
<span class="sr-only">Loading...</span>
</div>
<div class="spinner-grow text-primary m-1" role="status" style="width: 3rem; height: 3rem;">
<span class="sr-only">Loading...</span>
</div>
</div>
<div *ngIf="account" class="card mb-3">
<div class="row card-body">
<h3>
<strong> {{account?.vcard?.fn[0].value}} </strong>
</h3>
<span class="ml-auto"><strong>Balance:</strong> {{account?.balance}} SRF</span>
<span class="ml-2"><strong>Created:</strong> {{account?.date_registered | date}}</span>
<span class="ml-2"><strong>Address:</strong><a href="{{bloxbergLink}}" target="_blank"> {{account?.identities.evm['bloxberg:8996']}} </a></span>
</div>
</div>
<app-disbursement *ngIf="isDisbursing" (cancelDisbursmentEvent)="addTransfer()" [account]="account">
</app-disbursement>
<div *ngIf="account" class="card mt-3 mb-3">
<div class="card-body">
<form [formGroup]="accountInfoForm" (ngSubmit)="saveInfo()">
<div class="row form-inline">
<div class="col-md-6 col-lg-4">
<mat-form-field appearance="outline">
<mat-label>Name(s): *</mat-label>
<input matInput type="text" id="givenNames" placeholder="{{account?.vcard?.fn[0].value}}"
value="{{account?.vcard?.fn[0].value}}" formControlName="name" [errorStateMatcher]="matcher">
<mat-error *ngIf="submitted && accountInfoFormStub.name.errors">Name is required.</mat-error>
</mat-form-field>
</div>
<div class="col-md-6 col-lg-4">
<mat-form-field appearance="outline">
<mat-label>Phone Number: </mat-label>
<input matInput type="text" id="phoneNumber" placeholder="{{account?.vcard?.tel[0].value}}"
value="{{account?.vcard?.tel[0].value}}" formControlName="phoneNumber" [errorStateMatcher]="matcher">
<mat-error *ngIf="submitted && accountInfoFormStub.phoneNumber.errors">Phone Number is required.</mat-error>
</mat-form-field>
</div>
<div class="col-md-6 col-lg-4">
<mat-form-field appearance="outline">
<mat-label>Age: </mat-label>
<input matInput type="text" id="age" placeholder="{{account?.age}}"
value="{{account?.age}}" formControlName="age" [errorStateMatcher]="matcher">
<mat-error *ngIf="submitted && accountInfoFormStub.age.errors">Age is required.</mat-error>
</mat-form-field>
</div>
<div class="col-md-6 col-lg-4">
<mat-form-field appearance="outline">
<mat-label> ACCOUNT TYPE: </mat-label>
<mat-select id="accountType" [(value)]="account.type" formControlName="type"
[errorStateMatcher]="matcher">
<mat-option value="user"> USER </mat-option>
<mat-option value="cashier"> CASHIER </mat-option>
<mat-option value="vendor"> VENDOR </mat-option>
<mat-option value="tokenAgent"> TOKENAGENT </mat-option>
<mat-option value="group"> GROUPACCOUNT </mat-option>
</mat-select>
<mat-error *ngIf="submitted && accountInfoFormStub.type.errors">Type is required.</mat-error>
</mat-form-field>
</div>
<div class="col-md-6 col-lg-4">
<mat-form-field appearance="outline">
<mat-label>Bio: </mat-label>
<input matInput type="text" id="bio" placeholder="{{account?.products}}" value="{{account?.products}}"
formControlName="bio" [errorStateMatcher]="matcher">
<mat-error *ngIf="submitted && accountInfoFormStub.bio.errors">Bio is required.</mat-error>
</mat-form-field>
</div>
<div class="col-md-6 col-lg-4">
<mat-form-field appearance="outline">
<mat-label> GENDER: </mat-label>
<mat-select id="gender" [(value)]="account.gender" formControlName="gender"
[errorStateMatcher]="matcher">
<mat-option value="male"> MALE </mat-option>
<mat-option value="female"> FEMALE </mat-option>
<mat-option value="other"> OTHER </mat-option>
</mat-select>
<mat-error *ngIf="submitted && accountInfoFormStub.gender.errors">Gender is required.</mat-error>
</mat-form-field>
</div>
<div class="col-md-6 col-lg-4">
<mat-form-field appearance="outline">
<mat-label> BUSINESS CATEGORY: </mat-label>
<mat-select id="businessCategory" [(value)]="account.category" formControlName="businessCategory"
[errorStateMatcher]="matcher">
<mat-option value="food/water">Food/Water</mat-option>
<mat-option value="fuel/energy">Fuel/Energy</mat-option>
<mat-option value="education">Education</mat-option>
<mat-option value="health">Health</mat-option>
<mat-option value="shop">Shop</mat-option>
<mat-option value="environment">Environment</mat-option>
<mat-option value="transport">Transport</mat-option>
<mat-option value="farming/labour">Farming/Labour</mat-option>
<mat-option value="savings">Savings Group</mat-option>
<mat-option value="other">Savings Group</mat-option>
</mat-select>
<mat-error *ngIf="submitted && accountInfoFormStub.businessCategory.errors">
Category is required.
</mat-error>
</mat-form-field>
</div>
<div class="col-md-6 col-lg-4">
<mat-form-field appearance="outline">
<mat-label>User Location: </mat-label>
<input matInput type="text" id="userLocation" placeholder="{{account?.location.area_name}}"
value="{{account?.location.area_name}}" formControlName="userLocation"
[errorStateMatcher]="matcher">
<mat-error *ngIf="submitted && accountInfoFormStub.userLocation.errors">
User Location is required.
</mat-error>
</mat-form-field>
</div>
<div class="col-md-6 col-lg-4">
<mat-form-field appearance="outline">
<mat-label> LOCATION: </mat-label>
<mat-select id="location" [(value)]="account.location.area" formControlName="location"
[errorStateMatcher]="matcher">
<div *ngFor="let county of locations; trackBy: trackByName">
<div *ngFor="let district of county.districts; trackBy: trackByName">
<mat-optgroup *ngFor="let location of district.locations; trackBy: trackByName" [label]="county.name + ' / ' +
district.name + ' / ' + location.name">
<mat-option *ngFor="let village of location.villages; trackBy: trackByName" [value]="village">
{{village}}
</mat-option>
</mat-optgroup>
</div>
</div>
</mat-select>
<mat-error *ngIf="submitted && accountInfoFormStub.location.errors">Location is required.</mat-error>
</mat-form-field>
</div>
<div class="col-md-6 col-lg-4">
<mat-form-field appearance="outline">
<mat-label> LOCATION TYPE: </mat-label>
<mat-select id="locationType" [(value)]="account.location.area_type" formControlName="locationType"
[errorStateMatcher]="matcher">
<mat-option value="Urban"> URBAN </mat-option>
<mat-option value="Periurban"> PERIURBAN </mat-option>
<mat-option value="Rural"> RURAL </mat-option>
<mat-option value="Other"> OTHER </mat-option>
</mat-select>
<mat-error *ngIf="submitted && accountInfoFormStub.locationType.errors">Location Type is required.</mat-error>
</mat-form-field>
</div>
<div class="col-md-6 col-lg-4">
<button mat-raised-button color="primary" type="button" class="btn btn btn-outline-primary mb-3">
Add User KYC
</button>
</div>
<div class="col-md-6 col-lg-4">
<button mat-raised-button color="primary" type="button" class="btn btn btn-outline-success mb-3"
(click)="resetPin()">
Reset Pin
</button>
</div>
<div class="col-md-6 col-lg-4">
<button mat-raised-button color="warn" type="button" class="btn btn-outline-danger mb-3">
Delete User
</button>
</div>
<div class="col-md-6 col-lg-4">
<button mat-raised-button color="primary" type="submit" class="btn btn-outline-primary">
SAVE DETAILS
</button>
</div>
</div>
</form>
</div>
</div>
<div class="card mb-3">
<mat-card-title class="card-header">
USER
</mat-card-title>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped table-bordered table-hover">
<caption> 1 user </caption>
<thead class="thead-dark">
<tr>
<th scope="col">NAME</th>
<th scope="col">ACCOUNT TYPE</th>
<th scope="col">CREATED</th>
<th scope="col">STATUS</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{account?.name}}</td>
<td>{{account?.type}}</td>
<td>{{account?.created}}</td>
<td>
<span *ngIf="account?.status === 'active'" class="badge badge-success badge-pill">
{{account?.status}}
</span>
<span *ngIf="account?.status === 'blocked'" class="badge badge-danger badge-pill">
{{account?.status}}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="card">
<mat-card-title class="card-header">
History
</mat-card-title>
<div class="card-body">
<mat-form-field appearance="outline">
<mat-label> Filter </mat-label>
<input matInput type="text" (keyup)="doHistoryFilter($event.target.value)" placeholder="Filter">
<mat-icon matSuffix>search</mat-icon>
</mat-form-field>
<mat-table class="mat-elevation-z10" [dataSource]="historyDataSource" matSort #HistoryTableSort="matSort"
matSortActive="sender" matSortDirection="asc" matSortDisableClear>
<ng-container matColumnDef="user">
<mat-header-cell *matHeaderCellDef mat-sort-header> User </mat-header-cell>
<mat-cell *matCellDef="let history"> {{history.userName}} </mat-cell>
</ng-container>
<ng-container matColumnDef="action">
<mat-header-cell *matHeaderCellDef mat-sort-header> Action </mat-header-cell>
<mat-cell *matCellDef="let history"> {{history.action}} </mat-cell>
</ng-container>
<ng-container matColumnDef="staff">
<mat-header-cell *matHeaderCellDef mat-sort-header> Staff </mat-header-cell>
<mat-cell *matCellDef="let history"> {{history.staff}} </mat-cell>
</ng-container>
<ng-container matColumnDef="timestamp">
<mat-header-cell *matHeaderCellDef mat-sort-header> Timestamp </mat-header-cell>
<mat-cell *matCellDef="let history"> {{history.timestamp | date}} </mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="historyDisplayedColumns"></mat-header-row>
<mat-row *matRowDef="let history; columns: historyDisplayedColumns" matRipple></mat-row>
</mat-table>
<mat-paginator #HistoryTablePaginator="matPaginator" [pageSize]="10"
[pageSizeOptions]="[10, 20, 50, 100]" showFirstLastButtons></mat-paginator>
</div>
</div>
<mat-tab-group dynamicHeight mat-align-tabs="start">
<mat-tab label="Transactions">
<app-transaction-details [transaction]="transaction"></app-transaction-details>
<div class="card mt-1">
<div class="card-header">
<div class="row">
<mat-form-field appearance="outline">
<mat-label> TRANSACTION TYPE </mat-label>
<mat-select id="transferSelect" [(value)]="transactionsType" (selectionChange)="filterTransactions()">
<mat-option value="all">ALL TRANSFERS</mat-option>
<mat-option value="transaction">PAYMENTS</mat-option>
<mat-option value="conversion">CONVERSION</mat-option>
<mat-option value="disbursements">DISBURSEMENTS</mat-option>
<mat-option value="rewards">REWARDS</mat-option>
<mat-option value="reclamation">RECLAMATION</mat-option>
</mat-select>
</mat-form-field>
</div>
</div>
<div class="card-body">
<mat-form-field appearance="outline">
<mat-label> Filter </mat-label>
<input matInput type="text" (keyup)="doTransactionFilter($event.target.value)" placeholder="Filter">
<mat-icon matSuffix>search</mat-icon>
</mat-form-field>
<mat-table class="mat-elevation-z10" [dataSource]="transactionsDataSource" matSort matSortActive="created"
#TransactionTableSort="matSort" matSortDirection="asc" matSortDisableClear>
<ng-container matColumnDef="sender">
<th mat-header-cell *matHeaderCellDef mat-sort-header> Sender </th>
<td mat-cell *matCellDef="let transaction"> {{transaction?.sender?.vcard.fn[0].value}} </td>
</ng-container>
<ng-container matColumnDef="recipient">
<th mat-header-cell *matHeaderCellDef mat-sort-header> Recipient </th>
<td mat-cell *matCellDef="let transaction"> {{transaction?.recipient?.vcard.fn[0].value}} </td>
</ng-container>
<ng-container matColumnDef="value">
<th mat-header-cell *matHeaderCellDef mat-sort-header> Value </th>
<td mat-cell *matCellDef="let transaction">
<span *ngIf="transaction.type == 'transaction'">{{transaction?.value | tokenRatio}}</span>
<span *ngIf="transaction.type == 'conversion'">{{transaction?.toValue | tokenRatio}}</span>
</td>
</ng-container>
<ng-container matColumnDef="created">
<th mat-header-cell *matHeaderCellDef mat-sort-header> Created </th>
<td mat-cell *matCellDef="let transaction"> {{transaction?.tx.timestamp | date}} </td>
</ng-container>
<ng-container matColumnDef="type">
<th mat-header-cell *matHeaderCellDef mat-sort-header> TYPE </th>
<td mat-cell *matCellDef="let transaction">
<span class="badge badge-success badge-pill"> {{transaction?.type}} </span>
</td>
</ng-container>
<mat-header-row *matHeaderRowDef="transactionsDisplayedColumns"></mat-header-row>
<mat-row *matRowDef="let transaction; columns: transactionsDisplayedColumns" matRipple
(click)="viewTransaction(transaction)"></mat-row>
</mat-table>
<mat-paginator #TransactionTablePaginator="matPaginator" [pageSize]="transactionsDefaultPageSize"
[pageSizeOptions]="transactionsPageSizeOptions" showFirstLastButtons></mat-paginator>
</div>
</div>
</mat-tab>
<mat-tab label="Users">
<div class="card mt-1">
<mat-card-title class="card-header">
Accounts
</mat-card-title>
<div class="card-body">
<div class="row card-header">
<mat-form-field appearance="outline">
<mat-label> ACCOUNT TYPE </mat-label>
<mat-select id="typeSelect" [(value)]="accountsType" (selectionChange)="filterAccounts()">
<mat-option value="all">ALL</mat-option>
<mat-option value="user">USER</mat-option>
<mat-option value="cashier">CASHIER</mat-option>
<mat-option value="vendor">VENDOR</mat-option>
<mat-option value="tokenAgent">TOKENAGENT</mat-option>
<mat-option value="group">GROUPACCOUNT</mat-option>
</mat-select>
</mat-form-field>
</div>
<mat-form-field appearance="outline">
<mat-label> Filter </mat-label>
<input matInput type="text" (keyup)="doUserFilter($event.target.value)" placeholder="Filter">
<mat-icon matSuffix>search</mat-icon>
</mat-form-field>
<mat-table class="mat-elevation-z10" [dataSource]="userDataSource" matSort #UserTableSort="matSort"
matSortActive="created" matSortDirection="desc" matSortDisableClear>
<ng-container matColumnDef="name">
<mat-header-cell *matHeaderCellDef mat-sort-header> NAME </mat-header-cell>
<mat-cell *matCellDef="let user"> {{user?.vcard.fn[0].value}} </mat-cell>
</ng-container>
<ng-container matColumnDef="phone">
<mat-header-cell *matHeaderCellDef mat-sort-header> PHONE NUMBER </mat-header-cell>
<mat-cell *matCellDef="let user"> {{user?.vcard.tel[0].value}} </mat-cell>
</ng-container>
<ng-container matColumnDef="created">
<mat-header-cell *matHeaderCellDef mat-sort-header> CREATED </mat-header-cell>
<mat-cell *matCellDef="let user"> {{user?.date_registered | date}} </mat-cell>
</ng-container>
<ng-container matColumnDef="balance">
<mat-header-cell *matHeaderCellDef mat-sort-header> BALANCE </mat-header-cell>
<mat-cell *matCellDef="let user"> {{user?.balance}} </mat-cell>
</ng-container>
<ng-container matColumnDef="location">
<mat-header-cell *matHeaderCellDef mat-sort-header> LOCATION </mat-header-cell>
<mat-cell *matCellDef="let user"> {{user?.location.area_name}} </mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef=userDisplayedColumns></mat-header-row>
<mat-row *matRowDef="let account; columns: userDisplayedColumns" (click)="viewAccount(account)"
matRipple></mat-row>
</mat-table>
<mat-paginator #UserTablePaginator="matPaginator" [pageSize]="usersDefaultPageSize"
[pageSizeOptions]="usersPageSizeOptions" showFirstLastButtons></mat-paginator>
</div>
</div>
</mat-tab>
</mat-tab-group>
</div>
<app-footer appMenuSelection></app-footer>
</div>
<!-- ============================================================== -->
<!-- End Page content -->
<!-- ============================================================== -->
</div>

View File

@ -0,0 +1,61 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AccountDetailsComponent } from '@pages/accounts/account-details/account-details.component';
import {HttpClient} from '@angular/common/http';
import {HttpClientTestingModule, HttpTestingController} from '@angular/common/http/testing';
import {ActivatedRoute} from '@angular/router';
import {AccountsModule} from '@pages/accounts/accounts.module';
import {UserService} from '@app/_services';
import {AppModule} from '@app/app.module';
import {ActivatedRouteStub, FooterStubComponent, SidebarStubComponent, TopbarStubComponent, UserServiceStub} from '@src/testing';
describe('AccountDetailsComponent', () => {
let component: AccountDetailsComponent;
let fixture: ComponentFixture<AccountDetailsComponent>;
let httpClient: HttpClient;
let httpTestingController: HttpTestingController;
let route: ActivatedRouteStub;
beforeEach(async () => {
route = new ActivatedRouteStub();
route.setParamMap({ id: 'test' });
await TestBed.configureTestingModule({
declarations: [
AccountDetailsComponent,
FooterStubComponent,
SidebarStubComponent,
TopbarStubComponent
],
imports: [
AccountsModule,
AppModule,
HttpClientTestingModule,
],
providers: [
{ provide: ActivatedRoute, useValue: route },
{ provide: UserService, useClass: UserServiceStub }
]
})
.compileComponents();
httpClient = TestBed.inject(HttpClient);
httpTestingController = TestBed.inject(HttpTestingController);
});
beforeEach(() => {
fixture = TestBed.createComponent(AccountDetailsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('#addTransfer() should toggle #isDisbursing', () => {
expect(component.isDisbursing).toBe(false, 'off at first');
component.addTransfer();
expect(component.isDisbursing).toBe(true, 'on after click');
component.addTransfer();
expect(component.isDisbursing).toBe(false, 'off after second click');
});
});

View File

@ -0,0 +1,213 @@
import {ChangeDetectionStrategy, Component, OnInit, ViewChild} from '@angular/core';
import {MatTableDataSource} from '@angular/material/table';
import {SelectionModel} from '@angular/cdk/collections';
import {MatPaginator} from '@angular/material/paginator';
import {MatSort} from '@angular/material/sort';
import {BlockSyncService, LocationService, LoggingService, TokenService, TransactionService, UserService} from '@app/_services';
import {ActivatedRoute, Params, Router} from '@angular/router';
import {first} from 'rxjs/operators';
import {FormBuilder, FormGroup, Validators} from '@angular/forms';
import {CustomErrorStateMatcher} from '@app/_helpers';
import {Envelope, User} from 'cic-client-meta';
const vCard = require('vcard-parser');
@Component({
selector: 'app-account-details',
templateUrl: './account-details.component.html',
styleUrls: ['./account-details.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AccountDetailsComponent implements OnInit {
transactionsDataSource: MatTableDataSource<any>;
transactionsDisplayedColumns = ['sender', 'recipient', 'value', 'created', 'type'];
transactionsDefaultPageSize = 10;
transactionsPageSizeOptions = [10, 20, 50, 100];
@ViewChild('TransactionTablePaginator', {static: true}) transactionTablePaginator: MatPaginator;
@ViewChild('TransactionTableSort', {static: true}) transactionTableSort: MatSort;
userDataSource: MatTableDataSource<any>;
userDisplayedColumns = ['name', 'phone', 'created', 'balance', 'location'];
usersDefaultPageSize = 10;
usersPageSizeOptions = [10, 20, 50, 100];
@ViewChild('UserTablePaginator', {static: true}) userTablePaginator: MatPaginator;
@ViewChild('UserTableSort', {static: true}) userTableSort: MatSort;
historyDataSource: MatTableDataSource<any>;
historyDisplayedColumns = ['user', 'action', 'staff', 'timestamp'];
@ViewChild('HistoryTablePaginator', {static: true}) historyTablePaginator: MatPaginator;
@ViewChild('HistoryTableSort', {static: true}) historyTableSort: MatSort;
accountInfoForm: FormGroup;
account: any;
accountAddress: string;
accountBalance: number;
metaAccount: any;
accounts: any[] = [];
accountsType = 'all';
date: string;
time: number;
isDisbursing = false;
locations: any;
transaction: any;
transactions: any[];
transactionsType = 'all';
matcher = new CustomErrorStateMatcher();
submitted: boolean = false;
bloxbergLink: string;
constructor(
private formBuilder: FormBuilder,
private locationService: LocationService,
private transactionService: TransactionService,
private userService: UserService,
private route: ActivatedRoute,
private router: Router,
private tokenService: TokenService,
private loggingService: LoggingService,
private blockSyncService: BlockSyncService
) {
this.accountInfoForm = this.formBuilder.group({
name: ['', Validators.required],
phoneNumber: ['', Validators.required],
age: ['', Validators.required],
type: ['', Validators.required],
bio: ['', Validators.required],
gender: ['', Validators.required],
businessCategory: ['', Validators.required],
userLocation: ['', Validators.required],
location: ['', Validators.required],
locationType: ['', Validators.required],
});
this.route.paramMap.subscribe(async (params: Params) => {
this.accountAddress = params.get('id');
this.bloxbergLink = 'https://blockexplorer.bloxberg.org/address/' + this.accountAddress + '/transactions';
this.userService.getAccountDetailsFromMeta(await User.toKey(this.accountAddress)).pipe(first()).subscribe(res => {
this.metaAccount = Envelope.fromJSON(JSON.stringify(res.body)).unwrap();
this.account = this.metaAccount.m.data;
this.loggingService.sendInfoLevelMessage(this.account);
this.tokenService.getTokenBalance(this.accountAddress).then(r => this.accountBalance = r);
this.account.vcard = vCard.parse(atob(this.account.vcard));
this.accountInfoForm.patchValue({
name: this.account.vcard?.fn[0].value,
phoneNumber: this.account.vcard?.tel[0].value,
age: this.account.age,
type: this.account.type,
bio: this.account.products,
gender: this.account.gender,
businessCategory: this.account.category,
userLocation: this.account.location.area_name,
location: this.account.location.area,
locationType: this.account.location.area_type,
});
});
this.userService.getHistoryByUser(this.accountAddress).pipe(first()).subscribe(response => {
this.historyDataSource = new MatTableDataSource<any>(response.body);
this.historyDataSource.paginator = this.historyTablePaginator;
this.historyDataSource.sort = this.historyTableSort;
});
this.blockSyncService.blockSync(this.accountAddress);
});
this.userService.getAccounts();
this.locationService.getLocations();
this.locationService.locationsSubject.subscribe(locations => {
this.locations = locations;
});
}
ngOnInit(): void {
this.userService.accountsSubject.subscribe(accounts => {
this.userDataSource = new MatTableDataSource<any>(accounts);
this.userDataSource.paginator = this.userTablePaginator;
this.userDataSource.sort = this.userTableSort;
this.accounts = accounts;
});
this.transactionService.transactionsSubject.subscribe(transactions => {
this.transactionsDataSource = new MatTableDataSource<any>(transactions);
this.transactionsDataSource.paginator = this.transactionTablePaginator;
this.transactionsDataSource.sort = this.transactionTableSort;
this.transactions = transactions;
});
const d = new Date();
this.date = `${d.getDate()}/${d.getMonth()}/${d.getFullYear()}`;
}
addTransfer(): void {
this.isDisbursing = !this.isDisbursing;
}
doTransactionFilter(value: string): void {
this.transactionsDataSource.filter = value.trim().toLocaleLowerCase();
}
doUserFilter(value: string): void {
this.userDataSource.filter = value.trim().toLocaleLowerCase();
}
doHistoryFilter(value: string): void {
this.historyDataSource.filter = value.trim().toLocaleLowerCase();
}
viewTransaction(transaction): void {
this.transaction = transaction;
}
viewAccount(account): void {
this.router.navigateByUrl(`/accounts/${account.id}`);
}
get accountInfoFormStub(): any { return this.accountInfoForm.controls; }
saveInfo(): void {
this.submitted = true;
if (this.accountInfoForm.invalid) { return; }
this.userService.changeAccountInfo(
this.account.address,
this.accountInfoFormStub.name.value,
this.accountInfoFormStub.phoneNumber.value,
this.accountInfoFormStub.age.value,
this.accountInfoFormStub.type.value,
this.accountInfoFormStub.bio.value,
this.accountInfoFormStub.gender.value,
this.accountInfoFormStub.businessCategory.value,
this.accountInfoFormStub.userLocation.value,
this.accountInfoFormStub.location.value,
this.accountInfoFormStub.locationType.value,
this.metaAccount
).then(res => this.loggingService.sendInfoLevelMessage(`Response: ${res}`));
this.submitted = false;
}
filterAccounts(): void {
if (this.accountsType === 'all') {
this.userService.accountsSubject.subscribe(accounts => {
this.userDataSource.data = accounts;
this.accounts = accounts;
});
} else {
this.userDataSource.data = this.accounts.filter(account => account.type === this.accountsType);
}
}
filterTransactions(): void {
if (this.transactionsType === 'all') {
this.transactionService.transactionsSubject.subscribe(transactions => {
this.transactionsDataSource.data = transactions;
this.transactions = transactions;
});
} else {
this.transactionsDataSource.data = this.transactions.filter(transaction => transaction.type === this.transactionsType);
}
}
resetPin(): void {
this.userService.resetPin(this.account.phone).pipe(first()).subscribe(res => {
this.loggingService.sendInfoLevelMessage(`Response: ${res.body}`);
});
}
public trackByName(index, item): string {
return item.name;
}
}

View File

@ -0,0 +1,21 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { AccountsComponent } from '@pages/accounts/accounts.component';
import {CreateAccountComponent} from '@pages/accounts/create-account/create-account.component';
import {ExportAccountsComponent} from '@pages/accounts/export-accounts/export-accounts.component';
import {AccountDetailsComponent} from '@pages/accounts/account-details/account-details.component';
const routes: Routes = [
{ path: '', component: AccountsComponent },
// { path: 'create', component: CreateAccountComponent },
{ path: 'export', component: ExportAccountsComponent },
{ path: ':id', component: AccountDetailsComponent },
{ path: '**', redirectTo: '', pathMatch: 'full' }
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class AccountsRoutingModule { }

View File

@ -0,0 +1,86 @@
<!-- Begin page -->
<div class="wrapper">
<app-sidebar></app-sidebar>
<!-- ============================================================== -->
<!-- Start Page Content here -->
<!-- ============================================================== -->
<div id="content">
<app-topbar></app-topbar>
<!-- Start Content-->
<div class="container-fluid" appMenuSelection>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a routerLink="/home">Home</a></li>
<li class="breadcrumb-item active" aria-current="page">Accounts</li>
</ol>
</nav>
<div class="card">
<mat-card-title class="card-header">
Accounts
</mat-card-title>
<div class="card-body">
<div class="row card-header">
<mat-form-field appearance="outline">
<mat-label> ACCOUNT TYPE </mat-label>
<mat-select id="typeSelect" [(value)]="accountsType" (selectionChange)="filterAccounts()">
<mat-option value="all">ALL</mat-option>
<mat-option value="user">USER</mat-option>
<mat-option value="cashier">CASHIER</mat-option>
<mat-option value="vendor">VENDOR</mat-option>
<mat-option value="tokenAgent">TOKENAGENT</mat-option>
<mat-option value="group">GROUPACCOUNT</mat-option>
</mat-select>
</mat-form-field>
<button mat-raised-button color="primary" routerLink="/accounts/export" type="button" class="btn btn-outline-primary ml-auto"> EXPORT </button>
</div>
<mat-form-field appearance="outline">
<mat-label> Filter </mat-label>
<input matInput type="text" (keyup)="doFilter($event.target.value)" placeholder="Filter">
<mat-icon matSuffix>search</mat-icon>
</mat-form-field>
<mat-table class="mat-elevation-z10" [dataSource]="dataSource" matSort matSortActive="created"
matSortDirection="desc" matSortDisableClear>
<ng-container matColumnDef="name">
<mat-header-cell *matHeaderCellDef mat-sort-header> NAME </mat-header-cell>
<mat-cell *matCellDef="let user"> {{user?.vcard.fn[0].value}} </mat-cell>
</ng-container>
<ng-container matColumnDef="phone">
<mat-header-cell *matHeaderCellDef mat-sort-header> PHONE NUMBER </mat-header-cell>
<mat-cell *matCellDef="let user"> {{user?.vcard.tel[0].value}} </mat-cell>
</ng-container>
<ng-container matColumnDef="created">
<mat-header-cell *matHeaderCellDef mat-sort-header> CREATED </mat-header-cell>
<mat-cell *matCellDef="let user"> {{user?.date_registered | date}} </mat-cell>
</ng-container>
<ng-container matColumnDef="balance">
<mat-header-cell *matHeaderCellDef mat-sort-header> BALANCE </mat-header-cell>
<mat-cell *matCellDef="let user"> {{user?.balance | tokenRatio}} </mat-cell>
</ng-container>
<ng-container matColumnDef="location">
<mat-header-cell *matHeaderCellDef mat-sort-header> LOCATION </mat-header-cell>
<mat-cell *matCellDef="let user"> {{user?.location.area_name}} </mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
<mat-row *matRowDef="let account; columns: displayedColumns" (click)="viewAccount(account)" matRipple></mat-row>
</mat-table>
<mat-paginator [pageSize]="defaultPageSize" [pageSizeOptions]="pageSizeOptions" showFirstLastButtons></mat-paginator>
</div>
</div>
</div>
<app-footer appMenuSelection></app-footer>
</div>
<!-- ============================================================== -->
<!-- End Page content -->
<!-- ============================================================== -->
</div>

View File

@ -0,0 +1,48 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AccountsComponent } from './accounts.component';
import {FooterStubComponent, SidebarStubComponent, TopbarStubComponent, UserServiceStub} from '@src/testing';
import {AccountsModule} from '@pages/accounts/accounts.module';
import {AppModule} from '@app/app.module';
import {HttpClient} from '@angular/common/http';
import {HttpClientTestingModule, HttpTestingController} from '@angular/common/http/testing';
import {UserService} from '@app/_services';
describe('AccountsComponent', () => {
let component: AccountsComponent;
let fixture: ComponentFixture<AccountsComponent>;
let httpClient: HttpClient;
let httpTestingController: HttpTestingController;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [
AccountsComponent,
FooterStubComponent,
SidebarStubComponent,
TopbarStubComponent
],
imports: [
AccountsModule,
AppModule,
HttpClientTestingModule,
],
providers: [
{ provide: UserService, useClass: UserServiceStub }
]
})
.compileComponents();
httpClient = TestBed.inject(HttpClient);
httpTestingController = TestBed.inject(HttpTestingController);
});
beforeEach(() => {
fixture = TestBed.createComponent(AccountsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,59 @@
import {ChangeDetectionStrategy, Component, OnInit, ViewChild} from '@angular/core';
import {MatTableDataSource} from '@angular/material/table';
import {MatPaginator} from '@angular/material/paginator';
import {MatSort} from '@angular/material/sort';
import {UserService} from '@app/_services';
import {Router} from '@angular/router';
@Component({
selector: 'app-accounts',
templateUrl: './accounts.component.html',
styleUrls: ['./accounts.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AccountsComponent implements OnInit {
dataSource: MatTableDataSource<any>;
accounts: any[] = [];
displayedColumns = ['name', 'phone', 'created', 'balance', 'location'];
defaultPageSize = 10;
pageSizeOptions = [10, 20, 50, 100];
accountsType = 'all';
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
constructor(
private userService: UserService,
private router: Router
) {
this.userService.loadAccounts(100).then();
}
ngOnInit(): void {
this.userService.accountsSubject.subscribe(accounts => {
this.dataSource = new MatTableDataSource<any>(accounts);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
this.accounts = accounts;
});
}
doFilter(value: string): void {
this.dataSource.filter = value.trim().toLocaleLowerCase();
}
viewAccount(account): void {
this.router.navigateByUrl(`/accounts/${account.identities.evm['bloxberg:8996']}`).then();
}
filterAccounts(): void {
if (this.accountsType === 'all') {
this.userService.accountsSubject.subscribe(accounts => {
this.dataSource.data = accounts;
this.accounts = accounts;
});
} else {
this.dataSource.data = this.accounts.filter(account => account.type === this.accountsType);
}
}
}

View File

@ -0,0 +1,53 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AccountsRoutingModule } from '@pages/accounts/accounts-routing.module';
import { AccountsComponent } from '@pages/accounts/accounts.component';
import {SharedModule} from '@app/shared/shared.module';
import { AccountDetailsComponent } from '@pages/accounts/account-details/account-details.component';
import {DataTablesModule} from 'angular-datatables';
import { CreateAccountComponent } from '@pages/accounts/create-account/create-account.component';
import { DisbursementComponent } from '@pages/accounts/disbursement/disbursement.component';
import { ExportAccountsComponent } from '@pages/accounts/export-accounts/export-accounts.component';
import {MatTableModule} from '@angular/material/table';
import {MatSortModule} from '@angular/material/sort';
import {MatCheckboxModule} from '@angular/material/checkbox';
import {MatPaginatorModule} from '@angular/material/paginator';
import {MatInputModule} from '@angular/material/input';
import {MatFormFieldModule} from '@angular/material/form-field';
import {MatButtonModule} from '@angular/material/button';
import {MatCardModule} from '@angular/material/card';
import {MatIconModule} from '@angular/material/icon';
import {MatSelectModule} from '@angular/material/select';
import {TransactionsModule} from '@pages/transactions/transactions.module';
import {MatTabsModule} from '@angular/material/tabs';
import {MatRippleModule} from '@angular/material/core';
import {MatProgressSpinnerModule} from '@angular/material/progress-spinner';
import {ReactiveFormsModule} from '@angular/forms';
@NgModule({
declarations: [AccountsComponent, AccountDetailsComponent, CreateAccountComponent, DisbursementComponent, ExportAccountsComponent],
imports: [
CommonModule,
AccountsRoutingModule,
SharedModule,
DataTablesModule,
MatTableModule,
MatSortModule,
MatCheckboxModule,
MatPaginatorModule,
MatInputModule,
MatFormFieldModule,
MatButtonModule,
MatCardModule,
MatIconModule,
MatSelectModule,
TransactionsModule,
MatTabsModule,
MatRippleModule,
MatProgressSpinnerModule,
ReactiveFormsModule
]
})
export class AccountsModule { }

View File

@ -0,0 +1,147 @@
<!-- Begin page -->
<div class="wrapper">
<app-sidebar></app-sidebar>
<!-- ============================================================== -->
<!-- Start Page Content here -->
<!-- ============================================================== -->
<div id="content">
<app-topbar></app-topbar>
<!-- Start Content-->
<div class="container-fluid" appMenuSelection>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a routerLink="/home">Home</a></li>
<li class="breadcrumb-item"><a routerLink="/accounts">Accounts</a></li>
<li class="breadcrumb-item active" aria-current="page">Create Account</li>
</ol>
</nav>
<div class="card">
<mat-card-title class="card-header text-center">
CREATE A USER ACCOUNT
</mat-card-title>
<div class="card-body">
<form class="row form-inline" [formGroup]="createForm" (ngSubmit)="onSubmit()">
<div class="col-md-6 col-lg-4">
<mat-form-field appearance="outline">
<mat-label>Account Type: </mat-label>
<mat-select id="accountType" formControlName="accountType" [errorStateMatcher]="matcher">
<mat-option value="user">USER</mat-option>
<mat-option value="cashier">CASHIER</mat-option>
<mat-option value="vendor">VENDOR</mat-option>
<mat-option value="tokenAgent">TOKENAGENT</mat-option>
<mat-option value="group">GROUPACCOUNT</mat-option>
</mat-select>
<mat-error *ngIf="submitted && createFormStub.accountType.errors">Account type is required.</mat-error>
</mat-form-field><br>
</div>
<div class="col-md-6 col-lg-4">
<mat-form-field appearance="outline">
<mat-label>ID Number: </mat-label>
<input matInput type="text" id="idNumber" placeholder="ID Number" formControlName="idNumber" [errorStateMatcher]="matcher">
<mat-error *ngIf="submitted && createFormStub.idNumber.errors">ID Number is required.</mat-error>
</mat-form-field>
</div>
<div class="col-md-6 col-lg-4">
<mat-form-field appearance="outline">
<mat-label>Phone Number: </mat-label>
<input matInput type="text" id="phoneNumber" placeholder="Phone Number" formControlName="phoneNumber" [errorStateMatcher]="matcher">
<mat-error *ngIf="submitted && createFormStub.phoneNumber.errors">Phone Number is required.</mat-error>
</mat-form-field><br>
</div>
<div class="col-md-6 col-lg-4">
<mat-form-field appearance="outline">
<mat-label>Given Name(s):* </mat-label>
<input matInput type="text" id="givenNames" placeholder="Given Names" formControlName="givenName" [errorStateMatcher]="matcher">
<mat-error *ngIf="submitted && createFormStub.givenName.errors">Given Names are required.</mat-error>
</mat-form-field><br>
</div>
<div class="col-md-6 col-lg-4">
<mat-form-field appearance="outline">
<mat-label>Family/Surname: </mat-label>
<input matInput type="text" id="surname" placeholder="Surname" formControlName="surname" [errorStateMatcher]="matcher">
<mat-error *ngIf="submitted && createFormStub.surname.errors">Surname is required.</mat-error>
</mat-form-field><br>
</div>
<div class="col-md-6 col-lg-4">
<mat-form-field appearance="outline">
<mat-label>Directory Entry: </mat-label>
<input matInput type="text" id="directoryEntry" placeholder="Directory Entry" formControlName="directoryEntry" [errorStateMatcher]="matcher">
<mat-error *ngIf="submitted && createFormStub.directoryEntry.errors">Directory Entry is required.</mat-error>
</mat-form-field><br>
</div>
<div class="col-md-6 col-lg-4">
<mat-form-field appearance="outline">
<mat-label>Location: </mat-label>
<mat-select id="location" formControlName="location" [errorStateMatcher]="matcher">
<div *ngFor="let county of locations; trackBy: trackByName">
<div *ngFor="let district of county.districts; trackBy: trackByName">
<mat-optgroup *ngFor="let location of district.locations; trackBy: trackByName" [label]="county.name + ' / ' + district.name + ' / ' + location.name">
<mat-option *ngFor="let village of location.villages; trackBy: trackByName" [value]="village">
{{village}}
</mat-option>
</mat-optgroup>
</div>
</div>
</mat-select>
<mat-error *ngIf="submitted && createFormStub.location.errors">Location is required.</mat-error>
</mat-form-field><br>
</div>
<div class="col-md-6 col-lg-4">
<mat-form-field appearance="outline">
<mat-label>Gender: </mat-label>
<mat-select id="gender" formControlName="gender" [errorStateMatcher]="matcher">
<mat-option value="female">FEMALE</mat-option>
<mat-option value="male">MALE</mat-option>
<mat-option value="other">OTHER</mat-option>
</mat-select>
<mat-error *ngIf="submitted && createFormStub.gender.errors">Gender is required.</mat-error>
</mat-form-field><br>
</div>
<div class="col-md-6 col-lg-4">
<mat-form-field appearance="outline">
<mat-label>Referrer Phone Number: </mat-label>
<input matInput type="text" id="referredBy" placeholder="Reffered By" formControlName="referrer" [errorStateMatcher]="matcher">
<mat-error *ngIf="submitted && createFormStub.referrer.errors">Referrer is required.</mat-error>
</mat-form-field><br>
</div>
<div class="col-md-6 col-lg-4">
<mat-form-field appearance="outline">
<mat-label>Business Category: </mat-label>
<mat-select id="businessCategory" formControlName="businessCategory" [errorStateMatcher]="matcher">
<mat-option value="food/water">Food/Water</mat-option>
<mat-option value="fuel/energy">Fuel/Energy</mat-option>
<mat-option value="education">Education</mat-option>
<mat-option value="health">Health</mat-option>
<mat-option value="shop">Shop</mat-option>
<mat-option value="environment">Environment</mat-option>
<mat-option value="transport">Transport</mat-option>
<mat-option value="farming/labour">Farming/Labour</mat-option>
<mat-option value="savings">Savings Group</mat-option>
<mat-option value="other">Other</mat-option>
</mat-select>
<mat-error *ngIf="submitted && createFormStub.businessCategory.errors">Business Category is required.</mat-error>
</mat-form-field>
</div>
<button mat-raised-button color="primary" type="submit" class="btn btn-outline-primary ml-3" (click)="onSubmit()">Submit</button>
</form>
</div>
</div>
</div>
<app-footer appMenuSelection></app-footer>
</div>
<!-- ============================================================== -->
<!-- End Page content -->
<!-- ============================================================== -->
</div>

View File

@ -0,0 +1,38 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CreateAccountComponent } from '@pages/accounts/create-account/create-account.component';
import {AccountsModule} from '@pages/accounts/accounts.module';
import {AppModule} from '@app/app.module';
import {FooterStubComponent, SidebarStubComponent, TopbarStubComponent} from '@src/testing';
describe('CreateAccountComponent', () => {
let component: CreateAccountComponent;
let fixture: ComponentFixture<CreateAccountComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [
CreateAccountComponent,
FooterStubComponent,
SidebarStubComponent,
TopbarStubComponent
],
imports: [
AccountsModule,
AppModule
]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(CreateAccountComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,71 @@
import {ChangeDetectionStrategy, Component, OnInit} from '@angular/core';
import {Router} from '@angular/router';
import {FormBuilder, FormGroup, Validators} from '@angular/forms';
import {LocationService, UserService} from '@app/_services';
import {CustomErrorStateMatcher} from '@app/_helpers';
@Component({
selector: 'app-create-account',
templateUrl: './create-account.component.html',
styleUrls: ['./create-account.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CreateAccountComponent implements OnInit {
createForm: FormGroup;
matcher = new CustomErrorStateMatcher();
submitted: boolean = false;
locations: any;
constructor(
private formBuilder: FormBuilder,
private router: Router,
private userService: UserService,
private locationService: LocationService
) { }
ngOnInit(): void {
this.createForm = this.formBuilder.group({
accountType: ['', Validators.required],
idNumber: ['', Validators.required],
phoneNumber: ['', Validators.required],
givenName: ['', Validators.required],
surname: ['', Validators.required],
directoryEntry: ['', Validators.required],
location: ['', Validators.required],
gender: ['', Validators.required],
referrer: ['', Validators.required],
businessCategory: ['', Validators.required]
});
this.locationService.getLocations();
this.locationService.locationsSubject.subscribe(locations => {
this.locations = locations;
});
}
get createFormStub(): any { return this.createForm.controls; }
onSubmit(): void {
this.submitted = true;
if (this.createForm.invalid) { return; }
// this.userService.createAccount(
// this.createFormStub.accountType.value,
// this.createFormStub.idNumber.value,
// this.createFormStub.phoneNumber.value,
// this.createFormStub.givenName.value,
// this.createFormStub.surname.value,
// this.createFormStub.directoryEntry.value,
// this.createFormStub.location.value,
// this.createFormStub.gender.value,
// this.createFormStub.referrer.value,
// this.createFormStub.businessCategory.value,
// ).pipe(first()).subscribe(res => {
// console.log(res);
// });
// this.router.navigateByUrl(`/accounts`);
this.submitted = false;
}
public trackByName(index, item): string {
return item.name;
}
}

View File

@ -0,0 +1,36 @@
<div class="card">
<mat-card-title class="card-header">
<div class="row">
NEW TRANSFER
<button mat-raised-button color="warn" type="button" class="btn btn-outline-danger ml-auto mr-2" (click)="cancel()"> CANCEL </button>
</div>
</mat-card-title>
<div class="card-body">
<form [formGroup]="disbursementForm" (ngSubmit)="createTransfer()">
<div class="row form-inline">
<mat-form-field appearance="outline">
<mat-label> TRANSACTION TYPE </mat-label>
<mat-select id="transactionType" formControlName="transactionType" [errorStateMatcher]="matcher">
<mat-option value="disbursement">DISBURSEMENT</mat-option>
<mat-option value="transfer">TRANSFER</mat-option>
<mat-option value="deposit">DEPOSIT</mat-option>
<mat-option value="reclamation">RECLAMATION</mat-option>
</mat-select>
<mat-error *ngIf="submitted && disbursementFormStub.transactionType.errors">Transaction type is required.</mat-error>
</mat-form-field>
<mat-form-field *ngIf="disbursementFormStub.transactionType.value === 'transfer'" appearance="outline" class="ml-3">
<mat-label>Enter Recipient: </mat-label>
<input matInput type="text" id="recipient" placeholder="Recipient" formControlName="recipient">
</mat-form-field>
<mat-form-field appearance="outline" class="ml-3">
<mat-label>Enter Amount: </mat-label>
<input matInput type="text" id="amount" placeholder="Amount" formControlName="amount" [errorStateMatcher]="matcher">
<mat-error *ngIf="submitted && disbursementFormStub.amount.errors">Amount is required.</mat-error>
</mat-form-field>
<button mat-raised-button color="primary" type="submit" class="btn btn-outline-primary ml-3" style="margin-bottom: 1.2rem;">
CREATE TRANSFER
</button>
</div>
</form>
</div>
</div>

View File

@ -0,0 +1,37 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DisbursementComponent } from '@pages/accounts/disbursement/disbursement.component';
import {AccountsModule} from '@pages/accounts/accounts.module';
import {AppModule} from '@app/app.module';
import {FooterStubComponent, SidebarStubComponent, TopbarStubComponent} from '@src/testing';
describe('DisbursementComponent', () => {
let component: DisbursementComponent;
let fixture: ComponentFixture<DisbursementComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [
DisbursementComponent,
FooterStubComponent,
SidebarStubComponent,
TopbarStubComponent
],
imports: [
AccountsModule,
AppModule
]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(DisbursementComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,51 @@
import {Component, OnInit, EventEmitter, Output, Input, ChangeDetectionStrategy} from '@angular/core';
import {FormBuilder, FormGroup, Validators} from '@angular/forms';
import {CustomErrorStateMatcher} from '@app/_helpers';
import {TransactionService} from '@app/_services';
@Component({
selector: 'app-disbursement',
templateUrl: './disbursement.component.html',
styleUrls: ['./disbursement.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DisbursementComponent implements OnInit {
@Input() account;
@Output() cancelDisbursmentEvent = new EventEmitter();
disbursementForm: FormGroup;
matcher = new CustomErrorStateMatcher();
submitted: boolean = false;
constructor(
private formBuilder: FormBuilder,
private transactionService: TransactionService
) { }
ngOnInit(): void {
this.disbursementForm = this.formBuilder.group({
transactionType: ['', Validators.required],
recipient: '',
amount: ['', Validators.required]
});
}
get disbursementFormStub(): any { return this.disbursementForm.controls; }
async createTransfer(): Promise<void> {
this.submitted = true;
if (this.disbursementForm.invalid) { return; }
if (this.disbursementFormStub.transactionType.value === 'transfer') {
await this.transactionService.transferRequest(
this.account.token,
this.account.address,
this.disbursementFormStub.recipient.value,
this.disbursementFormStub.amount.value
);
}
this.submitted = false;
}
cancel(): void {
this.cancelDisbursmentEvent.emit();
}
}

View File

@ -0,0 +1,53 @@
<!-- Begin page -->
<div class="wrapper">
<app-sidebar></app-sidebar>
<!-- ============================================================== -->
<!-- Start Page Content here -->
<!-- ============================================================== -->
<div id="content">
<app-topbar></app-topbar>
<!-- Start Content-->
<div class="container-fluid" appMenuSelection>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a routerLink="/home">Home</a></li>
<li class="breadcrumb-item"><a routerLink="/accounts">Accounts</a></li>
<li class="breadcrumb-item active" aria-current="page">Export Accounts</li>
</ol>
</nav>
<div class="card">
<mat-card-title class="card-header">
EXPORT ACCOUNTS
</mat-card-title>
<div class="card-body">
<form [formGroup]="exportForm" (ngSubmit)="export()">
<div class="form-inline mb-2">
<mat-form-field appearance="outline">
<mat-label>Export : </mat-label>
<mat-select id="accountType" formControlName="accountType" [errorStateMatcher]="matcher">
<mat-option value="vendors">VENDORS</mat-option>
<mat-option value="partners">PARTNERS</mat-option>
<mat-option value="selected">SELECTED</mat-option>
</mat-select>
<mat-error *ngIf="submitted && exportFormStub.accountType.errors">Account Type is required.</mat-error>
</mat-form-field>
</div>
<div class="form-inline mb-3">
<div class="form-group form-check">
<label class="form-check-label mr-2" for="transfers">Include transfers?</label>
<mat-checkbox id="transfers" formControlName="transfers"></mat-checkbox>
</div>
</div>
<button mat-raised-button color="primary" type="submit" class="btn btn-outline-primary"> EXPORT </button>
</form>
</div>
</div>
</div>
<app-footer appMenuSelection></app-footer>
</div>
<!-- ============================================================== -->
<!-- End Page content -->
<!-- ============================================================== -->
</div>

View File

@ -0,0 +1,37 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ExportAccountsComponent } from '@pages/accounts/export-accounts/export-accounts.component';
import {AccountsModule} from '@pages/accounts/accounts.module';
import {AppModule} from '@app/app.module';
import {FooterStubComponent, SidebarStubComponent, TopbarStubComponent} from '@src/testing';
describe('ExportAccountsComponent', () => {
let component: ExportAccountsComponent;
let fixture: ComponentFixture<ExportAccountsComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [
ExportAccountsComponent,
FooterStubComponent,
SidebarStubComponent,
TopbarStubComponent
],
imports: [
AccountsModule,
AppModule
]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ExportAccountsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,34 @@
import {ChangeDetectionStrategy, Component, OnInit} from '@angular/core';
import {FormBuilder, FormGroup, Validators} from '@angular/forms';
import {CustomErrorStateMatcher} from '@app/_helpers';
@Component({
selector: 'app-export-accounts',
templateUrl: './export-accounts.component.html',
styleUrls: ['./export-accounts.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ExportAccountsComponent implements OnInit {
exportForm: FormGroup;
matcher = new CustomErrorStateMatcher();
submitted: boolean = false;
constructor(
private formBuilder: FormBuilder
) { }
ngOnInit(): void {
this.exportForm = this.formBuilder.group({
accountType: ['', Validators.required],
transfers: ['']
});
}
get exportFormStub(): any { return this.exportForm.controls; }
export(): void {
this.submitted = true;
if (this.exportForm.invalid) { return; }
this.submitted = false;
}
}

View File

@ -0,0 +1,12 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { AdminComponent } from '@pages/admin/admin.component';
const routes: Routes = [{ path: '', component: AdminComponent }];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class AdminRoutingModule { }

View File

@ -0,0 +1,102 @@
<!-- Begin page -->
<div class="wrapper">
<app-sidebar></app-sidebar>
<!-- ============================================================== -->
<!-- Start Page Content here -->
<!-- ============================================================== -->
<div id="content">
<app-topbar></app-topbar>
<!-- Start Content-->
<div class="container-fluid" appMenuSelection>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a routerLink="/home">Home</a></li>
<li class="breadcrumb-item active" aria-current="page">Admin</li>
</ol>
</nav>
<div class="card">
<mat-card-title class="card-header">
Actions
</mat-card-title>
<div class="card-body">
<mat-form-field appearance="outline">
<mat-label> Filter </mat-label>
<input matInput type="text" (keyup)="doFilter($event.target.value)" placeholder="Filter">
<mat-icon matSuffix>search</mat-icon>
</mat-form-field>
<mat-table class="mat-elevation-z10" [dataSource]="dataSource" multiTemplateDataRows>
<!-- Expand Column -->
<ng-container matColumnDef="expand">
<mat-header-cell *matHeaderCellDef> Expand </mat-header-cell>
<mat-cell *matCellDef="let element" (click)="expandCollapse(element)">
<span *ngIf="!element.isExpanded" class="signs"> + </span>
<span *ngIf="element.isExpanded" class="signs"> - </span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="user">
<mat-header-cell *matHeaderCellDef> NAME </mat-header-cell>
<mat-cell *matCellDef="let action"> {{action.user}} </mat-cell>
</ng-container>
<ng-container matColumnDef="role">
<mat-header-cell *matHeaderCellDef> ROLE </mat-header-cell>
<mat-cell *matCellDef="let action"> {{action.role}} </mat-cell>
</ng-container>
<ng-container matColumnDef="action">
<mat-header-cell *matHeaderCellDef> ACTION </mat-header-cell>
<mat-cell *matCellDef="let action"> {{action.action}} </mat-cell>
</ng-container>
<ng-container matColumnDef="status">
<mat-header-cell *matHeaderCellDef> STATUS </mat-header-cell>
<mat-cell *matCellDef="let action">
<span *ngIf="action.approval == true" class="badge badge-success badge-pill"> {{approvalStatus(action.approval)}} </span>
<span *ngIf="action.approval == false" class="badge badge-danger badge-pill"> {{approvalStatus(action.approval)}} </span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="approve">
<mat-header-cell *matHeaderCellDef> APPROVE </mat-header-cell>
<mat-cell *matCellDef="let action">
<button mat-raised-button color="primary" *ngIf="!action.approval" class="btn btn-outline-success" (click)="approveAction(action)"> Approve </button>
<button mat-raised-button color="warn" *ngIf="action.approval" class="btn btn-outline-danger" (click)="revertAction(action)"> Revert </button>
</mat-cell>
</ng-container>
<!-- Expanded Content Column - The detail row is made up of this one column -->
<ng-container matColumnDef="expandedDetail">
<mat-cell *matCellDef="let action">
<div>
<span><strong>Staff Name:</strong> {{action.user}}</span><br>
<span><strong>Role:</strong> {{action.role}}</span><br>
<span><strong>Action Details:</strong> {{action.action}}</span><br>
<span><strong>Approval Status:</strong> {{action.approval}}</span><br>
</div>
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: displayedColumns;" matRipple class="element-row"
[class.expanded]="row.isExpanded"></mat-row>
<mat-row *matRowDef="let row; columns: ['expandedDetail'];"
[@detailExpand]="row.isExpanded == true ? 'expanded': 'collapsed'" style="overflow: hidden"></mat-row>
</mat-table>
<mat-paginator [pageSize]="10" [pageSizeOptions]="[10, 20, 50, 100]" showFirstLastButtons></mat-paginator>
</div>
</div>
</div>
<app-footer appMenuSelection></app-footer>
</div>
<!-- ============================================================== -->
<!-- End Page content -->
<!-- ============================================================== -->
</div>

View File

@ -0,0 +1,3 @@
button {
width: 6rem;
}

View File

@ -0,0 +1,61 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AdminComponent } from '@pages/admin/admin.component';
import {HttpClient} from '@angular/common/http';
import {HttpClientTestingModule, HttpTestingController} from '@angular/common/http/testing';
import {AdminModule} from '@pages/admin/admin.module';
import {FooterStubComponent, SidebarStubComponent, TopbarStubComponent, UserServiceStub} from '@src/testing';
import {AppModule} from '@app/app.module';
import {UserService} from '@app/_services';
describe('AdminComponent', () => {
let component: AdminComponent;
let fixture: ComponentFixture<AdminComponent>;
let httpClient: HttpClient;
let httpTestingController: HttpTestingController;
let userService: UserServiceStub;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [
AdminComponent,
FooterStubComponent,
SidebarStubComponent,
TopbarStubComponent
],
imports: [
AdminModule,
AppModule,
HttpClientTestingModule,
],
providers: [
{ provide: UserService, useClass: UserServiceStub }
]
})
.compileComponents();
httpClient = TestBed.inject(HttpClient);
httpTestingController = TestBed.inject(HttpTestingController);
userService = new UserServiceStub();
});
beforeEach(() => {
fixture = TestBed.createComponent(AdminComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('#approveAction should toggle approval status', () => {
const action = userService.getActionById('1');
expect(action).toBe({
id: 1,
user: 'Tom',
role: 'enroller',
action: 'Disburse RSV 100',
approval: false
});
});
});

View File

@ -0,0 +1,65 @@
import {ChangeDetectionStrategy, Component, OnInit, ViewChild} from '@angular/core';
import {MatTableDataSource} from '@angular/material/table';
import {MatPaginator} from '@angular/material/paginator';
import {MatSort} from '@angular/material/sort';
import {UserService} from '@app/_services';
import {animate, state, style, transition, trigger} from '@angular/animations';
import {first} from 'rxjs/operators';
@Component({
selector: 'app-admin',
templateUrl: './admin.component.html',
styleUrls: ['./admin.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [
trigger('detailExpand', [
state('collapsed', style({height: '0px', minHeight: 0, visibility: 'hidden'})),
state('expanded', style({height: '*', visibility: 'visible'})),
transition('expanded <=> collapsed', animate('225ms cubic-bezier(0.4, 0.0, 0.2, 1)')),
])
]
})
export class AdminComponent implements OnInit {
dataSource: MatTableDataSource<any>;
displayedColumns = ['expand', 'user', 'role', 'action', 'status', 'approve'];
action: any;
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
constructor(
private userService: UserService
) {
this.userService.getActions();
this.userService.actionsSubject.subscribe(actions => {
this.dataSource = new MatTableDataSource<any>(actions);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
});
}
ngOnInit(): void {
}
doFilter(value: string): void {
this.dataSource.filter = value.trim().toLocaleLowerCase();
}
approvalStatus(status: boolean): string {
return status ? 'Approved' : 'Unapproved';
}
approveAction(action: any): void {
this.userService.approveAction(action.id).pipe(first()).subscribe(res => console.log(res.body));
this.userService.getActions();
}
revertAction(action: any): void {
this.userService.revokeAction(action.id).pipe(first()).subscribe(res => console.log(res.body));
this.userService.getActions();
}
expandCollapse(row): void {
row.isExpanded = !row.isExpanded;
}
}

View File

@ -0,0 +1,35 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AdminRoutingModule } from '@pages/admin/admin-routing.module';
import { AdminComponent } from '@pages/admin/admin.component';
import {SharedModule} from '@app/shared/shared.module';
import {MatCardModule} from '@angular/material/card';
import {MatFormFieldModule} from '@angular/material/form-field';
import {MatInputModule} from '@angular/material/input';
import {MatIconModule} from '@angular/material/icon';
import {MatTableModule} from '@angular/material/table';
import {MatSortModule} from '@angular/material/sort';
import {MatPaginatorModule} from '@angular/material/paginator';
import {MatButtonModule} from '@angular/material/button';
import {MatRippleModule} from '@angular/material/core';
@NgModule({
declarations: [AdminComponent],
imports: [
CommonModule,
AdminRoutingModule,
SharedModule,
MatCardModule,
MatFormFieldModule,
MatInputModule,
MatIconModule,
MatTableModule,
MatSortModule,
MatPaginatorModule,
MatButtonModule,
MatRippleModule
]
})
export class AdminModule { }

View File

@ -0,0 +1,20 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { PagesComponent } from './pages.component';
const routes: Routes = [
{ path: 'home', component: PagesComponent },
{ path: 'tx', loadChildren: () => import('@pages/transactions/transactions.module').then(m => m.TransactionsModule) },
{ path: 'settings', loadChildren: () => import('@pages/settings/settings.module').then(m => m.SettingsModule) },
{ path: 'accounts', loadChildren: () => import('@pages/accounts/accounts.module').then(m => m.AccountsModule) },
{ path: 'tokens', loadChildren: () => import('@pages/tokens/tokens.module').then(m => m.TokensModule) },
{ path: 'admin', loadChildren: () => import('@pages/admin/admin.module').then(m => m.AdminModule) },
{ path: '**', redirectTo: 'home', pathMatch: 'full'}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class PagesRoutingModule { }

View File

@ -0,0 +1,116 @@
<!-- Begin page -->
<div class="wrapper">
<app-sidebar></app-sidebar>
<!-- ============================================================== -->
<!-- Start Page Content here -->
<!-- ============================================================== -->
<div id="content">
<app-topbar></app-topbar>
<!-- Start Content-->
<div class="container-fluid" appMenuSelection>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item active" aria-current="page">Home</li>
</ol>
</nav>
<div class="card">
<mat-card-title class="card-header">
CICADA DASHBOARD
</mat-card-title>
<div class="col-12">
<div class="card-body">
<mat-form-field appearance="outline">
<mat-label>Filter by location : </mat-label>
<mat-select class="ml-2" id="filterUser">
<div *ngFor="let county of locations; trackBy: trackByName">
<div *ngFor="let district of county.districts; trackBy: trackByName">
<mat-optgroup *ngFor="let location of district.locations; trackBy: trackByName" [label]="county.name + ' / ' + district.name + ' / ' + location.name">
<mat-option *ngFor="let village of location.villages; trackBy: trackByName" [value]="village">
{{village}}
</mat-option>
</mat-optgroup>
</div>
</div>
</mat-select>
</mat-form-field>
</div>
<div class="row">
<div class="card col-md-8">
<div class="card-body">
<div style="display: block">
<canvas baseChart
[datasets]="lineChartData"
[labels]="lineChartLabels"
[options]="lineChartOptions"
[colors]="lineChartColors"
[legend]="lineChartLegend"
[chartType]="lineChartType"
(chartHover)="chartHovered($event)"
(chartClick)="chartClicked($event)">
</canvas>
</div>
</div>
<hr>
<div class="card-body row">
<div class="col-md-3 text-center">
<h4>MASTER WALLET BALANCE</h4>
<p>{{10000000 | number}} RCU</p>
</div>
<div class="col-md-3 text-center">
<h4>TOTAL DISTRIBUTED</h4>
<p>{{disbursements * 1000 | number}} RCU</p>
</div>
<div class="col-md-3 text-center">
<h4>TOTAL SPENT</h4>
<p>{{transactions * 100000 | number}} RCU</p>
</div>
<div class="col-md-3 text-center">
<h4>TOTAL USERS</h4>
<p>{{users * 10 | number}} users</p>
</div>
</div>
<hr>
<div class="card-body">
<div style="display: block">
<canvas baseChart
[datasets]="barChartData"
[labels]="barChartLabels"
[options]="barChartOptions"
[legend]="barChartLegend"
[chartType]="barChartType">
</canvas>
</div>
</div>
</div>
<div class="card col-md-4">
<div class="card-body text-center">
<h4>TRANSFER USAGES</h4>
<div style="display: block">
<canvas baseChart
[data]="transferUsagesChartData"
[labels]="transferUsagesChartLabels"
[options]="transferUsagesChartOptions"
[colors]="transferUsagesChartColors"
[legend]="transferUsagesChartLegend"
[chartType]="transferUsagesChartType">
</canvas>
</div>
</div>
<hr>
<div class="card-body">
<h4 class="text-center">PARTNER LIVE FEED</h4>
</div>
</div>
</div>
</div>
</div>
</div>
<app-footer appMenuSelection></app-footer>
</div>
<!-- ============================================================== -->
<!-- End Page content -->
<!-- ============================================================== -->
</div>

View File

View File

@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PagesComponent } from '@pages/pages.component';
describe('PagesComponent', () => {
let component: PagesComponent;
let fixture: ComponentFixture<PagesComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ PagesComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(PagesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,190 @@
import {ChangeDetectionStrategy, Component, OnInit} from '@angular/core';
import {Color, Label} from 'ng2-charts';
import {ChartDataSets, ChartOptions, ChartType} from 'chart.js';
import {LocationService, UserService} from '@app/_services';
import {ArraySum} from '@app/_helpers';
@Component({
selector: 'app-pages',
templateUrl: './pages.component.html',
styleUrls: ['./pages.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class PagesComponent implements OnInit {
disbursements: number = 0;
users: any;
locations: any;
transactions: number = 0;
public lineChartData: ChartDataSets[] = [
{ data: [65, 59, 80, 81, 56, 55, 40], label: 'User Registration'},
{ data: [28, 48, 40, 19, 86, 27, 90], label: 'Transaction Volumes'},
{ data: [180, 480, 770, 90, 1000, 270, 400], label: 'Token Disbursements', yAxisID: 'y-axis-1'}
];
public lineChartLabels: Label[] = ['January', 'February', 'March', 'April', 'May', 'June', 'July'];
public lineChartOptions: (ChartOptions & { annotation: any }) = {
responsive: true,
scales: {
// We use this empty structure as a placeholder for dynamic theming.
xAxes: [{}],
yAxes: [
{
id: 'y-axis-0',
position: 'left',
},
{
id: 'y-axis-1',
position: 'right',
gridLines: {
color: 'rgba(255,0,0,0.3)',
},
ticks: {
fontColor: 'red',
}
}
]
},
annotation: {
annotations: [
{
type: 'line',
mode: 'vertical',
scaleID: 'x-axis-0',
value: 'March',
borderColor: 'orange',
borderWidth: 2,
label: {
enabled: true,
fontColor: 'orange',
content: 'LineAnno'
}
},
],
},
};
public lineChartColors: Color[] = [
{ // grey
backgroundColor: 'rgba(148,159,177,0.2)',
borderColor: 'rgba(148,159,177,1)',
pointBackgroundColor: 'rgba(148,159,177,1)',
pointBorderColor: '#fff',
pointHoverBackgroundColor: '#fff',
pointHoverBorderColor: 'rgba(148,159,177,0.8)'
},
{ // dark grey
backgroundColor: 'rgba(77,83,96,0.2)',
borderColor: 'rgba(77,83,96,1)',
pointBackgroundColor: 'rgba(77,83,96,1)',
pointBorderColor: '#fff',
pointHoverBackgroundColor: '#fff',
pointHoverBorderColor: 'rgba(77,83,96,1)'
},
{ // red
backgroundColor: 'rgba(255,0,0,0.3)',
borderColor: 'red',
pointBackgroundColor: 'rgba(148,159,177,1)',
pointBorderColor: '#fff',
pointHoverBackgroundColor: '#fff',
pointHoverBorderColor: 'rgba(148,159,177,0.8)'
}
];
public lineChartLegend = true;
public lineChartType = 'line';
public barChartOptions: ChartOptions = {
responsive: true,
// We use these empty structures as placeholders for dynamic theming.
scales: { xAxes: [{}], yAxes: [{}] },
plugins: {
datalabels: {
anchor: 'end',
align: 'end',
}
}
};
public barChartLabels: Label[] = ['January', 'February', 'March', 'April', 'May', 'June', 'July'];
public barChartType: ChartType = 'horizontalBar';
public barChartLegend = true;
public barChartData: ChartDataSets[] = [
{ data: [65, 59, 80, 81, 56, 55, 40], label: 'New Users'},
{ data: [28, 48, 40, 19, 86, 27, 90], label: 'Recurrent Users'}
];
public transferUsagesChartLabels: Label[] = ['Food/Water', 'Fuel/Energy', 'Education', 'Health', 'Shop', 'Environment', 'Transport',
'Farming/Labour', 'Savings Group', 'Savings Group'];
public transferUsagesChartData: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
public transferUsagesChartType: ChartType = 'pie';
public transferUsagesChartOptions: ChartOptions = {
responsive: true,
legend: {
position: 'top',
},
plugins: {
datalabels: {
formatter: (value, ctx) => {
const label = ctx.chart.data.labels[ctx.dataIndex];
return label;
},
},
}
};
public transferUsagesChartLegend = true;
public transferUsagesChartColors = [
{
backgroundColor: [
'rgba(0,0,255,0.3)',
'rgba(255,0,0,0.3)',
'rgba(0,255,0,0.3)',
'rgba(255,0,255,0.3)',
'rgba(255,255,0,0.3)',
'rgba(0,255,255,0.3)',
'rgba(255,255,255,0.3)',
'rgba(255,100,0,0.3)',
'rgba(0,255,100,0.3)',
'rgba(100,0,255,0.3)'],
},
];
constructor(
private userService: UserService,
private locationService: LocationService
) {
this.locationService.getLocations();
this.locationService.locationsSubject.subscribe(locations => {
this.locations = locations;
});
}
ngOnInit(): void {
this.newDataPoint([50, 80], 'August');
this.transferUsagesChartData = [18, 12, 10, 8, 6, 5, 5, 3, 2, 1];
this.disbursements = ArraySum.arraySum(this.lineChartData.find(data => data.label === 'Token Disbursements').data);
this.users = ArraySum.arraySum(this.barChartData.find(data => data.label === 'New Users').data);
this.transactions = ArraySum.arraySum(this.lineChartData.find(data => data.label === 'Transaction Volumes').data);
}
newDataPoint(dataArr: any[], label: string): void {
this.barChartData.forEach((dataset, index) => {
this.barChartData[index] = Object.assign({}, this.barChartData[index], {
data: [...this.barChartData[index].data, dataArr[index]]
});
});
this.barChartLabels = [...this.barChartLabels, label];
}
public chartClicked({ event, active}: { event: MouseEvent, active: {}[] }): void {
console.log(event, active);
}
public chartHovered({ event, active }: { event: MouseEvent, active: {}[] }): void {
console.log(event, active);
}
public trackByName(index, item): string {
return item.name;
}
}

View File

@ -0,0 +1,29 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { PagesRoutingModule } from '@pages/pages-routing.module';
import { PagesComponent } from '@pages/pages.component';
import {SharedModule} from '@app/shared/shared.module';
import {ChartsModule} from 'ng2-charts';
import {MatButtonModule} from '@angular/material/button';
import {MatFormFieldModule} from '@angular/material/form-field';
import {MatSelectModule} from '@angular/material/select';
import {MatInputModule} from '@angular/material/input';
import {MatCardModule} from '@angular/material/card';
@NgModule({
declarations: [PagesComponent],
imports: [
CommonModule,
PagesRoutingModule,
SharedModule,
ChartsModule,
MatButtonModule,
MatFormFieldModule,
MatSelectModule,
MatInputModule,
MatCardModule
]
})
export class PagesModule { }

View File

@ -0,0 +1,51 @@
<!-- Begin page -->
<div class="wrapper">
<app-sidebar></app-sidebar>
<!-- ============================================================== -->
<!-- Start Page Content here -->
<!-- ============================================================== -->
<div id="content">
<app-topbar></app-topbar>
<!-- Start Content-->
<div class="container-fluid" appMenuSelection>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a routerLink="/home">Home</a></li>
<li class="breadcrumb-item"><a routerLink="/settings">Settings</a></li>
<li class="breadcrumb-item active" aria-current="page">Invite User</li>
</ol>
</nav>
<div class="col-md-6">
<div class="card">
<mat-card-title class="card-header text-center">
INVITE NEW USERS
</mat-card-title>
<div class="card-body">
<form [formGroup]="inviteForm" (ngSubmit)="invite()">
<mat-form-field appearance="outline">
<mat-label>Email Address: </mat-label>
<input matInput type="email" id="email" placeholder="Email" formControlName="email"
[errorStateMatcher]="matcher">
<mat-error *ngIf="submitted && inviteFormStub.email.errors">Email is required.</mat-error>
</mat-form-field><br>
<mat-radio-group aria-label="Select a role" formControlName="role">
<mat-radio-button value="Superadmin">Superadmin</mat-radio-button><br>
<mat-radio-button value="Admin">Admin</mat-radio-button><br>
<mat-radio-button value="Subadmin">Subadmin</mat-radio-button><br>
<mat-radio-button value="View">View</mat-radio-button><br>
</mat-radio-group>
<mat-error *ngIf="submitted && inviteFormStub.role.errors">Role is required.</mat-error>
<button mat-raised-button color="primary" type="submit" class="btn btn-outline-primary">INVITE</button>
</form>
</div>
</div>
</div>
</div>
<app-footer appMenuSelection></app-footer>
</div>
<!-- ============================================================== -->
<!-- End Page content -->
<!-- ============================================================== -->
</div>

View File

@ -0,0 +1,37 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { InviteComponent } from '@pages/settings/invite/invite.component';
import {FooterStubComponent, SidebarStubComponent, TopbarStubComponent} from '@src/testing';
import {SettingsModule} from '@pages/settings/settings.module';
import {AppModule} from '@app/app.module';
describe('InviteComponent', () => {
let component: InviteComponent;
let fixture: ComponentFixture<InviteComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [
InviteComponent,
FooterStubComponent,
SidebarStubComponent,
TopbarStubComponent
],
imports: [
AppModule,
SettingsModule,
]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(InviteComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,34 @@
import {ChangeDetectionStrategy, Component, OnInit} from '@angular/core';
import {FormBuilder, FormGroup, Validators} from '@angular/forms';
import {CustomErrorStateMatcher} from '@app/_helpers';
@Component({
selector: 'app-invite',
templateUrl: './invite.component.html',
styleUrls: ['./invite.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class InviteComponent implements OnInit {
inviteForm: FormGroup;
submitted: boolean = false;
matcher = new CustomErrorStateMatcher();
constructor(
private formBuilder: FormBuilder
) { }
ngOnInit(): void {
this.inviteForm = this.formBuilder.group({
email: ['', Validators.required],
role: ['', Validators.required]
});
}
get inviteFormStub(): any { return this.inviteForm.controls; }
invite(): void {
this.submitted = true;
if (this.inviteForm.invalid) { return; }
this.submitted = false;
}
}

Some files were not shown because too many files have changed in this diff Show More