diff --git a/api/api.go b/api/api.go index 23ea5058..480d04cb 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" @@ -146,6 +147,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/Permissions.tsx b/frontend/src/components/Permissions.tsx index 1b525892..b29f6ca1 100644 --- a/frontend/src/components/Permissions.tsx +++ b/frontend/src/components/Permissions.tsx @@ -85,6 +85,14 @@ const Permissions: React.FC = ({ return (
+ {permissions.isolated && ( +

+ This app is isolated from the rest of your wallet. This means it will + have an isolated balance and only has access to its own transaction + history. It will not be able to sign messages on your node's behalf. +

+ )} + {!readOnly && !scopesReadOnly ? ( = ({ onScopesChanged={onScopesChanged} isNewConnection={isNewConnection} /> - ) : permissions.isolated ? ( -

- This app will be isolated from the rest of your wallet. This means it - will have an isolated balance and only has access to its own - transaction history. It will not be able to read your node info, - transactions, or sign messages. -

) : ( <>

Scopes

diff --git a/frontend/src/components/SuggestedAppData.tsx b/frontend/src/components/SuggestedAppData.tsx index 6b133f84..2ac3afe7 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/requests/createApp.ts b/frontend/src/requests/createApp.ts new file mode 100644 index 00000000..31e1325e --- /dev/null +++ b/frontend/src/requests/createApp.ts @@ -0,0 +1,19 @@ +import { CreateAppRequest, CreateAppResponse } from "src/types"; +import { request } from "src/utils/request"; + +export async function createApp( + createAppRequest: CreateAppRequest +): Promise { + 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"); + } + return createAppResponse; +} 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/NewApp.tsx b/frontend/src/screens/apps/NewApp.tsx index b86923e8..9e5bd857 100644 --- a/frontend/src/screens/apps/NewApp.tsx +++ b/frontend/src/screens/apps/NewApp.tsx @@ -5,7 +5,6 @@ import { AppPermissions, BudgetRenewalType, CreateAppRequest, - CreateAppResponse, Nip47NotificationType, Nip47RequestMethod, Scope, @@ -23,8 +22,8 @@ import { Separator } from "src/components/ui/separator"; import { useToast } from "src/components/ui/use-toast"; import { useApps } from "src/hooks/useApps"; import { useCapabilities } from "src/hooks/useCapabilities"; +import { createApp } from "src/requests/createApp"; import { handleRequestError } from "src/utils/handleRequestError"; -import { request } from "src/utils/request"; // build the project for this to appear import Permissions from "../../components/Permissions"; import { suggestedApps } from "../../components/SuggestedAppData"; @@ -204,17 +203,7 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => { }, }; - 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 createAppResponse = await createApp(createAppRequest); if (createAppResponse.returnTo) { // open connection URI directly in an app diff --git a/frontend/src/screens/apps/ShowApp.tsx b/frontend/src/screens/apps/ShowApp.tsx index 0ebc8c5e..bbceeb91 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, }); @@ -259,59 +258,57 @@ function AppInternal({ app, refetchApp, capabilities }: AppInternalProps) { - {!app.isolated && ( - - - -
- Permissions -
- {isEditingPermissions && ( -
- + + + +
+ Permissions +
+ {isEditingPermissions && ( +
+ - -
- )} + +
+ )} - {!isEditingPermissions && ( - <> - - - )} -
+ {!app.isolated && !isEditingPermissions && ( + <> + + + )}
- - - - - - - )} +
+ + + + + +
diff --git a/frontend/src/screens/internal-apps/BuzzPay.tsx b/frontend/src/screens/internal-apps/BuzzPay.tsx new file mode 100644 index 00000000..c42169b6 --- /dev/null +++ b/frontend/src/screens/internal-apps/BuzzPay.tsx @@ -0,0 +1,88 @@ +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 { createApp } from "src/requests/createApp"; +import { handleRequestError } from "src/utils/handleRequestError"; + +export function BuzzPay() { + const { data: apps, mutate: reloadApps } = useApps(); + const [creatingApp, setCreatingApp] = React.useState(false); + const [connectionSecret, setConnectionSecret] = React.useState(""); + const { toast } = useToast(); + + if (!apps) { + return ; + } + const app = apps.find((app) => app.metadata?.app_store_app_id === "buzzpay"); + + function handleCreateApp() { + 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 createAppResponse = await createApp({ + name, + scopes: ["get_info", "lookup_invoice", "make_invoice"], + isolated: true, + metadata: { + app_store_app_id: "buzzpay", + }, + }); + + setConnectionSecret(createAppResponse.pairingUri); + + await reloadApps(); + + toast({ title: "BuzzPay app created" }); + } catch (error) { + handleRequestError(toast, "Failed to create app", error); + } + setCreatingApp(false); + })(); + } + + return ( +
+ + {app && ( +
+

+ Simply click the button below to access your PoS which you can + instantly receive payments, manage your items, and share your PoS + with your employees. +

+ + 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 to a PoS you can share with your + employees, on any device. +

+ + Create BuzzPay App + +
+ )} +
+ ); +} diff --git a/frontend/src/screens/internal-apps/UncleJim.tsx b/frontend/src/screens/internal-apps/UncleJim.tsx new file mode 100644 index 00000000..9b7e7052 --- /dev/null +++ b/frontend/src/screens/internal-apps/UncleJim.tsx @@ -0,0 +1,241 @@ +import { CopyIcon } from "lucide-react"; +import React from "react"; +import AppHeader from "src/components/AppHeader"; +import AppCard from "src/components/connections/AppCard"; +import ExternalLink from "src/components/ExternalLink"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "src/components/ui/accordion"; +import { Button } from "src/components/ui/button"; +import { Input } from "src/components/ui/input"; +import { Label } from "src/components/ui/label"; +import { LoadingButton } from "src/components/ui/loading-button"; +import { Textarea } from "src/components/ui/textarea"; +import { useToast } from "src/components/ui/use-toast"; +import { useApp } from "src/hooks/useApp"; +import { useApps } from "src/hooks/useApps"; +import { useNodeConnectionInfo } from "src/hooks/useNodeConnectionInfo"; +import { copyToClipboard } from "src/lib/clipboard"; +import { createApp } from "src/requests/createApp"; +import { ConnectAppCard } from "src/screens/apps/AppCreated"; +import { CreateAppRequest } from "src/types"; +import { handleRequestError } from "src/utils/handleRequestError"; + +export function UncleJim() { + const [name, setName] = React.useState(""); + const [appPublicKey, setAppPublicKey] = React.useState(""); + const [connectionSecret, setConnectionSecret] = React.useState(""); + const { data: apps } = useApps(); + const { data: app } = useApp(appPublicKey, true); + const { data: nodeConnectionInfo } = useNodeConnectionInfo(); + const { toast } = useToast(); + const [isLoading, setLoading] = React.useState(false); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setLoading(true); + + try { + if (apps?.some((existingApp) => existingApp.name === name)) { + throw new Error("A connection with the same name already exists."); + } + + const createAppRequest: CreateAppRequest = { + name, + scopes: [ + "get_balance", + "get_info", + "list_transactions", + "lookup_invoice", + "make_invoice", + "notifications", + "pay_invoice", + ], + isolated: true, + metadata: { + app_store_app_id: "uncle-jim", + }, + }; + + const createAppResponse = await createApp(createAppRequest); + + setConnectionSecret(createAppResponse.pairingUri); + setAppPublicKey(createAppResponse.pairingPublicKey); + + toast({ title: "New subaccount created for " + name }); + } catch (error) { + handleRequestError(toast, "Failed to create app", error); + } + setLoading(false); + }; + + const albyAccountUrl = `https://getalby.com/nwc/new#${connectionSecret}`; + const valueTag = ` + +`; + + const onboardedApps = apps?.filter( + (app) => app.metadata?.app_store_app_id === "uncle-jim" + ); + + return ( +
+ + {!connectionSecret && ( + <> +
+
+ + setName(e.target.value)} + required + autoComplete="off" + placeholder="John Galt" + /> +
+ + Create Subaccount + +
+ + {!!onboardedApps?.length && ( + <> +

+ Great job! You've onboarded {onboardedApps.length} friends and + family members so far. +

+
+ {onboardedApps.map((app, index) => ( + + ))} +
{" "} + + )} + + )} + {connectionSecret && ( +
+

+ Step 2. Onboard {name} to their new wallet +

+ + + Alby Mobile + +

+ 1. Ask {name} to download the Alby Mobile app from Google Play + or the iOS App Store +

+

+ 2. Ask {name} to scan the below QR code. +

+ {app && ( + + )} +
+
+ + Alby Account + +

+ 1. Send {name} an{" "} + + Alby Account invitation + {" "} + if they don't have one yet. +

+

+ 2. Send {name} the below link which will link the new wallet + to their Alby Account. Do not to share this publicly as it + contains the connection secret for their wallet. +

+
+ + +
+
+
+ + Alby Extension + +

+ 1. Send {name} the below connection secret which they can add + to their Alby Extension by choosing "Bring Your Own Wallet"{" "} + {"->"} "Nostr Wallet Connect" and pasting the connection + secret. Do not to share this publicly as it contains the + connection secret for their wallet. +

+
+ + +
+
+
+ + Podcasting 2.0 + +

+ 1. Make sure to give {name} access to their wallet with one of + the options above. +

+

+ 2. Send them this value tag which they can add to their RSS + feed. +

+
+