From 7145a437b43820b59decf933cc5d26dd44a2838a Mon Sep 17 00:00:00 2001 From: Rene Vergara Date: Thu, 11 Nov 2021 09:18:38 -0600 Subject: [PATCH] Adjust visuals for small screens --- backend/app.js | 69 +++++++++--- backend/models/user.js | 5 +- src/app/app-routing.module.ts | 2 +- src/app/app.component.css | 2 - src/app/checkout/checkout.component.html | 2 +- src/app/header/header.component.html | 10 +- src/app/header/header.component.ts | 23 ---- src/app/listorders/listorders.component.css | 3 + src/app/listorders/listorders.component.html | 11 +- src/app/login/login.component.css | 5 + src/app/login/login.component.html | 107 ++++++++++--------- src/app/login/login.component.ts | 59 +++++----- src/app/order/order.service.ts | 5 +- src/app/tx.model.ts | 1 + src/app/user.model.ts | 3 + src/app/user.service.ts | 14 ++- src/app/viewer/viewer.component.css | 6 ++ src/app/viewer/viewer.component.html | 20 ++-- src/app/viewer/viewer.component.ts | 55 ++++++++++ src/assets/logo.png | Bin 0 -> 13575 bytes 20 files changed, 258 insertions(+), 144 deletions(-) create mode 100644 src/assets/logo.png diff --git a/backend/app.js b/backend/app.js index dd4b096..e51a2cd 100644 --- a/backend/app.js +++ b/backend/app.js @@ -2,6 +2,7 @@ const express = require('express'); const app = express(); const bodyparser = require('body-parser'); const cors = require('cors'); +const crypto = require('crypto'); const postmodel = require('./models/post'); const usermodel = require('./models/user'); const ownermodel = require('./models/owner'); @@ -12,6 +13,8 @@ const txmodel = require('./models/tx'); const mongoose = require('mongoose'); const stdrpc = require('stdrpc'); const CoinGecko = require('coingecko-api'); +var URLSafeBase64 = require('urlsafe-base64'); +var Buffer = require('buffer/').Buffer; var db = require('./config/db'); mongoose.connect('mongodb://'+db.user+':'+db.password+'@'+db.server+'/'+db.database).then(() => { @@ -74,16 +77,24 @@ function hexToString(hexString) { return str; } +function sendPin(pin, address) { + //var memo = URLSafeBase64.encode(Buffer.from('ZGO pin: '.concat(pin))); + var memo = Buffer.from('ZGO pin: '.concat(pin)).toString('hex'); + //console.log(typeof(memo)); + var amounts = [ + { + address: address, + amount: 0.00000001, + memo: memo + } + ]; + rpc.z_sendmany(fullnode.addr, amounts).catch((err) => { + console.log('Sendmany', err); + }); +} + var blockInterval = setInterval( function() { console.log('Node periodic Zcash scan'); - //usermodel.find({}, function (err, docs) { - //if (err) { - //console.log(err); - //} else { - //console.log(session, blocktime); - //console.log(docs); - //} - //}); rpc.z_listreceivedbyaddress(fullnode.addr, 1).then(txs => { var re = /.*ZGO::(.*)\sReply-To:\s(z\w+)/; async.each (txs, function(txData, callback) { @@ -95,17 +106,31 @@ var blockInterval = setInterval( function() { var address = match[2]; var session = match[1]; var blocktime = txData.blocktime; + var amount = txData.amount; + var expiration = blocktime; //console.log(' ', session, blocktime); if (txData.confirmations >= 10 ) { usermodel.findOne({address: address, session: session, blocktime: blocktime}).then(function(doc){ if (doc != null) { console.log('Found user'); } else { - console.log('User not found', session, blocktime); + console.log('User not found', session, blocktime, amount); + if (amount >= 0.001 && amount < 0.005){ + expiration = blocktime + 3600; + } else if (amount >= 0.005){ + expiration = blocktime + 24*3600; + } + console.log('exp', expiration); + const n = crypto.randomInt(0, 10000000); + const pin = n.toString().padStart(6, '0'); + sendPin(pin, address); var user = new usermodel({ address: address, session: session, - blocktime: blocktime + blocktime: blocktime, + expiration: expiration, + pin: pin, + validated: false }); user.save(function(error) { if (error) { @@ -181,6 +206,10 @@ app.use((req, res, next) => { }); +app.get('/api/test', (req, res, next) => { + sendPin('12345678', 'zs1w6nkameazc5gujm69350syl5w8tgvyaphums3pw8eytzy5ym08x7dvskmykkatmwrucmgv3er8e'); + res.status(200).send('Endpoint triggered'); +}); app.get('/api/users', (req, res, next) => { console.log('Get: /api/users'); @@ -223,8 +252,7 @@ app.get('/api/pending', (req, res, next) => { app.get('/api/getuser', (req, res, next) => { console.log('Get: /api/getuser/', req.query.session); var today = new Date().getTime() / 1000; - var expiration = today - (24*3600); - usermodel.find({'session': req.query.session, 'blocktime': { $gt: expiration }}). + usermodel.find({'session': req.query.session, 'expiration': { $gt: today }}). then((documents) => { if(documents.length > 0){ //console.log(documents); @@ -253,8 +281,6 @@ app.get('/api/blockheight', (req, res, next) => { }); }); - - app.get('/api/txs', (req, res, next) => { console.log('Get: /api/txs'); rpc.z_listreceivedbyaddress(fullnode.addr, 10).then(txs => { @@ -301,6 +327,21 @@ app.post('/api/addowner', (req, res, next) => { }); }); +app.post('/api/validateuser', (req, res, next) => { + console.log('Post: /api/validateuser'); + usermodel.findByIdAndUpdate(req.body.user._id, req.body.user, + function(err, docs) { + if (err) { + console.log(err); + } else { + res.status(201).json({ + message: 'User Validated', + user: docs + }); + } + }); +}); + app.post('/api/updateowner', (req, res, next) => { console.log('Post: /api/updateowner'); ownermodel.findByIdAndUpdate(req.body.owner._id, req.body.owner, diff --git a/backend/models/user.js b/backend/models/user.js index a40171e..83ae866 100644 --- a/backend/models/user.js +++ b/backend/models/user.js @@ -3,7 +3,10 @@ const mongoose = require('mongoose'); const userSchema = mongoose.Schema({ address: {type: String, required:true}, session: {type: String, required:true}, - blocktime: {type: Number, required:true} + blocktime: {type: Number, required:true}, + expiration: {type: Number, required:true, default:0}, + pin: {type: String, required:true}, + validated: {type: Boolean, required:true} }); module.exports = mongoose.model('User', userSchema); diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index cff4598..464931c 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -11,7 +11,7 @@ const routes: Routes = [ //{ path: 'create', component: PostCreateComponent, canActivate: [AuthGuardService]}, { path: 'shop', component: ViewerComponent, canActivate: [AuthGuardService]}, { path: 'orders', component: ListOrdersComponent, canActivate: [AuthGuardService]}, - { path: 'login', component: LoginComponent} + { path: 'login', component: LoginComponent, resolve: { response: NodeResolverService}} ]; @NgModule({ diff --git a/src/app/app.component.css b/src/app/app.component.css index 150f53d..435af29 100644 --- a/src/app/app.component.css +++ b/src/app/app.component.css @@ -1,5 +1,3 @@ main{ - margin-top: 16px; - width: 80%; margin: auto; } diff --git a/src/app/checkout/checkout.component.html b/src/app/checkout/checkout.component.html index 7034aea..3458441 100644 --- a/src/app/checkout/checkout.component.html +++ b/src/app/checkout/checkout.component.html @@ -1,5 +1,5 @@
-

Scan to make payment

+

Scan to make payment

diff --git a/src/app/header/header.component.html b/src/app/header/header.component.html index 6ea406e..5f2346e 100644 --- a/src/app/header/header.component.html +++ b/src/app/header/header.component.html @@ -1,14 +1,10 @@ - - Z Go! - -

Last block seen: {{heightUpdate | async}}

+
- +

Last block:

+

{{heightUpdate | async}}

diff --git a/src/app/header/header.component.ts b/src/app/header/header.component.ts index 7d828cf..4e88167 100644 --- a/src/app/header/header.component.ts +++ b/src/app/header/header.component.ts @@ -3,7 +3,6 @@ import { MatDialog, MatDialogConfig} from '@angular/material/dialog'; import {FullnodeService} from '../fullnode.service'; import { UserService } from '../user.service'; import {Subscription, Observable} from 'rxjs'; -import { SettingsComponent } from '../settings/settings.component'; import {Owner} from '../owner.model'; @@ -42,28 +41,6 @@ export class HeaderComponent implements OnInit, OnDestroy { ngOnDestroy(){ } - shortenZaddr(address:string) { - var addr = address; - var end = addr.length; - var last = end - 5; - return addr.substring(0,5).concat('...').concat(addr.substring(last, end)); - } - openSettings() { - - const dialogConfig = new MatDialogConfig(); - - dialogConfig.disableClose = true; - dialogConfig.autoFocus = true; - dialogConfig.data = this.owner; - - const dialogRef = this.dialog.open(SettingsComponent, dialogConfig); - dialogRef.afterClosed().subscribe((val) => { - if (val != null) { - console.log('Saving settings'); - this.userService.updateOwner(val); - } - }); - } } diff --git a/src/app/listorders/listorders.component.css b/src/app/listorders/listorders.component.css index 121ab70..a9d0799 100644 --- a/src/app/listorders/listorders.component.css +++ b/src/app/listorders/listorders.component.css @@ -20,3 +20,6 @@ img.icon{ .total{ font-size: large; } +img.total{ + margin-bottom:-2px; +} diff --git a/src/app/listorders/listorders.component.html b/src/app/listorders/listorders.component.html index f6fa94a..e14b581 100644 --- a/src/app/listorders/listorders.component.html +++ b/src/app/listorders/listorders.component.html @@ -1,6 +1,6 @@
-

{{(ownerUpdate | async)!.name}}

+

{{(ownerUpdate | async)!.name}}

@@ -11,11 +11,11 @@

Today's Total:

-

{{todayTotal}}

+

{{todayTotal | number: '1.0-6'}}

Overall Total:

-

{{total}}

+

{{total | number: '1.0-6'}}

@@ -25,13 +25,14 @@ - {{order.totalZec}} (@{{order.price | currency: 'USD'}}) + {{order.totalZec}} - {{order.timestamp | date: 'medium'}} + {{order.timestamp | date: 'short'}}

Order: {{order._id}}

+

Zcash price: {{order.price | currency: 'USD'}}

{{item.qty}} x {{item.name}} diff --git a/src/app/login/login.component.css b/src/app/login/login.component.css index 0177792..1e0275a 100644 --- a/src/app/login/login.component.css +++ b/src/app/login/login.component.css @@ -4,6 +4,7 @@ mat-card.coolcard{ background-color: #FF5722; color: #FFFFFF; + margin: 5px; } .icon{ font-family: 'Material Icons'; @@ -14,3 +15,7 @@ mat-card.coolcard{ font-size: 120%; padding: 3px; } +.alert-success{ + margin: 5px; + background-color: #BBFFBB +} diff --git a/src/app/login/login.component.html b/src/app/login/login.component.html index d571d2d..222274e 100644 --- a/src/app/login/login.component.html +++ b/src/app/login/login.component.html @@ -1,61 +1,62 @@
-

-
- __||__   _____       _ 
-|___  /  / ____|     | |
-   / /  | |  __  ___ | |
-  / /   | | |_ |/ _ \| |
- / /__  | |__| | (_) |_|
-/__  _|  \_____|\___/(_)
-   ||                   
-		
-

+ + + + + + + + + + + +

Last block seen: {{ heightUpdate | async }}

- - - - - - - - - -
- -

A non-custodial point-of-sale application, powered by Zcash!

-
    -
  • Your Zcash shielded address is your login.
  • -
  • Your customer pays directly to your wallet.
  • -
-
-
- -

Login received!

- - It has {{tx.confirmations}} confirmations, needs 10. - -
- -
- - Session length - - - {{ticket.viewValue}} - - - - - - -
-
-
+ +

A non-custodial point-of-sale application, powered by Zcash!

+

Your Zcash shielded address is your login.

+

Your customer pays directly to your wallet.

+
+ +

+ Check your wallet +

+ + PIN + + + + + +
+ +

Login received!

+ + It needs {{10 - tx.confirmations}} more confirmations. + +
+ +
+ + Session length + + + {{ticket.viewValue}} + + + + + + +
+
diff --git a/src/app/login/login.component.ts b/src/app/login/login.component.ts index 274d2e9..b54edf0 100644 --- a/src/app/login/login.component.ts +++ b/src/app/login/login.component.ts @@ -6,6 +6,7 @@ import { UserService } from '../user.service'; import { FullnodeService } from '../fullnode.service'; import { ScanComponent} from '../scan/scan.component'; import { Tx } from '../tx.model'; +import {User} from '../user.model'; import { Subscription, Observable } from 'rxjs'; import { v4 as uuidv4 } from 'uuid'; var QRCode = require('easyqrcodejs'); @@ -25,10 +26,19 @@ export class LoginComponent implements OnInit { nodeAddress: string = ''; localToken: string | null = ''; selectedValue: number = 0.001; + public user:User = { + address: '', + session: '', + blocktime: 0, + expiration: 0, + pin: '', + validated: false + }; private FullnodeSub: Subscription = new Subscription(); private UserSub: Subscription = new Subscription(); public heightUpdate: Observable; public uZaddrUpdate: Observable; + public userUpdate:Observable; public txsUpdate: Observable; tickets = [ { @@ -39,8 +49,10 @@ export class LoginComponent implements OnInit { viewValue: 'One day' } ]; + prompt: boolean = false; entryForm: FormGroup; + pinForm: FormGroup; constructor( private fb: FormBuilder, @@ -55,8 +67,15 @@ export class LoginComponent implements OnInit { this.entryForm = fb.group({ selectedSession: [0.001, Validators.required] }); + this.pinForm = fb.group({ + pinValue: [null, Validators.required] + }); this.heightUpdate = fullnodeService.heightUpdate; this.uZaddrUpdate = userService.uZaddrUpdate; + this.userUpdate = userService.userUpdate; + this.userUpdate.subscribe((user) => { + this.user = user; + }); this.txsUpdate = userService.txUpdate; this.txsUpdate.subscribe((txs) => { this.txs = txs; @@ -76,26 +95,7 @@ export class LoginComponent implements OnInit { localStorage.setItem('s4z_token', token); this.localToken = token; } - this.userService.findUser(); - this.userService.uZaddrUpdate. - subscribe((userAddr: string) => { - if (userAddr.length != 0) { - console.log('Log in found!'); - this.router.navigate(['/shop']); - } else { - console.log('No login for existing token found'); - //console.log('Showing QR code for login'); - ////console.log(URLSafeBase64.encode(Buffer.from('S4ZEC::'.concat(localToken)))); - //var codeString = `zcash:${this.nodeAddress}?amount=0.005&memo=${URLSafeBase64.encode(Buffer.from('ZGO::'.concat(this.localToken!)))}`; - //console.log(codeString); - //var qrcode = new QRCode(document.getElementById("qrcode"), { - //text: codeString, - //logo: "/assets/zcash.png", - //logoWidth: 80, - //logoHeight: 80 - //}); - } - }); + this.loginCheck(); }); this.intervalHolder = setInterval(() => { this.fullnodeService.getHeight(); @@ -106,21 +106,25 @@ export class LoginComponent implements OnInit { } loginCheck(){ + var today = new Date().getTime() / 1000; this.userService.findUser(); this.userService.findPending(); this.txsUpdate.subscribe((txs) => { this.txs = txs; }); - this.uZaddrUpdate.subscribe((userAddr: string) => { - if (userAddr.length != 0) { + this.userUpdate.subscribe((user) => { + if (user.expiration > today) { + this.prompt = true; console.log('Log in found in blockchain!'); - this.router.navigate(['/shop']); + if (user.validated) { + this.router.navigate(['/shop']); + } } }); } login() { - console.log('Dropdown:', this.entryForm.value.selectedSession); + //console.log('Dropdown:', this.entryForm.value.selectedSession); const dialogConfig = new MatDialogConfig(); dialogConfig.disableClose = true; @@ -137,6 +141,13 @@ export class LoginComponent implements OnInit { }); } + confirmPin(){ + if (this.user.pin === this.pinForm.value.pinValue) { + this.userService.validateUser(); + this.router.navigate(['/shop']); + } + } + ngOnDestroy(){ this.FullnodeSub.unsubscribe(); this.UserSub.unsubscribe(); diff --git a/src/app/order/order.service.ts b/src/app/order/order.service.ts index 69334b6..8d69190 100644 --- a/src/app/order/order.service.ts +++ b/src/app/order/order.service.ts @@ -16,7 +16,10 @@ export class OrderService { user:{ address: '', session: '', - blocktime: 0 + blocktime: 0, + expiration: 0, + pin: '', + validated: false }, order: { address: '', diff --git a/src/app/tx.model.ts b/src/app/tx.model.ts index 9e8d9b8..69b43f5 100644 --- a/src/app/tx.model.ts +++ b/src/app/tx.model.ts @@ -3,4 +3,5 @@ export interface Tx { address: string; session: string; confirmations: number; + amount: number; } diff --git a/src/app/user.model.ts b/src/app/user.model.ts index ff6ebca..bf43ae3 100644 --- a/src/app/user.model.ts +++ b/src/app/user.model.ts @@ -3,4 +3,7 @@ export interface User { address: string; session: string; blocktime: number; + expiration: number; + pin: string; + validated: boolean; } diff --git a/src/app/user.service.ts b/src/app/user.service.ts index f8a403f..524b5bb 100644 --- a/src/app/user.service.ts +++ b/src/app/user.service.ts @@ -13,7 +13,10 @@ export class UserService{ user: { address: '', session: '', - blocktime: 0 + blocktime: 0, + expiration: 0, + pin: '', + validated: false }, owner: { address: '', @@ -99,6 +102,15 @@ export class UserService{ } } + validateUser(){ + var validatedUser: User = this.dataStore.user; + validatedUser.validated = true; + this.http.post<{message: string, user: User}>(this.beUrl+'api/validateuser', {user: validatedUser}, {headers: this.reqHeaders}). + subscribe((responseData) => { + console.log(responseData.message); + }); + } + addOwner(address: string) { const owner: Owner={_id: '', address: address, name: 'Zgo-'.concat(address.substring(0,5))}; let obs = this.http.post<{message: string}>(this.beUrl+'api/addowner', {address: owner.address, name: owner.name}, {headers: this.reqHeaders}); diff --git a/src/app/viewer/viewer.component.css b/src/app/viewer/viewer.component.css index f6d0e97..f4e7eaf 100644 --- a/src/app/viewer/viewer.component.css +++ b/src/app/viewer/viewer.component.css @@ -1,3 +1,9 @@ * { font-family: 'Roboto Mono', monospace; } +.icon{ + font-family: 'Material Icons'; +} +.small{ + font-size: x-small; +} diff --git a/src/app/viewer/viewer.component.html b/src/app/viewer/viewer.component.html index e4cd943..0ef72f0 100644 --- a/src/app/viewer/viewer.component.html +++ b/src/app/viewer/viewer.component.html @@ -1,15 +1,13 @@
-

{{(ownerUpdate | async)!.name}}

+

{{(ownerUpdate | async)!.name}}

+

{{ shortenZaddr((ownerUpdate | async)!.address) }}

+ + +
- - - - - -
- - - -
+ + diff --git a/src/app/viewer/viewer.component.ts b/src/app/viewer/viewer.component.ts index fbbb81c..a13abe9 100644 --- a/src/app/viewer/viewer.component.ts +++ b/src/app/viewer/viewer.component.ts @@ -5,8 +5,10 @@ import { UserService } from '../user.service'; import { FullnodeService } from '../fullnode.service'; import { ItemService } from '../items/items.service'; import { Subscription, Observable } from 'rxjs'; +import { SettingsComponent } from '../settings/settings.component'; import {Owner} from '../owner.model'; +import {User} from '../user.model'; @Component({ @@ -16,9 +18,20 @@ import {Owner} from '../owner.model'; }) export class ViewerComponent implements OnInit { + intervalHolder: any; public message: string = "Welcome to the inside!"; + public user: User = { + address: '', + session: '', + blocktime: 0, + expiration: 0, + pin: '', + validated: false + }; + private owner: Owner= {_id:'', address: 'none', name:''}; public addrUpdate: Observable; public ownerUpdate: Observable; + public userUpdate: Observable; constructor( public fullnodeService: FullnodeService, @@ -28,15 +41,57 @@ export class ViewerComponent implements OnInit { ){ this.addrUpdate = fullnodeService.addrUpdate; this.ownerUpdate = userService.ownerUpdate; + this.ownerUpdate.subscribe((owner) => { + this.owner = owner; + }); + this.userUpdate = userService.userUpdate; + this.userUpdate.subscribe((user) => { + this.user = user; + }); } ngOnInit(){ this.ownerUpdate.subscribe((owner) => { this.message = owner.name; }); + this.loginCheck(); + this.intervalHolder = setInterval(() => { + this.loginCheck(); + }, 60000); } ngOnDestroy(){ } + shortenZaddr(address:string) { + var addr = address; + var end = addr.length; + var last = end - 5; + return addr.substring(0,5).concat('...').concat(addr.substring(last, end)); + } + openSettings() { + + const dialogConfig = new MatDialogConfig(); + + dialogConfig.disableClose = true; + dialogConfig.autoFocus = true; + dialogConfig.data = this.owner; + + const dialogRef = this.dialog.open(SettingsComponent, dialogConfig); + dialogRef.afterClosed().subscribe((val) => { + if (val != null) { + console.log('Saving settings'); + this.userService.updateOwner(val); + } + }); + } + + loginCheck(){ + var today = new Date().getTime() / 1000; + console.log('User check', this.user.validated); + if (this.user.expiration < today || !this.user.validated) { + console.log('Log in expired!'); + this.router.navigate(['/login']); + } + } } diff --git a/src/assets/logo.png b/src/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..5c1d4365d5276f069b5ff5cfba6312c0dc38c90b GIT binary patch literal 13575 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