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 00000000..81988f89 Binary files /dev/null and b/frontend/src/assets/suggested-apps/buzzpay.png differ diff --git a/frontend/src/components/SuggestedAppData.tsx b/frontend/src/components/SuggestedAppData.tsx index 083f8905..49b67f93 100644 --- a/frontend/src/components/SuggestedAppData.tsx +++ b/frontend/src/components/SuggestedAppData.tsx @@ -1,5 +1,6 @@ import alby from "src/assets/suggested-apps/alby.png"; import amethyst from "src/assets/suggested-apps/amethyst.png"; +import buzzpay from "src/assets/suggested-apps/buzzpay.png"; import damus from "src/assets/suggested-apps/damus.png"; import hablanews from "src/assets/suggested-apps/habla-news.png"; import kiwi from "src/assets/suggested-apps/kiwi.png"; @@ -38,6 +39,13 @@ export const suggestedApps: SuggestedApp[] = [ internal: true, logo: uncleJim, }, + { + id: "buzzpay", + title: "BuzzPay PoS", + description: "Receive-only PoS you can safely share with your employees", + internal: true, + logo: buzzpay, + }, { id: "alby-extension", title: "Alby Extension", diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index 843a0439..bd842876 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -33,7 +33,8 @@ import { OpeningAutoChannel } from "src/screens/channels/auto/OpeningAutoChannel import { FirstChannel } from "src/screens/channels/first/FirstChannel"; import { OpenedFirstChannel } from "src/screens/channels/first/OpenedFirstChannel"; import { OpeningFirstChannel } from "src/screens/channels/first/OpeningFirstChannel"; -import { UncleJimApp } from "src/screens/internal-apps/UncleJimApp"; +import { BuzzPay } from "src/screens/internal-apps/BuzzPay"; +import { UncleJim } from "src/screens/internal-apps/UncleJim"; import { Success } from "src/screens/onboarding/Success"; import BuyBitcoin from "src/screens/onchain/BuyBitcoin"; import DepositBitcoin from "src/screens/onchain/DepositBitcoin"; @@ -210,7 +211,11 @@ const routes = [ children: [ { path: "uncle-jim", - element: , + 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 = {