From 1d675dcd0f4a060b8f5770d5e328fa9ec3fd1c25 Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Mon, 2 Sep 2024 13:03:39 +0700 Subject: [PATCH] feat: add buzzpay internal app --- api/api.go | 15 ++ api/models.go | 1 + .../src/assets/suggested-apps/buzzpay.png | Bin 0 -> 1924 bytes frontend/src/components/SuggestedAppData.tsx | 8 + frontend/src/routes.tsx | 9 +- frontend/src/screens/apps/ShowApp.tsx | 3 +- .../src/screens/internal-apps/BuzzPay.tsx | 145 ++++++++++++++++++ .../{UncleJimApp.tsx => UncleJim.tsx} | 2 +- frontend/src/types.ts | 8 +- 9 files changed, 183 insertions(+), 8 deletions(-) create mode 100644 frontend/src/assets/suggested-apps/buzzpay.png create mode 100644 frontend/src/screens/internal-apps/BuzzPay.tsx rename frontend/src/screens/internal-apps/{UncleJimApp.tsx => UncleJim.tsx} (99%) diff --git a/api/api.go b/api/api.go index edaff3e5..924f286f 100644 --- a/api/api.go +++ b/api/api.go @@ -13,6 +13,7 @@ import ( "time" "github.com/sirupsen/logrus" + "gorm.io/datatypes" "gorm.io/gorm" "github.com/getAlby/hub/alby" @@ -141,6 +142,20 @@ func (api *api) UpdateApp(userApp *db.App, updateAppRequest *UpdateAppRequest) e } } + if updateAppRequest.Metadata != nil { + var metadataBytes []byte + var err error + metadataBytes, err = json.Marshal(updateAppRequest.Metadata) + if err != nil { + logger.Logger.WithError(err).Error("Failed to serialize metadata") + return err + } + err = tx.Model(&db.App{}).Where("id", userApp.ID).Update("metadata", datatypes.JSON(metadataBytes)).Error + if err != nil { + return err + } + } + // Update existing permissions with new budget and expiry err := tx.Model(&db.AppPermission{}).Where("app_id", userApp.ID).Updates(map[string]interface{}{ "ExpiresAt": expiresAt, diff --git a/api/models.go b/api/models.go index fd01e69d..c6c72f55 100644 --- a/api/models.go +++ b/api/models.go @@ -83,6 +83,7 @@ type UpdateAppRequest struct { BudgetRenewal string `json:"budgetRenewal"` ExpiresAt string `json:"expiresAt"` Scopes []string `json:"scopes"` + Metadata Metadata `json:"metadata,omitempty"` } type CreateAppRequest struct { diff --git a/frontend/src/assets/suggested-apps/buzzpay.png b/frontend/src/assets/suggested-apps/buzzpay.png new file mode 100644 index 0000000000000000000000000000000000000000..81988f89103cd910cf364ce9992df53efda9e5aa GIT binary patch literal 1924 zcmV-~2YdL5P)RK zRp!xs%(-sq)P9qNPJ?zuu%BMPt!FJL6elAQ5e@~;yKl(0Y{IZ;yQ*Z5f=p>&Hds$A z#j|R0W;<WVPKnn>2i+)LAS20RMCEL6q`2YY0f=NU{RCwC#!GQn(004lX z{izqJ0ssI200000000000001(y}H{;7zm;Od?sZne+64msR|NN5fLeh27liFx!frg zO678mjXB@#p2=i4Gg%@cA|fIpA|fIpA|fIpA|fIpA|fIpBKik{Mot)*Dt<#P*CXlp zD3Mdy^SX`=+zTb=V;0p>7PYw)jgR15Ec>2U9h5^$emYCwS|mjeEtEni2h0o{yA^9P zwkD4iN!=QhfhUIy;Fdqvv2Q_{pm`=LMj1q+E(U>>4A-*Jgj?M^9RMGhdFS3xL1nNYrYiw0-#Oh%qTuWv=^7Uj1E6PVyTdWn_>Pc#Z%$ul62(^^_s@5-d z(S{UF{7t0b{EJ*WT}~SY0sy@0`Vhr{0~m9M`wAfmIUpqD{U58Bo+uT?>9`?pR*0z)2`m#r ze$oZ;PR$tE3NlXTVe0Zy7uFCMw_4>{AEl_q*Mkv_C)~`2%1!-g06BABG3oWYW|T+w z^wE3JUFy~fCp%#kM!<TV#EihGtZW&p_g1CMM$<&YaUQaz#7}^vZOEq$=6FZdcjDVCl;KZd(YajB~ z$Fz@u3uI(05dt+nX<}t_1-*GjSXv7tkp*l{9bT6vs}bMrJkxBTf2Mv*F}7IPIQsh3 zgS-_n%RRsU-B=<_kd)v|43XGP4`RHgjie!fRoa;H^htNRa;9*MSxI+vbbg_j@#LJl z7w(LDAl?0XK=Mc_8R}^0f}l~ESrL*&%Y_(GMOGDXQoxi$Ejc>sx7ellyfZmx>Nn(X zjM5xVoNX-6d*%{>ZyiDLr${ki52%g*JcqYc!H8ehZYP&M{qfV;7j;me0AT zp>pR!?n=qcua9J_7dvwSq{b6wQ$0CH@>4J)Iz!T~r;Z&n3%JT9Z@#}_9HV@MAl5R{ zKIHEnNe>d$I@I(2!8|#t0|_=r)-{N$t&uzi5F@D2!ixcjCCjI|U|4bF`h4{YR!9nF zO7fOVbc_HLxw84I^)`|RAGC{UVX5Ur+A^`>85sS+EgyI@UIbr3r1rZ^3>JFiuY7Th z(~B!r{?rlid4B6a+;5c@As7M*qt z)!Ah~sP8t&rmp<0yGTB&MWrnJzk|dc7L}%;R;KsyX34F9xpn!TjRlG~mSJwlpbEGG z-^MgmZcZ1|8az6tyzs1@y%}Cz?m_RsB^SyU-bFS=Jh55rENJYxh5$1iDB9i;(+0Ai z)|gVvj>{0FDIX(b`rYMZTJJ~i$abI&knKIVh8AXNI2i}#PkO#^KC+L|$JucKPqyqe zgiP)VlgjCNx*Nb%R57JHyYqlKGgRy-;D19tHcTndMmD8)-r)UskWH+!@4;&3fzbjs2%vL+RbgSlFIUy9$_AI4@|YaEW_&;4&DsoafYG`JU1* zgzK{Z`vg2T)I| z|K!hz{;+GuGynh~0K-0fkl+FT&X55Kt%jZ35S?}9Bir$|eE, + element: , + }, + { + path: "buzzpay", + element: , }, ], }, diff --git a/frontend/src/screens/apps/ShowApp.tsx b/frontend/src/screens/apps/ShowApp.tsx index 0ebc8c5e..68dc8a09 100644 --- a/frontend/src/screens/apps/ShowApp.tsx +++ b/frontend/src/screens/apps/ShowApp.tsx @@ -7,7 +7,6 @@ import { useDeleteApp } from "src/hooks/useDeleteApp"; import { App, AppPermissions, - BudgetRenewalType, UpdateAppRequest, WalletCapabilities, } from "src/types"; @@ -95,7 +94,7 @@ function AppInternal({ app, refetchApp, capabilities }: AppInternalProps) { const [permissions, setPermissions] = React.useState({ scopes: app.scopes, maxAmount: app.maxAmount, - budgetRenewal: app.budgetRenewal as BudgetRenewalType, + budgetRenewal: app.budgetRenewal, expiresAt: app.expiresAt ? new Date(app.expiresAt) : undefined, isolated: app.isolated, }); diff --git a/frontend/src/screens/internal-apps/BuzzPay.tsx b/frontend/src/screens/internal-apps/BuzzPay.tsx new file mode 100644 index 00000000..3d511790 --- /dev/null +++ b/frontend/src/screens/internal-apps/BuzzPay.tsx @@ -0,0 +1,145 @@ +import React from "react"; +import AppHeader from "src/components/AppHeader"; +import AppCard from "src/components/connections/AppCard"; +import Loading from "src/components/Loading"; +import { ExternalLinkButton } from "src/components/ui/button"; +import { LoadingButton } from "src/components/ui/loading-button"; +import { useToast } from "src/components/ui/use-toast"; +import { useApps } from "src/hooks/useApps"; +import { + App, + AppPermissions, + CreateAppRequest, + CreateAppResponse, + UpdateAppRequest, +} from "src/types"; +import { handleRequestError } from "src/utils/handleRequestError"; +import { request } from "src/utils/request"; + +export function BuzzPay() { + const { data: apps, mutate: reloadApps } = useApps(); + const [creatingApp, setCreatingApp] = React.useState(false); + const { toast } = useToast(); + + if (!apps) { + return ; + } + const app = apps.find( + (app) => + app.metadata?.app_store_app_id === "buzzpay" && + app.metadata.connection_secret + ); + + function createApp() { + setCreatingApp(true); + (async () => { + try { + const name = "BuzzPay"; + if (apps?.some((existingApp) => existingApp.name === name)) { + throw new Error("A connection with the same name already exists."); + } + + const createAppRequest: CreateAppRequest = { + name, + scopes: ["get_info", "lookup_invoice", "make_invoice"], + isolated: false, + metadata: { + app_store_app_id: "buzzpay", + }, + }; + + const createAppResponse = await request( + "/api/apps", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(createAppRequest), + } + ); + + if (!createAppResponse) { + throw new Error("no create app response received"); + } + + const app = await request( + `/api/apps/${createAppResponse.pairingPublicKey}` + ); + + if (!app) { + throw new Error("failed to fetch buzzpay app"); + } + + const permissions: AppPermissions = { + scopes: app.scopes, + maxAmount: app.maxAmount, + budgetRenewal: app.budgetRenewal, + expiresAt: app.expiresAt ? new Date(app.expiresAt) : undefined, + isolated: app.isolated, + }; + + // TODO: should be able to partially update app rather than having to pass everything + // we are only updating the metadata + const updateAppRequest: UpdateAppRequest = { + name, + scopes: Array.from(permissions.scopes), + budgetRenewal: permissions.budgetRenewal, + expiresAt: permissions.expiresAt?.toISOString(), + maxAmount: permissions.maxAmount, + metadata: { + ...app.metadata, + // read-only connection secret + connection_secret: createAppResponse.pairingUri, + }, + }; + + await request(`/api/apps/${app.nostrPubkey}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(updateAppRequest), + }); + await reloadApps(); + + toast({ title: "BuzzPay app created" }); + } catch (error) { + handleRequestError(toast, "Failed to create app", error); + } + setCreatingApp(false); + })(); + } + + return ( +
+ + {app && ( +
+ + + Go to BuzzPay PoS + +
+ )} + {!app && ( +
+

+ By creating a new buzzpay app, a read-only wallet connection will be + created and you will receive a link you can share with your + employees, on any device. +

+ + Create BuzzPay App + +
+ )} +
+ ); +} diff --git a/frontend/src/screens/internal-apps/UncleJimApp.tsx b/frontend/src/screens/internal-apps/UncleJim.tsx similarity index 99% rename from frontend/src/screens/internal-apps/UncleJimApp.tsx rename to frontend/src/screens/internal-apps/UncleJim.tsx index 93d26d9e..31e6cee1 100644 --- a/frontend/src/screens/internal-apps/UncleJimApp.tsx +++ b/frontend/src/screens/internal-apps/UncleJim.tsx @@ -24,7 +24,7 @@ import { CreateAppRequest, CreateAppResponse } from "src/types"; import { handleRequestError } from "src/utils/handleRequestError"; import { request } from "src/utils/request"; -export function UncleJimApp() { +export function UncleJim() { const [name, setName] = React.useState(""); const [appPublicKey, setAppPublicKey] = React.useState(""); const [connectionSecret, setConnectionSecret] = React.useState(""); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index b060cae9..d4d50dfb 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -152,9 +152,10 @@ export interface InfoResponse { export type Network = "bitcoin" | "testnet" | "signet"; -export type AppMetadata = - | Record - | { app_store_app_id?: string }; +export type AppMetadata = { app_store_app_id?: string } & Record< + string, + unknown +>; export interface MnemonicResponse { mnemonic: string; @@ -187,6 +188,7 @@ export type UpdateAppRequest = { budgetRenewal: string; expiresAt: string | undefined; scopes: Scope[]; + metadata?: AppMetadata; }; export type Channel = {