From baba8db4dcfd775050f3db84cc80f98483c8ee35 Mon Sep 17 00:00:00 2001 From: im-adithya Date: Wed, 3 Jul 2024 17:26:34 +0530 Subject: [PATCH 01/33] feat: revamp permissions component --- frontend/package.json | 3 + frontend/src/components/Permissions.tsx | 466 ++++++++++++------------ frontend/src/components/Scopes.tsx | 185 ++++++++++ frontend/src/components/ui/calendar.tsx | 64 ++++ frontend/src/components/ui/popover.tsx | 29 ++ frontend/src/screens/apps/NewApp.tsx | 50 ++- frontend/src/screens/apps/ShowApp.tsx | 2 +- frontend/src/types.ts | 37 +- frontend/yarn.lock | 187 +++++++++- 9 files changed, 754 insertions(+), 269 deletions(-) create mode 100644 frontend/src/components/Scopes.tsx create mode 100644 frontend/src/components/ui/calendar.tsx create mode 100644 frontend/src/components/ui/popover.tsx diff --git a/frontend/package.json b/frontend/package.json index 27f39f83..bb407857 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,6 +29,7 @@ "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-navigation-menu": "^1.1.4", + "@radix-ui/react-popover": "^1.1.1", "@radix-ui/react-progress": "^1.0.3", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-separator": "^1.0.3", @@ -41,12 +42,14 @@ "canvas-confetti": "^1.9.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", + "date-fns": "^3.6.0", "dayjs": "^1.11.10", "embla-carousel-react": "^8.0.2", "gradient-avatar": "^1.0.2", "lucide-react": "^0.363.0", "posthog-js": "^1.116.6", "react": "^18.2.0", + "react-day-picker": "^8.10.1", "react-dom": "^18.2.0", "react-lottie": "^1.2.4", "react-qr-code": "^2.0.12", diff --git a/frontend/src/components/Permissions.tsx b/frontend/src/components/Permissions.tsx index d061ed62..785409ec 100644 --- a/frontend/src/components/Permissions.tsx +++ b/frontend/src/components/Permissions.tsx @@ -1,8 +1,16 @@ -import { PlusCircle } from "lucide-react"; +import { format } from "date-fns"; +import { CalendarIcon, PlusCircle, XIcon } from "lucide-react"; import React, { useEffect, useState } from "react"; +import Scopes from "src/components/Scopes"; import { Button } from "src/components/ui/button"; -import { Checkbox } from "src/components/ui/checkbox"; +import { Calendar } from "src/components/ui/calendar"; +import { Input } from "src/components/ui/input"; import { Label } from "src/components/ui/label"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "src/components/ui/popover"; import { Select, SelectContent, @@ -10,38 +18,49 @@ import { SelectTrigger, SelectValue, } from "src/components/ui/select"; -import { useCapabilities } from "src/hooks/useCapabilities"; import { cn } from "src/lib/utils"; import { AppPermissions, BudgetRenewalType, + NIP_47_PAY_INVOICE_METHOD, Scope, + WalletCapabilities, budgetOptions, expiryOptions, - iconMap, - scopeDescriptions, validBudgetRenewals, } from "src/types"; +const daysFromNow = (date?: Date) => + date + ? Math.ceil((new Date(date).getTime() - Date.now()) / (1000 * 60 * 60 * 24)) + : 0; + interface PermissionsProps { + capabilities: WalletCapabilities; initialPermissions: AppPermissions; onPermissionsChange: (permissions: AppPermissions) => void; - budgetUsage?: number; canEditPermissions: boolean; - isNewConnection?: boolean; + budgetUsage?: number; } const Permissions: React.FC = ({ + capabilities, initialPermissions, onPermissionsChange, canEditPermissions, - isNewConnection, budgetUsage, }) => { + // TODO: EDITABLE LOGIC const [permissions, setPermissions] = React.useState(initialPermissions); - const [days, setDays] = useState(isNewConnection ? 0 : -1); - const [expireOptions, setExpireOptions] = useState(!isNewConnection); - const { data: capabilities } = useCapabilities(); + + // TODO: set expiry when set to non expiryType value like 24 days for example + const [expiryDays, setExpiryDays] = useState( + daysFromNow(permissions.expiresAt) + ); + const [budgetOption, setBudgetOption] = useState(!!permissions.maxAmount); + const [customBudget, setCustomBudget] = useState(!!permissions.maxAmount); + const [expireOption, setExpireOption] = useState(!!permissions.expiresAt); + const [customExpiry, setCustomExpiry] = useState(!!permissions.expiresAt); useEffect(() => { setPermissions(initialPermissions); @@ -55,30 +74,13 @@ const Permissions: React.FC = ({ onPermissionsChange(updatedPermissions); }; - const handleScopeChange = (scope: Scope) => { - if (!canEditPermissions) { - return; - } - - let budgetRenewal = permissions.budgetRenewal; - - const newScopes = new Set(permissions.scopes); - if (newScopes.has(scope)) { - newScopes.delete(scope); - } else { - newScopes.add(scope); - if (scope === "pay_invoice") { - budgetRenewal = "monthly"; - } - } - - handlePermissionsChange({ - scopes: newScopes, - budgetRenewal, - }); + const handleScopeChange = (scopes: Set) => { + // TODO: what if edit is not set (see prev diff) + // TODO: what if we set pay_invoice scope again, what would be the value of budgetRenewal + handlePermissionsChange({ scopes }); }; - const handleMaxAmountChange = (amount: number) => { + const handleBudgetMaxAmountChange = (amount: string) => { handlePermissionsChange({ maxAmount: amount }); }; @@ -86,234 +88,214 @@ const Permissions: React.FC = ({ handlePermissionsChange({ budgetRenewal: value as BudgetRenewalType }); }; - const handleDaysChange = (days: number) => { - setDays(days); - if (!days) { + const handleExpiryDaysChange = (expiryDays: number) => { + setExpiryDays(expiryDays); + if (!expiryDays) { handlePermissionsChange({ expiresAt: undefined }); return; } const currentDate = new Date(); - const expiryDate = new Date( - Date.UTC( - currentDate.getUTCFullYear(), - currentDate.getUTCMonth(), - currentDate.getUTCDate() + days, - 23, - 59, - 59, - 0 - ) - ); - handlePermissionsChange({ expiresAt: expiryDate }); + // const expiryDate = new Date( + // Date.UTC( + // currentDate.getUTCFullYear(), + // currentDate.getUTCMonth(), + // currentDate.getUTCDate() + expiryDays, + // 23, + // 59, + // 59, + // 0 + // ) + // ); + currentDate.setDate(currentDate.getDate() + expiryDays); + currentDate.setHours(23, 59, 59, 0); + handlePermissionsChange({ expiresAt: currentDate }); }; return (
-
-
    - {capabilities?.scopes.map((scope, index) => { - const ScopeIcon = iconMap[scope]; - return ( -
  • + + {capabilities.scopes.includes(NIP_47_PAY_INVOICE_METHOD) && + permissions.scopes.has(NIP_47_PAY_INVOICE_METHOD) && ( + <> + {!budgetOption && ( + + )} + {budgetOption && ( + <> +

    Budget Renewal

    +
    +
    - {scope == "pay_invoice" && ( +
    + {Object.keys(budgetOptions).map((budget) => { + return ( + // replace with something else and then remove dark prefixes +
    { + setCustomBudget(false); + handleBudgetMaxAmountChange( + budgetOptions[budget].toString() + ); + }} + className={cn( + "cursor-pointer rounded text-nowrap border-2 text-center p-4 dark:text-white", + !customBudget && + (permissions.maxAmount === "" + ? 100000 + : +permissions.maxAmount) == budgetOptions[budget] + ? "border-primary" + : "border-muted" + )} + > + {`${budget} ${budgetOptions[budget] ? " sats" : ""}`} +
    + ); + })}
    { + setCustomBudget(true); + handleBudgetMaxAmountChange(""); + }} className={cn( - "pt-2 pb-2 pl-5 ml-2.5 border-l-2 border-l-primary", - !permissions.scopes.has(scope) - ? canEditPermissions - ? "pointer-events-none opacity-30" - : "hidden" - : "" + "cursor-pointer rounded border-2 text-center p-4 dark:text-white", + customBudget ? "border-primary" : "border-muted" )} > - {canEditPermissions ? ( - <> -
    -

    Budget Renewal:

    - {!canEditPermissions ? ( - permissions.budgetRenewal - ) : ( - - )} -
    -
    - {Object.keys(budgetOptions).map((budget) => { - return ( - // replace with something else and then remove dark prefixes -
    - handleMaxAmountChange(budgetOptions[budget]) - } - className={`col-span-2 md:col-span-1 cursor-pointer rounded border-2 ${ - permissions.maxAmount == budgetOptions[budget] - ? "border-primary" - : "border-muted" - } text-center py-4 dark:text-white`} - > - {budget} -
    - {budgetOptions[budget] ? "sats" : "#reckless"} -
    - ); - })} -
    - - ) : isNewConnection ? ( - <> -

    - - {permissions.budgetRenewal} - {" "} - budget: {permissions.maxAmount} sats -

    - - ) : ( - - - - - - - - - - - -
    Budget Allowance: - {permissions.maxAmount - ? new Intl.NumberFormat().format( - permissions.maxAmount - ) - : "∞"}{" "} - sats ( - {new Intl.NumberFormat().format(budgetUsage || 0)}{" "} - sats used) -
    Renews: - {permissions.budgetRenewal || "Never"} -
    - )} + Custom... +
    +
    + {customBudget && ( +
    + + { + handleBudgetMaxAmountChange(e.target.value.trim()); + }} + />
    )} -
  • - ); - })} -
-
+ + )} + + )} - {( - isNewConnection ? !permissions.expiresAt || days : canEditPermissions - ) ? ( - <> - {!expireOptions && ( - - )} + {!expireOption && ( + + )} - {expireOptions && ( -
-

Connection expiration

- {!isNewConnection && ( -

- Expires:{" "} - {permissions.expiresAt && - new Date(permissions.expiresAt).getFullYear() !== 1 - ? new Date(permissions.expiresAt).toString() - : "This app will never expire"} -

- )} -
- {Object.keys(expiryOptions).map((expiry) => { - return ( -
handleDaysChange(expiryOptions[expiry])} - className={cn( - "cursor-pointer rounded border-2 text-center py-4", - days == expiryOptions[expiry] - ? "border-primary" - : "border-muted" - )} - > - {expiry} -
- ); - })} -
-
- )} - - ) : ( - <> -

Connection expiry

-

- {permissions.expiresAt && - new Date(permissions.expiresAt).getFullYear() !== 1 - ? new Date(permissions.expiresAt).toString() - : "This app will never expire"} -

- + {expireOption && ( +
+

Connection expiration

+
+ {Object.keys(expiryOptions).map((expiry) => { + return ( +
{ + setCustomExpiry(false); + handleExpiryDaysChange(expiryOptions[expiry]); + }} + className={cn( + "cursor-pointer rounded text-nowrap border-2 text-center p-4 dark:text-white", + !customExpiry && expiryDays == expiryOptions[expiry] + ? "border-primary" + : "border-muted" + )} + > + {expiry} +
+ ); + })} + + +
{}} + className={cn( + "flex items-center justify-center md:col-span-2 cursor-pointer rounded text-nowrap border-2 text-center px-3 py-4 dark:text-white", + customExpiry ? "border-primary" : "border-muted" + )} + > + + + {customExpiry && permissions.expiresAt + ? format(permissions.expiresAt, "PPP") + : "Custom..."} + +
+
+ + { + if (daysFromNow(date) == 0) { + return; + } + setCustomExpiry(true); + handleExpiryDaysChange(daysFromNow(date)); + }} + initialFocus + /> + +
+
+
)}
); diff --git a/frontend/src/components/Scopes.tsx b/frontend/src/components/Scopes.tsx new file mode 100644 index 00000000..510861af --- /dev/null +++ b/frontend/src/components/Scopes.tsx @@ -0,0 +1,185 @@ +import React from "react"; +import { Checkbox } from "src/components/ui/checkbox"; +import { Label } from "src/components/ui/label"; +import { cn } from "src/lib/utils"; +import { + NIP_47_MAKE_INVOICE_METHOD, + NIP_47_NOTIFICATIONS_PERMISSION, + NIP_47_PAY_INVOICE_METHOD, + SCOPE_GROUP_CUSTOM, + SCOPE_GROUP_ONLY_RECEIVE, + SCOPE_GROUP_SEND_RECEIVE, + Scope, + ScopeGroupType, + WalletCapabilities, + scopeDescriptions, + scopeGroupDescriptions, + scopeGroupIconMap, + scopeGroupTitle, +} from "src/types"; + +// TODO: this runs everytime, use useEffect +const scopeGrouper = (scopes: Set) => { + if ( + scopes.size === 2 && + scopes.has(NIP_47_MAKE_INVOICE_METHOD) && + scopes.has(NIP_47_PAY_INVOICE_METHOD) + ) { + return "send_receive"; + } else if (scopes.size === 1 && scopes.has(NIP_47_MAKE_INVOICE_METHOD)) { + return "only_receive"; + } + return "custom"; +}; + +const validScopeGroups = (capabilities: WalletCapabilities) => { + const scopeGroups = [SCOPE_GROUP_CUSTOM]; + if (capabilities.scopes.includes(NIP_47_MAKE_INVOICE_METHOD)) { + scopeGroups.unshift(SCOPE_GROUP_ONLY_RECEIVE); + if (capabilities.scopes.includes(NIP_47_PAY_INVOICE_METHOD)) { + scopeGroups.unshift(SCOPE_GROUP_SEND_RECEIVE); + } + } + return scopeGroups; +}; + +interface ScopesProps { + capabilities: WalletCapabilities; + scopes: Set; + onScopeChange: (scopes: Set) => void; +} + +const Scopes: React.FC = ({ + capabilities, + scopes, + onScopeChange, +}) => { + const [scopeGroup, setScopeGroup] = React.useState(scopeGrouper(scopes)); + const scopeGroups = validScopeGroups(capabilities); + + // TODO: EDITABLE PROP + const handleScopeGroupChange = (scopeGroup: ScopeGroupType) => { + setScopeGroup(scopeGroup); + switch (scopeGroup) { + case "send_receive": + onScopeChange( + new Set([ + NIP_47_PAY_INVOICE_METHOD, + NIP_47_MAKE_INVOICE_METHOD, + NIP_47_NOTIFICATIONS_PERMISSION, + ]) + ); + break; + case "only_receive": + onScopeChange(new Set([NIP_47_MAKE_INVOICE_METHOD])); + break; + default: { + const newSet = new Set(capabilities.scopes); + if (capabilities.notificationTypes.length) { + newSet.add(NIP_47_NOTIFICATIONS_PERMISSION); + } + onScopeChange(newSet); + break; + } + } + }; + + const handleScopeChange = (scope: Scope) => { + const newScopes = new Set(scopes); + if (newScopes.has(scope)) { + newScopes.delete(scope); + } else { + newScopes.add(scope); + } + onScopeChange(newScopes); + }; + + return ( +
+ {scopeGroups.length > 1 && ( +
+

Choose wallet permissions

+
+ {(scopeGroups as ScopeGroupType[]).map((sg, index) => { + if ( + scopeGroup == SCOPE_GROUP_SEND_RECEIVE && + !capabilities.scopes.includes(NIP_47_PAY_INVOICE_METHOD) + ) { + return; + } + const ScopeGroupIcon = scopeGroupIconMap[sg]; + return ( +
{ + handleScopeGroupChange(sg); + }} + > + +

{scopeGroupTitle[sg]}

+

+ {scopeGroupDescriptions[sg]} +

+
+ ); + })} +
+
+ )} + + {(scopeGroup == "custom" || scopeGroups.length == 1) && ( + <> +

Authorize the app to:

+
    + {capabilities.scopes.map((rm, index) => { + return ( +
  • +
    + handleScopeChange(rm)} + checked={scopes.has(rm)} + /> + +
    +
  • + ); + })} + {capabilities.notificationTypes.length > 0 && ( +
  • +
    + + handleScopeChange(NIP_47_NOTIFICATIONS_PERMISSION) + } + checked={scopes.has(NIP_47_NOTIFICATIONS_PERMISSION)} + /> + +
    +
  • + )} +
+ + )} +
+ ); +}; + +export default Scopes; diff --git a/frontend/src/components/ui/calendar.tsx b/frontend/src/components/ui/calendar.tsx new file mode 100644 index 00000000..449bef25 --- /dev/null +++ b/frontend/src/components/ui/calendar.tsx @@ -0,0 +1,64 @@ +import { ChevronLeft, ChevronRight } from "lucide-react"; +import * as React from "react"; +import { DayPicker } from "react-day-picker"; + +import { buttonVariants } from "src/components/ui/button"; +import { cn } from "src/lib/utils"; + +export type CalendarProps = React.ComponentProps; + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) { + return ( + , + IconRight: () => , + }} + {...props} + /> + ); +} +Calendar.displayName = "Calendar"; + +export { Calendar }; diff --git a/frontend/src/components/ui/popover.tsx b/frontend/src/components/ui/popover.tsx new file mode 100644 index 00000000..8f9a183c --- /dev/null +++ b/frontend/src/components/ui/popover.tsx @@ -0,0 +1,29 @@ +import * as PopoverPrimitive from "@radix-ui/react-popover"; +import * as React from "react"; + +import { cn } from "src/lib/utils"; + +const Popover = PopoverPrimitive.Root; + +const PopoverTrigger = PopoverPrimitive.Trigger; + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +export { Popover, PopoverContent, PopoverTrigger }; diff --git a/frontend/src/screens/apps/NewApp.tsx b/frontend/src/screens/apps/NewApp.tsx index e56b5efe..b0048d19 100644 --- a/frontend/src/screens/apps/NewApp.tsx +++ b/frontend/src/screens/apps/NewApp.tsx @@ -7,6 +7,8 @@ import { BudgetRenewalType, CreateAppRequest, CreateAppResponse, + NIP_47_MAKE_INVOICE_METHOD, + NIP_47_PAY_INVOICE_METHOD, Nip47NotificationType, Nip47RequestMethod, Scope, @@ -53,31 +55,44 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => { const appId = queryParams.get("app") ?? ""; const app = suggestedApps.find((app) => app.id === appId); - const nameParam = app - ? app.title - : (queryParams.get("name") || queryParams.get("c")) ?? ""; const pubkey = queryParams.get("pubkey") ?? ""; const returnTo = queryParams.get("return_to") ?? ""; - const [appName, setAppName] = useState(nameParam); + const nameParam = (queryParams.get("name") || queryParams.get("c")) ?? ""; + const [appName, setAppName] = useState(app ? app.title : nameParam); const budgetRenewalParam = queryParams.get( "budget_renewal" ) as BudgetRenewalType; + const budgetMaxAmountParam = queryParams.get("max_amount") ?? ""; + const expiresAtParam = queryParams.get("expires_at") ?? ""; const reqMethodsParam = queryParams.get("request_methods") ?? ""; const notificationTypesParam = queryParams.get("notification_types") ?? ""; - const maxAmountParam = queryParams.get("max_amount") ?? ""; - const expiresAtParam = queryParams.get("expires_at") ?? ""; const initialScopes: Set = React.useMemo(() => { const methods = reqMethodsParam ? reqMethodsParam.split(" ") - : capabilities.methods; - + : // this is done for scope grouping + capabilities.methods.includes(NIP_47_MAKE_INVOICE_METHOD) + ? capabilities.methods.includes(NIP_47_PAY_INVOICE_METHOD) + ? [NIP_47_MAKE_INVOICE_METHOD, NIP_47_PAY_INVOICE_METHOD] + : [NIP_47_MAKE_INVOICE_METHOD] + : capabilities.methods; const requestMethodsSet = new Set( methods as Nip47RequestMethod[] ); + + const notificationTypes = notificationTypesParam + ? notificationTypesParam.split(" ") + : // this is done for scope grouping + !capabilities.methods.includes(NIP_47_MAKE_INVOICE_METHOD) + ? capabilities.notificationTypes + : []; + const notificationTypesSet = new Set( + notificationTypes as Nip47NotificationType[] + ); + const unsupportedMethods = Array.from(requestMethodsSet).filter( (method) => capabilities.methods.indexOf(method) < 0 ); @@ -88,13 +103,6 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => { ); } - const notificationTypes = notificationTypesParam - ? notificationTypesParam.split(" ") - : capabilities.notificationTypes; - - const notificationTypesSet = new Set( - notificationTypes as Nip47NotificationType[] - ); const unsupportedNotificationTypes = Array.from( notificationTypesSet ).filter( @@ -158,7 +166,7 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => { const [permissions, setPermissions] = useState({ scopes: initialScopes, - maxAmount: parseInt(maxAmountParam || "100000"), + maxAmount: budgetMaxAmountParam, budgetRenewal: validBudgetRenewals.includes(budgetRenewalParam) ? budgetRenewalParam : "monthly", @@ -171,12 +179,17 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => { throw new Error("No CSRF token"); } + if (!permissions.scopes.size) { + toast({ title: "Please specify wallet permissions." }); + return; + } + try { const createAppRequest: CreateAppRequest = { name: appName, pubkey, budgetRenewal: permissions.budgetRenewal, - maxAmount: permissions.maxAmount, + maxAmount: parseInt(permissions.maxAmount), scopes: Array.from(permissions.scopes), expiresAt: permissions.expiresAt?.toISOString(), returnTo: returnTo, @@ -254,12 +267,11 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => { )}
-

Authorize the app to:

diff --git a/frontend/src/screens/apps/ShowApp.tsx b/frontend/src/screens/apps/ShowApp.tsx index 8fce11fd..b73d02bb 100644 --- a/frontend/src/screens/apps/ShowApp.tsx +++ b/frontend/src/screens/apps/ShowApp.tsx @@ -249,8 +249,8 @@ function ShowApp() { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index db6a5b80..cbc7220a 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,12 +1,15 @@ import { + ArrowDownUp, Bell, CirclePlus, HandCoins, Info, LucideIcon, + MoveDown, NotebookTabs, PenLine, Search, + SquarePen, WalletMinimal, } from "lucide-react"; @@ -20,6 +23,10 @@ export const NIP_47_SIGN_MESSAGE_METHOD = "sign_message"; export const NIP_47_NOTIFICATIONS_PERMISSION = "notifications"; +export const SCOPE_GROUP_SEND_RECEIVE = "send_receive"; +export const SCOPE_GROUP_ONLY_RECEIVE = "only_receive"; +export const SCOPE_GROUP_CUSTOM = "custom"; + export type BackendType = | "LND" | "BREEZ" @@ -58,10 +65,12 @@ export type Scope = | "sign_message" | "notifications"; // covers all notification types +export type ScopeGroupType = "send_receive" | "only_receive" | "custom"; + export type Nip47NotificationType = "payment_received" | "payment_sent"; export type IconMap = { - [key in Scope]: LucideIcon; + [key in string]: LucideIcon; }; export const iconMap: IconMap = { @@ -75,6 +84,12 @@ export const iconMap: IconMap = { [NIP_47_NOTIFICATIONS_PERMISSION]: Bell, }; +export const scopeGroupIconMap: IconMap = { + [SCOPE_GROUP_SEND_RECEIVE]: ArrowDownUp, + [SCOPE_GROUP_ONLY_RECEIVE]: MoveDown, + [SCOPE_GROUP_CUSTOM]: SquarePen, +}; + export type WalletCapabilities = { methods: Nip47RequestMethod[]; scopes: Scope[]; @@ -100,6 +115,18 @@ export const scopeDescriptions: Record = { [NIP_47_NOTIFICATIONS_PERMISSION]: "Receive wallet notifications", }; +export const scopeGroupTitle: Record = { + [SCOPE_GROUP_SEND_RECEIVE]: "Send & Receive", + [SCOPE_GROUP_ONLY_RECEIVE]: "Just Receive", + [SCOPE_GROUP_CUSTOM]: "Custom", +}; + +export const scopeGroupDescriptions: Record = { + [SCOPE_GROUP_SEND_RECEIVE]: "Pay and create invoices", + [SCOPE_GROUP_ONLY_RECEIVE]: "Only create invoices", + [SCOPE_GROUP_CUSTOM]: "Define permissions", +}; + export const expiryOptions: Record = { "1 week": 7, "1 month": 30, @@ -109,8 +136,6 @@ export const expiryOptions: Record = { export const budgetOptions: Record = { "10k": 10_000, - "25k": 25_000, - "50k": 50_000, "100k": 100_000, "1M": 1_000_000, Unlimited: 0, @@ -132,14 +157,14 @@ export interface App { expiresAt?: string; scopes: Scope[]; - maxAmount: number; + maxAmount: string; budgetUsage: number; budgetRenewal: string; } export interface AppPermissions { scopes: Set; - maxAmount: number; + maxAmount: string; budgetRenewal: BudgetRenewalType; expiresAt?: Date; } @@ -184,7 +209,7 @@ export interface CreateAppResponse { } export type UpdateAppRequest = { - maxAmount: number; + maxAmount: string; budgetRenewal: string; expiresAt: string | undefined; scopes: Scope[]; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 06ca7b01..30cbe0cf 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -529,6 +529,11 @@ dependencies: "@babel/runtime" "^7.13.10" +"@radix-ui/primitive@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.0.tgz#42ef83b3b56dccad5d703ae8c42919a68798bbe2" + integrity sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA== + "@radix-ui/react-alert-dialog@^1.0.5": version "1.0.5" resolved "https://registry.yarnpkg.com/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.0.5.tgz#70dd529cbf1e4bff386814d3776901fcaa131b8c" @@ -550,6 +555,13 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-primitive" "1.0.3" +"@radix-ui/react-arrow@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz#744f388182d360b86285217e43b6c63633f39e7a" + integrity sha512-FmlW1rCg7hBpEBwFbjHwCW6AmWLQM6g/v0Sn8XbP9NvmSZ2San1FpQeyPtufzOMSIx7Y4dzjlHoifhp+7NkZhw== + dependencies: + "@radix-ui/react-primitive" "2.0.0" + "@radix-ui/react-avatar@^1.0.4": version "1.0.4" resolved "https://registry.yarnpkg.com/@radix-ui/react-avatar/-/react-avatar-1.0.4.tgz#de9a5349d9e3de7bbe990334c4d2011acbbb9623" @@ -594,6 +606,11 @@ dependencies: "@babel/runtime" "^7.13.10" +"@radix-ui/react-compose-refs@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz#656432461fc8283d7b591dcf0d79152fae9ecc74" + integrity sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw== + "@radix-ui/react-context@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.0.1.tgz#fe46e67c96b240de59187dcb7a1a50ce3e2ec00c" @@ -601,6 +618,11 @@ dependencies: "@babel/runtime" "^7.13.10" +"@radix-ui/react-context@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.1.0.tgz#6df8d983546cfd1999c8512f3a8ad85a6e7fcee8" + integrity sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A== + "@radix-ui/react-dialog@1.0.5", "@radix-ui/react-dialog@^1.0.5": version "1.0.5" resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz#71657b1b116de6c7a0b03242d7d43e01062c7300" @@ -641,6 +663,17 @@ "@radix-ui/react-use-callback-ref" "1.0.1" "@radix-ui/react-use-escape-keydown" "1.0.3" +"@radix-ui/react-dismissable-layer@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.0.tgz#2cd0a49a732372513733754e6032d3fb7988834e" + integrity sha512-/UovfmmXGptwGcBQawLzvn2jOfM0t4z3/uKffoBlj724+n3FvBbZ7M0aaBOmkp6pqFYpO4yx8tSVJjx3Fl2jig== + dependencies: + "@radix-ui/primitive" "1.1.0" + "@radix-ui/react-compose-refs" "1.1.0" + "@radix-ui/react-primitive" "2.0.0" + "@radix-ui/react-use-callback-ref" "1.1.0" + "@radix-ui/react-use-escape-keydown" "1.1.0" + "@radix-ui/react-dropdown-menu@^2.0.6": version "2.0.6" resolved "https://registry.yarnpkg.com/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.0.6.tgz#cdf13c956c5e263afe4e5f3587b3071a25755b63" @@ -662,6 +695,11 @@ dependencies: "@babel/runtime" "^7.13.10" +"@radix-ui/react-focus-guards@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.0.tgz#8e9abb472a9a394f59a1b45f3dd26cfe3fc6da13" + integrity sha512-w6XZNUPVv6xCpZUqb/yN9DL6auvpGX3C/ee6Hdi16v2UUy25HV2Q5bcflsiDyT/g5RwbPQ/GIT1vLkeRb+ITBw== + "@radix-ui/react-focus-scope@1.0.4": version "1.0.4" resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.4.tgz#2ac45fce8c5bb33eb18419cdc1905ef4f1906525" @@ -672,6 +710,15 @@ "@radix-ui/react-primitive" "1.0.3" "@radix-ui/react-use-callback-ref" "1.0.1" +"@radix-ui/react-focus-scope@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.0.tgz#ebe2891a298e0a33ad34daab2aad8dea31caf0b2" + integrity sha512-200UD8zylvEyL8Bx+z76RJnASR2gRMuxlgFCPAe/Q/679a/r0eK3MBVYMb7vZODZcffZBdob1EGnky78xmVvcA== + dependencies: + "@radix-ui/react-compose-refs" "1.1.0" + "@radix-ui/react-primitive" "2.0.0" + "@radix-ui/react-use-callback-ref" "1.1.0" + "@radix-ui/react-icons@^1.3.0": version "1.3.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-icons/-/react-icons-1.3.0.tgz#c61af8f323d87682c5ca76b856d60c2312dbcb69" @@ -685,6 +732,13 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-use-layout-effect" "1.0.1" +"@radix-ui/react-id@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.1.0.tgz#de47339656594ad722eb87f94a6b25f9cffae0ed" + integrity sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA== + dependencies: + "@radix-ui/react-use-layout-effect" "1.1.0" + "@radix-ui/react-label@^2.0.2": version "2.0.2" resolved "https://registry.yarnpkg.com/@radix-ui/react-label/-/react-label-2.0.2.tgz#9c72f1d334aac996fdc27b48a8bdddd82108fb6d" @@ -739,6 +793,27 @@ "@radix-ui/react-use-previous" "1.0.1" "@radix-ui/react-visually-hidden" "1.0.3" +"@radix-ui/react-popover@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-popover/-/react-popover-1.1.1.tgz#604b783cdb3494ed4f16a58c17f0e81e61ab7775" + integrity sha512-3y1A3isulwnWhvTTwmIreiB8CF4L+qRjZnK1wYLO7pplddzXKby/GnZ2M7OZY3qgnl6p9AodUIHRYGXNah8Y7g== + dependencies: + "@radix-ui/primitive" "1.1.0" + "@radix-ui/react-compose-refs" "1.1.0" + "@radix-ui/react-context" "1.1.0" + "@radix-ui/react-dismissable-layer" "1.1.0" + "@radix-ui/react-focus-guards" "1.1.0" + "@radix-ui/react-focus-scope" "1.1.0" + "@radix-ui/react-id" "1.1.0" + "@radix-ui/react-popper" "1.2.0" + "@radix-ui/react-portal" "1.1.1" + "@radix-ui/react-presence" "1.1.0" + "@radix-ui/react-primitive" "2.0.0" + "@radix-ui/react-slot" "1.1.0" + "@radix-ui/react-use-controllable-state" "1.1.0" + aria-hidden "^1.1.1" + react-remove-scroll "2.5.7" + "@radix-ui/react-popper@1.1.3": version "1.1.3" resolved "https://registry.yarnpkg.com/@radix-ui/react-popper/-/react-popper-1.1.3.tgz#24c03f527e7ac348fabf18c89795d85d21b00b42" @@ -756,6 +831,22 @@ "@radix-ui/react-use-size" "1.0.1" "@radix-ui/rect" "1.0.1" +"@radix-ui/react-popper@1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-popper/-/react-popper-1.2.0.tgz#a3e500193d144fe2d8f5d5e60e393d64111f2a7a" + integrity sha512-ZnRMshKF43aBxVWPWvbj21+7TQCvhuULWJ4gNIKYpRlQt5xGRhLx66tMp8pya2UkGHTSlhpXwmjqltDYHhw7Vg== + dependencies: + "@floating-ui/react-dom" "^2.0.0" + "@radix-ui/react-arrow" "1.1.0" + "@radix-ui/react-compose-refs" "1.1.0" + "@radix-ui/react-context" "1.1.0" + "@radix-ui/react-primitive" "2.0.0" + "@radix-ui/react-use-callback-ref" "1.1.0" + "@radix-ui/react-use-layout-effect" "1.1.0" + "@radix-ui/react-use-rect" "1.1.0" + "@radix-ui/react-use-size" "1.1.0" + "@radix-ui/rect" "1.1.0" + "@radix-ui/react-portal@1.0.4": version "1.0.4" resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.0.4.tgz#df4bfd353db3b1e84e639e9c63a5f2565fb00e15" @@ -764,6 +855,14 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-primitive" "1.0.3" +"@radix-ui/react-portal@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.1.1.tgz#1957f1eb2e1aedfb4a5475bd6867d67b50b1d15f" + integrity sha512-A3UtLk85UtqhzFqtoC8Q0KvR2GbXF3mtPgACSazajqq6A41mEQgo53iPzY4i6BwDxlIFqWIhiQ2G729n+2aw/g== + dependencies: + "@radix-ui/react-primitive" "2.0.0" + "@radix-ui/react-use-layout-effect" "1.1.0" + "@radix-ui/react-presence@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.0.1.tgz#491990ba913b8e2a5db1b06b203cb24b5cdef9ba" @@ -773,6 +872,14 @@ "@radix-ui/react-compose-refs" "1.0.1" "@radix-ui/react-use-layout-effect" "1.0.1" +"@radix-ui/react-presence@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.1.0.tgz#227d84d20ca6bfe7da97104b1a8b48a833bfb478" + integrity sha512-Gq6wuRN/asf9H/E/VzdKoUtT8GC9PQc9z40/vEr0VCJ4u5XvvhWIrSsCB6vD2/cH7ugTdSfYq9fLJCcM00acrQ== + dependencies: + "@radix-ui/react-compose-refs" "1.1.0" + "@radix-ui/react-use-layout-effect" "1.1.0" + "@radix-ui/react-primitive@1.0.3": version "1.0.3" resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz#d49ea0f3f0b2fe3ab1cb5667eb03e8b843b914d0" @@ -781,6 +888,13 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-slot" "1.0.2" +"@radix-ui/react-primitive@2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz#fe05715faa9203a223ccc0be15dc44b9f9822884" + integrity sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw== + dependencies: + "@radix-ui/react-slot" "1.1.0" + "@radix-ui/react-progress@^1.0.3": version "1.0.3" resolved "https://registry.yarnpkg.com/@radix-ui/react-progress/-/react-progress-1.0.3.tgz#8380272fdc64f15cbf263a294dea70a7d5d9b4fa" @@ -850,6 +964,13 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-compose-refs" "1.0.1" +"@radix-ui/react-slot@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.1.0.tgz#7c5e48c36ef5496d97b08f1357bb26ed7c714b84" + integrity sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw== + dependencies: + "@radix-ui/react-compose-refs" "1.1.0" + "@radix-ui/react-switch@^1.0.3": version "1.0.3" resolved "https://registry.yarnpkg.com/@radix-ui/react-switch/-/react-switch-1.0.3.tgz#6119f16656a9eafb4424c600fdb36efa5ec5837e" @@ -909,6 +1030,11 @@ dependencies: "@babel/runtime" "^7.13.10" +"@radix-ui/react-use-callback-ref@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz#bce938ca413675bc937944b0d01ef6f4a6dc5bf1" + integrity sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw== + "@radix-ui/react-use-controllable-state@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz#ecd2ced34e6330caf89a82854aa2f77e07440286" @@ -917,6 +1043,13 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-use-callback-ref" "1.0.1" +"@radix-ui/react-use-controllable-state@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz#1321446857bb786917df54c0d4d084877aab04b0" + integrity sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw== + dependencies: + "@radix-ui/react-use-callback-ref" "1.1.0" + "@radix-ui/react-use-escape-keydown@1.0.3": version "1.0.3" resolved "https://registry.yarnpkg.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz#217b840c250541609c66f67ed7bab2b733620755" @@ -925,6 +1058,13 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-use-callback-ref" "1.0.1" +"@radix-ui/react-use-escape-keydown@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz#31a5b87c3b726504b74e05dac1edce7437b98754" + integrity sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw== + dependencies: + "@radix-ui/react-use-callback-ref" "1.1.0" + "@radix-ui/react-use-layout-effect@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz#be8c7bc809b0c8934acf6657b577daf948a75399" @@ -932,6 +1072,11 @@ dependencies: "@babel/runtime" "^7.13.10" +"@radix-ui/react-use-layout-effect@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz#3c2c8ce04827b26a39e442ff4888d9212268bd27" + integrity sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w== + "@radix-ui/react-use-previous@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-use-previous/-/react-use-previous-1.0.1.tgz#b595c087b07317a4f143696c6a01de43b0d0ec66" @@ -947,6 +1092,13 @@ "@babel/runtime" "^7.13.10" "@radix-ui/rect" "1.0.1" +"@radix-ui/react-use-rect@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz#13b25b913bd3e3987cc9b073a1a164bb1cf47b88" + integrity sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ== + dependencies: + "@radix-ui/rect" "1.1.0" + "@radix-ui/react-use-size@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-use-size/-/react-use-size-1.0.1.tgz#1c5f5fea940a7d7ade77694bb98116fb49f870b2" @@ -955,6 +1107,13 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-use-layout-effect" "1.0.1" +"@radix-ui/react-use-size@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz#b4dba7fbd3882ee09e8d2a44a3eed3a7e555246b" + integrity sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw== + dependencies: + "@radix-ui/react-use-layout-effect" "1.1.0" + "@radix-ui/react-visually-hidden@1.0.3": version "1.0.3" resolved "https://registry.yarnpkg.com/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.0.3.tgz#51aed9dd0fe5abcad7dee2a234ad36106a6984ac" @@ -970,6 +1129,11 @@ dependencies: "@babel/runtime" "^7.13.10" +"@radix-ui/rect@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.0.tgz#f817d1d3265ac5415dadc67edab30ae196696438" + integrity sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg== + "@remix-run/router@1.14.0": version "1.14.0" resolved "https://registry.npmjs.org/@remix-run/router/-/router-1.14.0.tgz" @@ -1661,6 +1825,11 @@ dargs@^8.0.0: resolved "https://registry.yarnpkg.com/dargs/-/dargs-8.1.0.tgz#a34859ea509cbce45485e5aa356fef70bfcc7272" integrity sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw== +date-fns@^3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-3.6.0.tgz#f20ca4fe94f8b754951b24240676e8618c0206bf" + integrity sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww== + dayjs@^1.11.10: version "1.11.10" resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.10.tgz#68acea85317a6e164457d6d6947564029a6a16a0" @@ -2906,6 +3075,11 @@ queue-microtask@^1.2.2: resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== +react-day-picker@^8.10.1: + version "8.10.1" + resolved "https://registry.yarnpkg.com/react-day-picker/-/react-day-picker-8.10.1.tgz#4762ec298865919b93ec09ba69621580835b8e80" + integrity sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA== + react-dom@^18.2.0: version "18.2.0" resolved "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz" @@ -2935,7 +3109,7 @@ react-qr-code@^2.0.12: prop-types "^15.8.1" qr.js "0.0.0" -react-remove-scroll-bar@^2.3.3: +react-remove-scroll-bar@^2.3.3, react-remove-scroll-bar@^2.3.4: version "2.3.6" resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.6.tgz#3e585e9d163be84a010180b18721e851ac81a29c" integrity sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g== @@ -2954,6 +3128,17 @@ react-remove-scroll@2.5.5: use-callback-ref "^1.3.0" use-sidecar "^1.1.2" +react-remove-scroll@2.5.7: + version "2.5.7" + resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.5.7.tgz#15a1fd038e8497f65a695bf26a4a57970cac1ccb" + integrity sha512-FnrTWO4L7/Bhhf3CYBNArEG/yROV0tKmTv7/3h9QCFvH6sndeFf1wPqOcbFVu5VAulS5dV1wGT3GZZ/1GawqiA== + dependencies: + react-remove-scroll-bar "^2.3.4" + react-style-singleton "^2.2.1" + tslib "^2.1.0" + use-callback-ref "^1.3.0" + use-sidecar "^1.1.2" + react-router-dom@^6.21.0: version "6.21.0" resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.21.0.tgz" From 4976123a01720605b01c980a737e62719a5f7ba8 Mon Sep 17 00:00:00 2001 From: im-adithya Date: Thu, 4 Jul 2024 11:35:05 +0530 Subject: [PATCH 02/33] chore: changes --- frontend/src/components/Permissions.tsx | 11 ----------- frontend/src/types.ts | 4 ++-- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/Permissions.tsx b/frontend/src/components/Permissions.tsx index 785409ec..1f17a6c5 100644 --- a/frontend/src/components/Permissions.tsx +++ b/frontend/src/components/Permissions.tsx @@ -95,17 +95,6 @@ const Permissions: React.FC = ({ return; } const currentDate = new Date(); - // const expiryDate = new Date( - // Date.UTC( - // currentDate.getUTCFullYear(), - // currentDate.getUTCMonth(), - // currentDate.getUTCDate() + expiryDays, - // 23, - // 59, - // 59, - // 0 - // ) - // ); currentDate.setDate(currentDate.getDate() + expiryDays); currentDate.setHours(23, 59, 59, 0); handlePermissionsChange({ expiresAt: currentDate }); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index cbc7220a..9503ba0e 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -157,7 +157,7 @@ export interface App { expiresAt?: string; scopes: Scope[]; - maxAmount: string; + maxAmount: number; budgetUsage: number; budgetRenewal: string; } @@ -209,7 +209,7 @@ export interface CreateAppResponse { } export type UpdateAppRequest = { - maxAmount: string; + maxAmount: number; budgetRenewal: string; expiresAt: string | undefined; scopes: Scope[]; From e7a8a20069bcfeef909e95276a54395fc0fe6224 Mon Sep 17 00:00:00 2001 From: im-adithya Date: Thu, 4 Jul 2024 11:55:28 +0530 Subject: [PATCH 03/33] chore: changes --- frontend/src/components/Permissions.tsx | 17 +++++++++-------- frontend/src/screens/apps/NewApp.tsx | 4 ++-- frontend/src/types.ts | 2 +- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/Permissions.tsx b/frontend/src/components/Permissions.tsx index 1f17a6c5..6450c1ab 100644 --- a/frontend/src/components/Permissions.tsx +++ b/frontend/src/components/Permissions.tsx @@ -80,7 +80,7 @@ const Permissions: React.FC = ({ handlePermissionsChange({ scopes }); }; - const handleBudgetMaxAmountChange = (amount: string) => { + const handleBudgetMaxAmountChange = (amount: number) => { handlePermissionsChange({ maxAmount: amount }); }; @@ -162,14 +162,12 @@ const Permissions: React.FC = ({ key={budget} onClick={() => { setCustomBudget(false); - handleBudgetMaxAmountChange( - budgetOptions[budget].toString() - ); + handleBudgetMaxAmountChange(budgetOptions[budget]); }} className={cn( "cursor-pointer rounded text-nowrap border-2 text-center p-4 dark:text-white", !customBudget && - (permissions.maxAmount === "" + (Number.isNaN(permissions.maxAmount) ? 100000 : +permissions.maxAmount) == budgetOptions[budget] ? "border-primary" @@ -183,7 +181,7 @@ const Permissions: React.FC = ({
{ setCustomBudget(true); - handleBudgetMaxAmountChange(""); + handleBudgetMaxAmountChange(0); }} className={cn( "cursor-pointer rounded border-2 text-center p-4 dark:text-white", @@ -204,9 +202,9 @@ const Permissions: React.FC = ({ type="number" required min={1} - value={permissions.maxAmount} + value={permissions.maxAmount || ""} onChange={(e) => { - handleBudgetMaxAmountChange(e.target.value.trim()); + handleBudgetMaxAmountChange(parseInt(e.target.value)); }} />
@@ -271,6 +269,9 @@ const Permissions: React.FC = ({ { if (daysFromNow(date) == 0) { diff --git a/frontend/src/screens/apps/NewApp.tsx b/frontend/src/screens/apps/NewApp.tsx index b0048d19..7a4f7782 100644 --- a/frontend/src/screens/apps/NewApp.tsx +++ b/frontend/src/screens/apps/NewApp.tsx @@ -166,7 +166,7 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => { const [permissions, setPermissions] = useState({ scopes: initialScopes, - maxAmount: budgetMaxAmountParam, + maxAmount: parseInt(budgetMaxAmountParam), budgetRenewal: validBudgetRenewals.includes(budgetRenewalParam) ? budgetRenewalParam : "monthly", @@ -189,7 +189,7 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => { name: appName, pubkey, budgetRenewal: permissions.budgetRenewal, - maxAmount: parseInt(permissions.maxAmount), + maxAmount: permissions.maxAmount, scopes: Array.from(permissions.scopes), expiresAt: permissions.expiresAt?.toISOString(), returnTo: returnTo, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 9503ba0e..813b3793 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -164,7 +164,7 @@ export interface App { export interface AppPermissions { scopes: Set; - maxAmount: string; + maxAmount: number; budgetRenewal: BudgetRenewalType; expiresAt?: Date; } From c070d2204b69d0bf093f12f4d0dcc02a304f2c81 Mon Sep 17 00:00:00 2001 From: im-adithya Date: Mon, 8 Jul 2024 17:46:32 +0530 Subject: [PATCH 04/33] chore: add view mode for show app screen --- frontend/src/components/Permissions.tsx | 63 +++++++++++++++++++++++-- frontend/src/screens/apps/NewApp.tsx | 1 + frontend/src/screens/apps/ShowApp.tsx | 5 +- 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/Permissions.tsx b/frontend/src/components/Permissions.tsx index 6450c1ab..3096e2c9 100644 --- a/frontend/src/components/Permissions.tsx +++ b/frontend/src/components/Permissions.tsx @@ -27,6 +27,8 @@ import { WalletCapabilities, budgetOptions, expiryOptions, + iconMap, + scopeDescriptions, validBudgetRenewals, } from "src/types"; @@ -41,6 +43,7 @@ interface PermissionsProps { onPermissionsChange: (permissions: AppPermissions) => void; canEditPermissions: boolean; budgetUsage?: number; + isNewConnection?: boolean; } const Permissions: React.FC = ({ @@ -48,6 +51,7 @@ const Permissions: React.FC = ({ initialPermissions, onPermissionsChange, canEditPermissions, + isNewConnection, budgetUsage, }) => { // TODO: EDITABLE LOGIC @@ -57,9 +61,13 @@ const Permissions: React.FC = ({ const [expiryDays, setExpiryDays] = useState( daysFromNow(permissions.expiresAt) ); - const [budgetOption, setBudgetOption] = useState(!!permissions.maxAmount); + const [budgetOption, setBudgetOption] = useState( + isNewConnection ? !!permissions.maxAmount : true + ); const [customBudget, setCustomBudget] = useState(!!permissions.maxAmount); - const [expireOption, setExpireOption] = useState(!!permissions.expiresAt); + const [expireOption, setExpireOption] = useState( + isNewConnection ? !!permissions.expiresAt : true + ); const [customExpiry, setCustomExpiry] = useState(!!permissions.expiresAt); useEffect(() => { @@ -100,8 +108,55 @@ const Permissions: React.FC = ({ handlePermissionsChange({ expiresAt: currentDate }); }; - return ( -
+ return !canEditPermissions ? ( + <> +

Scopes

+
+ {[...initialPermissions.scopes].map((rm, index) => { + const PermissionIcon = iconMap[rm]; + return ( +
+ +

{scopeDescriptions[rm]}

+
+ ); + })} +
+ {permissions.scopes.has(NIP_47_PAY_INVOICE_METHOD) && ( +
+
+

+ Budget Renewal: {permissions.budgetRenewal || "Never"} +

+

+ Budget Amount:{" "} + {permissions.maxAmount + ? new Intl.NumberFormat().format(permissions.maxAmount) + : "∞"} + {" sats "} + {`(${new Intl.NumberFormat().format(budgetUsage || 0)} sats used)`} +

+
+
+ )} +
+

Connection expiry

+

+ {permissions.expiresAt && + new Date(permissions.expiresAt).getFullYear() !== 1 + ? new Date(permissions.expiresAt).toString() + : "This app will never expire"} +

+
+ + ) : ( +
{ initialPermissions={permissions} onPermissionsChange={setPermissions} canEditPermissions={!reqMethodsParam} + isNewConnection />
diff --git a/frontend/src/screens/apps/ShowApp.tsx b/frontend/src/screens/apps/ShowApp.tsx index b73d02bb..a9fba457 100644 --- a/frontend/src/screens/apps/ShowApp.tsx +++ b/frontend/src/screens/apps/ShowApp.tsx @@ -39,6 +39,7 @@ import { } from "src/components/ui/card"; import { Table, TableBody, TableCell, TableRow } from "src/components/ui/table"; import { useToast } from "src/components/ui/use-toast"; +import { useCapabilities } from "src/hooks/useCapabilities"; function ShowApp() { const { data: info } = useInfo(); @@ -46,6 +47,7 @@ function ShowApp() { const { toast } = useToast(); const { pubkey } = useParams() as { pubkey: string }; const { data: app, mutate: refetchApp, error } = useApp(pubkey); + const { data: capabilities } = useCapabilities(); const navigate = useNavigate(); const location = useLocation(); const [editMode, setEditMode] = React.useState(false); @@ -81,7 +83,7 @@ function ShowApp() { return

{error.message}

; } - if (!app || !info) { + if (!app || !info || !capabilities) { return ; } @@ -247,6 +249,7 @@ function ShowApp() { Date: Tue, 9 Jul 2024 12:40:47 +0530 Subject: [PATCH 05/33] chore: further changes --- frontend/src/components/Permissions.tsx | 116 ++++++++++++++++------- frontend/src/components/Scopes.tsx | 6 +- frontend/src/screens/apps/AppCreated.tsx | 1 - frontend/src/screens/apps/NewApp.tsx | 2 +- frontend/src/screens/apps/ShowApp.tsx | 18 +--- 5 files changed, 90 insertions(+), 53 deletions(-) diff --git a/frontend/src/components/Permissions.tsx b/frontend/src/components/Permissions.tsx index 3096e2c9..084e0d74 100644 --- a/frontend/src/components/Permissions.tsx +++ b/frontend/src/components/Permissions.tsx @@ -1,6 +1,6 @@ import { format } from "date-fns"; import { CalendarIcon, PlusCircle, XIcon } from "lucide-react"; -import React, { useEffect, useState } from "react"; +import React, { useState } from "react"; import Scopes from "src/components/Scopes"; import { Button } from "src/components/ui/button"; import { Calendar } from "src/components/ui/calendar"; @@ -32,10 +32,31 @@ import { validBudgetRenewals, } from "src/types"; -const daysFromNow = (date?: Date) => - date - ? Math.ceil((new Date(date).getTime() - Date.now()) / (1000 * 60 * 60 * 24)) - : 0; +const getTimeZoneDirection = () => { + const offset = new Date().getTimezoneOffset(); + + return offset <= 0 ? +1 : -1; +}; + +const daysFromNow = (date?: Date) => { + if (!date) { + return 0; + } + const utcDate = new Date( + Date.UTC( + date.getUTCFullYear(), + date.getUTCMonth(), + date.getUTCDate(), + 23, + 59, + 59, + 0 + ) + ); + return Math.ceil( + (new Date(utcDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24) + ); +}; interface PermissionsProps { capabilities: WalletCapabilities; @@ -54,24 +75,45 @@ const Permissions: React.FC = ({ isNewConnection, budgetUsage, }) => { - // TODO: EDITABLE LOGIC const [permissions, setPermissions] = React.useState(initialPermissions); - - // TODO: set expiry when set to non expiryType value like 24 days for example const [expiryDays, setExpiryDays] = useState( daysFromNow(permissions.expiresAt) ); const [budgetOption, setBudgetOption] = useState( isNewConnection ? !!permissions.maxAmount : true ); - const [customBudget, setCustomBudget] = useState(!!permissions.maxAmount); + const [customBudget, setCustomBudget] = useState( + permissions.maxAmount + ? !Object.values(budgetOptions).includes(permissions.maxAmount) + : false + ); const [expireOption, setExpireOption] = useState( isNewConnection ? !!permissions.expiresAt : true ); - const [customExpiry, setCustomExpiry] = useState(!!permissions.expiresAt); + const [customExpiry, setCustomExpiry] = useState( + daysFromNow(permissions.expiresAt) + ? !Object.values(expiryOptions).includes( + daysFromNow(permissions.expiresAt) + ) + : false + ); - useEffect(() => { + // this is triggered when edit mode is cancelled in show app + React.useEffect(() => { setPermissions(initialPermissions); + setExpiryDays(daysFromNow(initialPermissions.expiresAt)); + setCustomBudget( + initialPermissions.maxAmount + ? !Object.values(budgetOptions).includes(initialPermissions.maxAmount) + : false + ); + setCustomExpiry( + daysFromNow(initialPermissions.expiresAt) + ? !Object.values(expiryOptions).includes( + daysFromNow(initialPermissions.expiresAt) + ) + : false + ); }, [initialPermissions]); const handlePermissionsChange = ( @@ -83,8 +125,6 @@ const Permissions: React.FC = ({ }; const handleScopeChange = (scopes: Set) => { - // TODO: what if edit is not set (see prev diff) - // TODO: what if we set pay_invoice scope again, what would be the value of budgetRenewal handlePermissionsChange({ scopes }); }; @@ -97,15 +137,24 @@ const Permissions: React.FC = ({ }; const handleExpiryDaysChange = (expiryDays: number) => { - setExpiryDays(expiryDays); + setExpiryDays(expiryDays + getTimeZoneDirection()); if (!expiryDays) { handlePermissionsChange({ expiresAt: undefined }); return; } const currentDate = new Date(); - currentDate.setDate(currentDate.getDate() + expiryDays); - currentDate.setHours(23, 59, 59, 0); - handlePermissionsChange({ expiresAt: currentDate }); + const expiryDate = new Date( + Date.UTC( + currentDate.getUTCFullYear(), + currentDate.getUTCMonth(), + currentDate.getUTCDate() + expiryDays, + 23, + 59, + 59, + 0 + ) + ); + handlePermissionsChange({ expiresAt: expiryDate }); }; return !canEditPermissions ? ( @@ -148,7 +197,8 @@ const Permissions: React.FC = ({

Connection expiry

- {permissions.expiresAt && + {expiryDays && + permissions.expiresAt && new Date(permissions.expiresAt).getFullYear() !== 1 ? new Date(permissions.expiresAt).toString() : "This app will never expire"} @@ -180,26 +230,20 @@ const Permissions: React.FC = ({ {budgetOption && ( <>

Budget Renewal

-
+
+ - - - - - {validBudgetRenewals.map((renewalOption) => ( - - {renewalOption} - - ))} - - onChange("never")} - /> - + <> + +
+ +
+ ); }; diff --git a/frontend/src/components/Permissions.tsx b/frontend/src/components/Permissions.tsx index 5f2663ac..5bcac7c0 100644 --- a/frontend/src/components/Permissions.tsx +++ b/frontend/src/components/Permissions.tsx @@ -184,13 +184,10 @@ const Permissions: React.FC = ({ )} {showBudgetOptions && ( <> -

Budget Renewal

-
- -
+ -
- +
+
- linkAccount(maxAmount, budgetRenewal)} From 7a3a8a2bbe44ef7ffd98caff0c73d8f76e372124 Mon Sep 17 00:00:00 2001 From: Roland <33993199+rolznz@users.noreply.github.com> Date: Sat, 13 Jul 2024 12:00:22 +0700 Subject: [PATCH 25/33] fix: LDK mark channel as inactive and show error if counterparty forwarding info missing (#267) fix: mark channel as inactive and show error if counterparty forwarding info missing --- frontend/src/screens/channels/Channels.tsx | 18 +++++++++++------- frontend/src/types.ts | 1 + lnclient/ldk/ldk.go | 13 ++++++++++++- lnclient/models.go | 1 + 4 files changed, 25 insertions(+), 8 deletions(-) diff --git a/frontend/src/screens/channels/Channels.tsx b/frontend/src/screens/channels/Channels.tsx index 1356a7a7..a8ccc8c5 100644 --- a/frontend/src/screens/channels/Channels.tsx +++ b/frontend/src/screens/channels/Channels.tsx @@ -670,13 +670,17 @@ export default function Channels() { channel.localBalance + channel.remoteBalance; let channelWarning = ""; - if (channel.localSpendableBalance < capacity * 0.1) { - channelWarning = - "Spending balance low. You may have trouble sending payments through this channel."; - } - if (channel.localSpendableBalance > capacity * 0.9) { - channelWarning = - "Receiving capacity low. You may have trouble receiving payments through this channel."; + if (channel.error) { + channelWarning = channel.error; + } else { + if (channel.localSpendableBalance < capacity * 0.1) { + channelWarning = + "Spending balance low. You may have trouble sending payments through this channel."; + } + if (channel.localSpendableBalance > capacity * 0.9) { + channelWarning = + "Receiving capacity low. You may have trouble receiving payments through this channel."; + } } return ( diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 1009a91f..cd942f40 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -203,6 +203,7 @@ export type Channel = { forwardingFeeBaseMsat: number; unspendablePunishmentReserve: number; counterpartyUnspendablePunishmentReserve: number; + error?: string; }; export type UpdateChannelRequest = { diff --git a/lnclient/ldk/ldk.go b/lnclient/ldk/ldk.go index f1697bee..c1dae670 100644 --- a/lnclient/ldk/ldk.go +++ b/lnclient/ldk/ldk.go @@ -833,6 +833,16 @@ func (ls *LDKService) ListChannels(ctx context.Context) ([]lnclient.Channel, err unspendablePunishmentReserve = *ldkChannel.UnspendablePunishmentReserve } + var channelError *string + + if ldkChannel.CounterpartyForwardingInfoFeeBaseMsat == nil { + // if we don't have this, routing will not work (LND <-> LDK interoperability bug - https://github.com/lightningnetwork/lnd/issues/6870 ) + channelErrorValue := "Counterparty forwarding info not available. Please contact support@getalby.com" + channelError = &channelErrorValue + } + + isActive := ldkChannel.IsUsable /* superset of ldkChannel.IsReady */ && channelError == nil + channels = append(channels, lnclient.Channel{ InternalChannel: internalChannel, LocalBalance: int64(ldkChannel.ChannelValueSats*1000 - ldkChannel.InboundCapacityMsat - ldkChannel.CounterpartyUnspendablePunishmentReserve*1000), @@ -840,7 +850,7 @@ func (ls *LDKService) ListChannels(ctx context.Context) ([]lnclient.Channel, err RemoteBalance: int64(ldkChannel.InboundCapacityMsat), RemotePubkey: ldkChannel.CounterpartyNodeId, Id: ldkChannel.UserChannelId, // CloseChannel takes the UserChannelId - Active: ldkChannel.IsUsable, // superset of ldkChannel.IsReady + Active: isActive, Public: ldkChannel.IsPublic, FundingTxId: fundingTxId, Confirmations: ldkChannel.Confirmations, @@ -848,6 +858,7 @@ func (ls *LDKService) ListChannels(ctx context.Context) ([]lnclient.Channel, err ForwardingFeeBaseMsat: ldkChannel.Config.ForwardingFeeBaseMsat(), UnspendablePunishmentReserve: unspendablePunishmentReserve, CounterpartyUnspendablePunishmentReserve: ldkChannel.CounterpartyUnspendablePunishmentReserve, + Error: channelError, }) } diff --git a/lnclient/models.go b/lnclient/models.go index f0e6cd31..6be72091 100644 --- a/lnclient/models.go +++ b/lnclient/models.go @@ -90,6 +90,7 @@ type Channel struct { ForwardingFeeBaseMsat uint32 `json:"forwardingFeeBaseMsat"` UnspendablePunishmentReserve uint64 `json:"unspendablePunishmentReserve"` CounterpartyUnspendablePunishmentReserve uint64 `json:"counterpartyUnspendablePunishmentReserve"` + Error *string `json:"error"` } type NodeStatus struct { From 1380934d33b5e43a270f07826c3408d8d5be4f67 Mon Sep 17 00:00:00 2001 From: Adithya Vardhan Date: Sat, 13 Jul 2024 10:32:46 +0530 Subject: [PATCH 26/33] chore: remove unnecessary dark classes (#255) --- frontend/src/components/TransactionItem.tsx | 28 ++++++++++----------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/TransactionItem.tsx b/frontend/src/components/TransactionItem.tsx index 1eae0234..ed589387 100644 --- a/frontend/src/components/TransactionItem.tsx +++ b/frontend/src/components/TransactionItem.tsx @@ -67,14 +67,14 @@ function TransactionItem({ tx }: Props) { className={cn( "w-6 h-6 md:w-8 md:h-8", type === "outgoing" - ? "text-orange-400 dark:text-amber-600 stroke-orange-400 dark:stroke-amber-600" - : "text-green-500 dark:text-emerald-500 stroke-green-400 dark:stroke-emerald-500" + ? "stroke-orange-400 dark:stroke-amber-600" + : "stroke-green-400 dark:stroke-emerald-500" )} />
-
+

{type == "incoming" ? "Received" : "Sent"}

@@ -86,12 +86,12 @@ function TransactionItem({ tx }: Props) { {tx.description || "Lightning invoice"}

-
+

{type == "outgoing" ? "-" : "+"} @@ -104,7 +104,7 @@ function TransactionItem({ tx }: Props) {

{/* {!!tx.totalAmountFiat && ( -

+

~{tx.totalAmountFiat}

)} */} @@ -133,8 +133,8 @@ function TransactionItem({ tx }: Props) { className={cn( "w-6 h-6 md:w-8 md:h-8", type === "outgoing" - ? "text-orange-400 dark:text-amber-600 stroke-orange-400 dark:stroke-amber-600" - : "text-green-500 dark:text-emerald-500 stroke-green-400 dark:stroke-emerald-500" + ? "stroke-orange-400 dark:stroke-amber-600" + : "stroke-green-400 dark:stroke-emerald-500" )} />
@@ -145,13 +145,13 @@ function TransactionItem({ tx }: Props) { )}{" "} {Math.floor(tx.amount / 1000) == 1 ? "sat" : "sats"}

- {/*

+ {/*

Fiat Amount

*/}
-

Date & Time

+

Date & Time

{dayjs(tx.settled_at * 1000) .tz(dayjs.tz.guess()) @@ -160,7 +160,7 @@ function TransactionItem({ tx }: Props) {

{type == "outgoing" && (
-

Fee

+

Fee

{new Intl.NumberFormat(undefined, {}).format( Math.floor(tx.fees_paid / 1000) @@ -171,7 +171,7 @@ function TransactionItem({ tx }: Props) { )} {tx.description && (

-

Description

+

Description

{tx.description}

)} @@ -191,7 +191,7 @@ function TransactionItem({ tx }: Props) { {showDetails && ( <>
-

Preimage

+

Preimage

{tx.preimage} @@ -205,7 +205,7 @@ function TransactionItem({ tx }: Props) {

-

Hash

+

Hash

{tx.payment_hash} From 9de731dae1a9e40fa5d3b2d49ce12615be2eb5a3 Mon Sep 17 00:00:00 2001 From: Roland <33993199+rolznz@users.noreply.github.com> Date: Sat, 13 Jul 2024 12:15:50 +0700 Subject: [PATCH 27/33] fix: links to open first channel in sidebar and onboarding checklist (#268) --- frontend/src/components/SidebarHint.tsx | 2 +- frontend/src/screens/wallet/OnboardingChecklist.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/SidebarHint.tsx b/frontend/src/components/SidebarHint.tsx index 3aa1a1c4..ce8d8817 100644 --- a/frontend/src/components/SidebarHint.tsx +++ b/frontend/src/components/SidebarHint.tsx @@ -73,7 +73,7 @@ function SidebarHint() { title="Open Your First Channel" description="Deposit bitcoin by onchain or lightning payment to start using your new wallet." buttonText="Begin Now" - buttonLink="/channels" + buttonLink="/channels/outgoing" /> ); } diff --git a/frontend/src/screens/wallet/OnboardingChecklist.tsx b/frontend/src/screens/wallet/OnboardingChecklist.tsx index af0deff4..8464f733 100644 --- a/frontend/src/screens/wallet/OnboardingChecklist.tsx +++ b/frontend/src/screens/wallet/OnboardingChecklist.tsx @@ -78,7 +78,7 @@ function OnboardingChecklist() { checked: hasChannel, to: canMigrateAlbyFundsToNewChannel ? "/onboarding/lightning/migrate-alby" - : "/channels", + : "/channels/outgoing", }, { title: "Send or receive your first payment", From 4a4d8d9c2b1bbd02c1b5da0da6f9f381a129fe9e Mon Sep 17 00:00:00 2001 From: Roland <33993199+rolznz@users.noreply.github.com> Date: Sat, 13 Jul 2024 12:45:02 +0700 Subject: [PATCH 28/33] feat: improve migrate node UI (#269) --- .../src/components/layouts/SettingsLayout.tsx | 2 +- frontend/src/screens/BackupNode.tsx | 28 +++++++++++++++++-- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/layouts/SettingsLayout.tsx b/frontend/src/components/layouts/SettingsLayout.tsx index 7a9b03c9..64cb2daf 100644 --- a/frontend/src/components/layouts/SettingsLayout.tsx +++ b/frontend/src/components/layouts/SettingsLayout.tsx @@ -106,7 +106,7 @@ export default function SettingsLayout() { Key Backup )} {hasNodeBackup && ( - Node Backup + Migrate Node )} Debug Tools diff --git a/frontend/src/screens/BackupNode.tsx b/frontend/src/screens/BackupNode.tsx index b2f93f3f..1c31992f 100644 --- a/frontend/src/screens/BackupNode.tsx +++ b/frontend/src/screens/BackupNode.tsx @@ -1,8 +1,11 @@ +import { InfoCircledIcon } from "@radix-ui/react-icons"; +import { AlertTriangleIcon } from "lucide-react"; import React, { useState } from "react"; import { useNavigate } from "react-router-dom"; import Container from "src/components/Container"; import SettingsHeader from "src/components/SettingsHeader"; +import { Alert, AlertDescription, AlertTitle } from "src/components/ui/alert"; import { Button } from "src/components/ui/button"; import { Input } from "src/components/ui/input"; import { Label } from "src/components/ui/label"; @@ -80,9 +83,27 @@ export function BackupNode() { return ( <> + + + Do not run your node on multiple devices + + Your node maintains channel state with your channel partners. After + you create this backup, do not restart Alby Hub on this device. + + + + + What Happens Next + + You'll need to enter your unlock password to download and decrypt a + backup of your Alby Hub data. After your backup is downloaded, we'll + give you instructions on how to import the backup file on another host + or machine. + + {showPasswordScreen ? (

Enter unlock password

@@ -114,9 +135,10 @@ export function BackupNode() { type="submit" disabled={loading} size="lg" + className="w-full" onClick={() => setShowPasswordScreen(true)} > - Create Backup + Create Backup To Migrate Node
)} From 831d7bfdb0afd8ef9f3b684a29a7cd546f147cb1 Mon Sep 17 00:00:00 2001 From: Roland <33993199+rolznz@users.noreply.github.com> Date: Sat, 13 Jul 2024 12:49:21 +0700 Subject: [PATCH 29/33] fix: migrate node copy (#270) --- frontend/src/screens/BackupNode.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/src/screens/BackupNode.tsx b/frontend/src/screens/BackupNode.tsx index 1c31992f..1ac47534 100644 --- a/frontend/src/screens/BackupNode.tsx +++ b/frontend/src/screens/BackupNode.tsx @@ -98,10 +98,11 @@ export function BackupNode() { What Happens Next - You'll need to enter your unlock password to download and decrypt a - backup of your Alby Hub data. After your backup is downloaded, we'll - give you instructions on how to import the backup file on another host - or machine. + You'll need to enter your unlock password to encrypt and download a + backup of your Alby Hub data. After your encrypted backup is + downloaded, we'll give you instructions on how to import the backup + file on another host or machine. Your unlock password will be needed + again to restore your backup. {showPasswordScreen ? ( From 7b9e4d5c2fdc7737a977562185d736249685b486 Mon Sep 17 00:00:00 2001 From: Roland <33993199+rolznz@users.noreply.github.com> Date: Sat, 13 Jul 2024 20:01:33 +0700 Subject: [PATCH 30/33] fix: stop nostr when app is shutdown and use context to stop lnclient (#271) --- api/api.go | 10 +++------- cmd/http/main.go | 2 +- main_wails.go | 2 +- service/models.go | 3 +-- service/service.go | 14 +------------- service/start.go | 33 ++++++++++++++++++++++--------- service/stop.go | 48 +++++++++++++++++++++++++++------------------- 7 files changed, 59 insertions(+), 53 deletions(-) diff --git a/api/api.go b/api/api.go index 38443aa1..7aa7e431 100644 --- a/api/api.go +++ b/api/api.go @@ -314,15 +314,11 @@ func (api *api) Stop() error { return errors.New("LNClient not started") } - // TODO: this should stop everything related to the lnclient - // stop the lnclient + // stop the lnclient, nostr relay etc. // The user will be forced to re-enter their unlock password to restart the node - err := api.svc.StopLNClient() - if err != nil { - logger.Logger.WithError(err).Error("Failed to stop LNClient") - } + api.svc.StopApp() - return err + return nil } func (api *api) GetNodeConnectionInfo(ctx context.Context) (*lnclient.NodeConnectionInfo, error) { diff --git a/cmd/http/main.go b/cmd/http/main.go index 929dec5d..328fb465 100644 --- a/cmd/http/main.go +++ b/cmd/http/main.go @@ -57,6 +57,6 @@ func main() { defer cancel() e.Shutdown(ctx) logger.Logger.Info("Echo server exited") - svc.WaitShutdown() + svc.Shutdown() logger.Logger.Info("Service exited") } diff --git a/main_wails.go b/main_wails.go index 0b021d42..46db4740 100644 --- a/main_wails.go +++ b/main_wails.go @@ -31,6 +31,6 @@ func main() { logger.Logger.Info("Cancelling service context...") // cancel the service context cancel() - svc.WaitShutdown() + svc.Shutdown() logger.Logger.Info("Service exited") } diff --git a/service/models.go b/service/models.go index bd056b53..3c0961b9 100644 --- a/service/models.go +++ b/service/models.go @@ -12,8 +12,7 @@ import ( type Service interface { StartApp(encryptionKey string) error StopApp() - StopLNClient() error - WaitShutdown() + Shutdown() // TODO: remove getters (currently used by http / wails services) GetAlbyOAuthSvc() alby.AlbyOAuthService diff --git a/service/service.go b/service/service.go index 63a4af84..57e71e58 100644 --- a/service/service.go +++ b/service/service.go @@ -203,7 +203,7 @@ func finishRestoreNode(workDir string) { } func (svc *service) Shutdown() { - svc.StopLNClient() + svc.StopApp() svc.eventPublisher.Publish(&events.Event{ Event: "nwc_stopped", }) @@ -211,13 +211,6 @@ func (svc *service) Shutdown() { time.Sleep(1 * time.Second) } -func (svc *service) StopApp() { - if svc.appCancelFn != nil { - svc.appCancelFn() - svc.wg.Wait() - } -} - func (svc *service) GetDB() *gorm.DB { return svc.db } @@ -245,8 +238,3 @@ func (svc *service) GetLNClient() lnclient.LNClient { func (svc *service) GetKeys() keys.Keys { return svc.keys } - -func (svc *service) WaitShutdown() { - logger.Logger.Info("Waiting for service to exit...") - svc.wg.Wait() -} diff --git a/service/start.go b/service/start.go index 83715e41..43731161 100644 --- a/service/start.go +++ b/service/start.go @@ -22,7 +22,7 @@ import ( "github.com/getAlby/hub/logger" ) -func (svc *service) StartNostr(ctx context.Context, encryptionKey string) error { +func (svc *service) startNostr(ctx context.Context, encryptionKey string) error { relayUrl := svc.cfg.GetRelayUrl() @@ -40,8 +40,10 @@ func (svc *service) StartNostr(ctx context.Context, encryptionKey string) error "npub": npub, "hex": svc.keys.GetNostrPublicKey(), }).Info("Starting Alby Hub") - svc.wg.Add(1) go func() { + svc.wg.Add(1) + // ensure the relay is properly disconnected before exiting + defer svc.wg.Done() //Start infinite loop which will be only broken by canceling ctx (SIGINT) var relay *nostr.Relay @@ -95,9 +97,7 @@ func (svc *service) StartNostr(ctx context.Context, encryptionKey string) error break } closeRelay(relay) - svc.Shutdown() logger.Logger.Info("Relay subroutine ended") - svc.wg.Done() }() return nil } @@ -123,17 +123,30 @@ func (svc *service) StartApp(encryptionKey string) error { return err } - svc.StartNostr(ctx, encryptionKey) + err = svc.startNostr(ctx, encryptionKey) + if err != nil { + cancelFn() + return err + } + svc.appCancelFn = cancelFn + return nil } func (svc *service) launchLNBackend(ctx context.Context, encryptionKey string) error { - err := svc.StopLNClient() - if err != nil { - return err + if svc.lnClient != nil { + logger.Logger.Error("LNClient already started") + return errors.New("LNClient already started") } + go func() { + // ensure the LNClient is stopped properly before exiting + svc.wg.Add(1) + <-ctx.Done() + svc.stopLNClient() + }() + lnBackend, _ := svc.cfg.Get("LNBackendType", "") if lnBackend == "" { return errors.New("no LNBackendType specified") @@ -141,6 +154,7 @@ func (svc *service) launchLNBackend(ctx context.Context, encryptionKey string) e logger.Logger.Infof("Launching LN Backend: %s", lnBackend) var lnClient lnclient.LNClient + var err error switch lnBackend { case config.LNDBackendType: LNDAddress, _ := svc.cfg.Get("LNDAddress", encryptionKey) @@ -183,6 +197,7 @@ func (svc *service) launchLNBackend(ctx context.Context, encryptionKey string) e return err } + svc.lnClient = lnClient info, err := lnClient.GetInfo(ctx) if err != nil { logger.Logger.WithError(err).Error("Failed to fetch node info") @@ -197,7 +212,7 @@ func (svc *service) launchLNBackend(ctx context.Context, encryptionKey string) e "node_type": lnBackend, }, }) - svc.lnClient = lnClient + return nil } diff --git a/service/stop.go b/service/stop.go index 2ec0fe03..56d6539d 100644 --- a/service/stop.go +++ b/service/stop.go @@ -7,28 +7,36 @@ import ( "github.com/getAlby/hub/logger" ) -// TODO: this should happen on ctx.Done() rather than having to call manually -// see svc.appCancelFn and how svc.StartNostr works -func (svc *service) StopLNClient() error { - if svc.lnClient != nil { - logger.Logger.Info("Shutting down LN client") - err := svc.lnClient.Shutdown() - if err != nil { - logger.Logger.WithError(err).Error("Failed to stop LN client") - svc.eventPublisher.Publish(&events.Event{ - Event: "nwc_node_stop_failed", - Properties: map[string]interface{}{ - "error": fmt.Sprintf("%v", err), - }, - }) - return err - } - logger.Logger.Info("Publishing node shutdown event") - svc.lnClient = nil +func (svc *service) StopApp() { + if svc.appCancelFn != nil { + logger.Logger.Info("Stopping app...") + svc.appCancelFn() + svc.wg.Wait() + logger.Logger.Info("app stopped") + } +} + +func (svc *service) stopLNClient() { + defer svc.wg.Done() + if svc.lnClient == nil { + return + } + logger.Logger.Info("Shutting down LN client") + err := svc.lnClient.Shutdown() + if err != nil { + logger.Logger.WithError(err).Error("Failed to stop LN client") svc.eventPublisher.Publish(&events.Event{ - Event: "nwc_node_stopped", + Event: "nwc_node_stop_failed", + Properties: map[string]interface{}{ + "error": fmt.Sprintf("%v", err), + }, }) + return } + logger.Logger.Info("Publishing node shutdown event") + svc.lnClient = nil + svc.eventPublisher.Publish(&events.Event{ + Event: "nwc_node_stopped", + }) logger.Logger.Info("LNClient stopped successfully") - return nil } From 979d9d4957f0841b8bc88309a1efee81818d4228 Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Sun, 14 Jul 2024 12:33:11 +0700 Subject: [PATCH 31/33] fix: permissions revamp WIP --- frontend/src/components/Permissions.tsx | 20 ++- frontend/src/components/Scopes.tsx | 140 +++++++++--------- .../connections/AppCardConnectionInfo.tsx | 19 +-- frontend/src/screens/apps/NewApp.tsx | 47 +++--- frontend/src/types.ts | 84 ++--------- 5 files changed, 120 insertions(+), 190 deletions(-) diff --git a/frontend/src/components/Permissions.tsx b/frontend/src/components/Permissions.tsx index 5bcac7c0..e5a2f5f5 100644 --- a/frontend/src/components/Permissions.tsx +++ b/frontend/src/components/Permissions.tsx @@ -9,7 +9,6 @@ import { cn } from "src/lib/utils"; import { AppPermissions, BudgetRenewalType, - NIP_47_PAY_INVOICE_METHOD, Scope, WalletCapabilities, scopeDescriptions, @@ -66,7 +65,7 @@ const Permissions: React.FC = ({ }, [canEditPermissions, isNewConnection]); const [showBudgetOptions, setShowBudgetOptions] = React.useState( - isNewConnection ? !!permissions.maxAmount : true + permissions.scopes.has("pay_invoice") ); const [showExpiryOptions, setShowExpiryOptions] = React.useState( isNewConnection ? !!permissions.expiresAt : true @@ -115,32 +114,31 @@ const Permissions: React.FC = ({ ) : ( <>

Scopes

- {[...permissions.scopes].map((rm) => { - const PermissionIcon = scopeIconMap[rm]; + {[...permissions.scopes].map((scope) => { + const PermissionIcon = scopeIconMap[scope]; return (
-

{scopeDescriptions[rm]}

+

{scopeDescriptions[scope]}

); })}
)} - {capabilities.scopes.includes(NIP_47_PAY_INVOICE_METHOD) && - permissions.scopes.has(NIP_47_PAY_INVOICE_METHOD) && + {permissions.scopes.has("pay_invoice") && (!isBudgetAmountEditable ? (
@@ -179,7 +177,7 @@ const Permissions: React.FC = ({ )} > - Set budget renewal + Set budget )} {showBudgetOptions && ( diff --git a/frontend/src/components/Scopes.tsx b/frontend/src/components/Scopes.tsx index f0958785..7fda55c1 100644 --- a/frontend/src/components/Scopes.tsx +++ b/frontend/src/components/Scopes.tsx @@ -1,89 +1,89 @@ -import React, { useEffect } from "react"; +import { + ArrowDownUp, + BrickWall, + LucideIcon, + MoveDown, + SquarePen, +} from "lucide-react"; +import React from "react"; import { Checkbox } from "src/components/ui/checkbox"; import { Label } from "src/components/ui/label"; import { cn } from "src/lib/utils"; -import { - NIP_47_GET_BALANCE_METHOD, - NIP_47_GET_INFO_METHOD, - NIP_47_LIST_TRANSACTIONS_METHOD, - NIP_47_LOOKUP_INVOICE_METHOD, - NIP_47_NOTIFICATIONS_PERMISSION, - NIP_47_PAY_INVOICE_METHOD, - ReadOnlyScope, - SCOPE_GROUP_CUSTOM, - SCOPE_GROUP_FULL_ACCESS, - SCOPE_GROUP_READ_ONLY, - Scope, - ScopeGroup, - WalletCapabilities, - scopeDescriptions, - scopeGroupDescriptions, - scopeGroupIconMap, - scopeGroupTitle, -} from "src/types"; +import { Scope, WalletCapabilities, scopeDescriptions } from "src/types"; + +const scopeGroups = ["full_access", "read_only", "isolated", "custom"] as const; +type ScopeGroup = (typeof scopeGroups)[number]; +type ScopeGroupIconMap = { [key in ScopeGroup]: LucideIcon }; + +const scopeGroupIconMap: ScopeGroupIconMap = { + full_access: ArrowDownUp, + read_only: MoveDown, + isolated: BrickWall, + custom: SquarePen, +}; + +const scopeGroupTitle: Record = { + full_access: "Full Access", + read_only: "Read Only", + isolated: "Isolated", + custom: "Custom", +}; + +const scopeGroupDescriptions: Record = { + full_access: "I trust this app to access my wallet within the budget I set", + read_only: "This app can receive payments and read my transaction history", + isolated: + "This app will have its own balance and only sees its own transactions", + custom: "I want to define exactly what access this app has to my wallet", +}; interface ScopesProps { capabilities: WalletCapabilities; scopes: Set; - onScopeChange: (scopes: Set) => void; + onScopesChanged: (scopes: Set) => void; } -const isSetEqual = (setA: Set, setB: Set) => - setA.size === setB.size && [...setA].every((value) => setB.has(value)); - const Scopes: React.FC = ({ capabilities, scopes, - onScopeChange, + onScopesChanged, }) => { const fullAccessScopes: Set = React.useMemo(() => { return new Set(capabilities.scopes); }, [capabilities.scopes]); - const readOnlyScopes: Set = React.useMemo(() => { + const readOnlyScopes: Set = React.useMemo(() => { + const readOnlyScopes: Scope[] = [ + "get_balance", + "get_info", + "make_invoice", + "lookup_invoice", + "list_transactions", + "notifications", + ]; + const scopes: Scope[] = capabilities.scopes; - return new Set( - scopes.filter((method): method is ReadOnlyScope => - [ - NIP_47_GET_BALANCE_METHOD, - NIP_47_GET_INFO_METHOD, - NIP_47_LOOKUP_INVOICE_METHOD, - NIP_47_LIST_TRANSACTIONS_METHOD, - NIP_47_NOTIFICATIONS_PERMISSION, - ].includes(method) - ) - ); + return new Set(scopes.filter((scope) => readOnlyScopes.includes(scope))); }, [capabilities.scopes]); const [scopeGroup, setScopeGroup] = React.useState(() => { - if (!scopes.size || isSetEqual(scopes, fullAccessScopes)) { - return SCOPE_GROUP_FULL_ACCESS; - } else if (isSetEqual(scopes, readOnlyScopes)) { - return SCOPE_GROUP_READ_ONLY; + if (!scopes.size) { + return "full_access"; } - return SCOPE_GROUP_CUSTOM; + return "custom"; }); - // we need scopes to be empty till this point for isScopesEditable - // and once this component is mounted we set it to all scopes - useEffect(() => { - // stop setting scopes on re-renders - if (!scopes.size && scopeGroup != SCOPE_GROUP_CUSTOM) { - onScopeChange(fullAccessScopes); - } - }, [fullAccessScopes, onScopeChange, scopeGroup, scopes]); - const handleScopeGroupChange = (scopeGroup: ScopeGroup) => { setScopeGroup(scopeGroup); switch (scopeGroup) { - case SCOPE_GROUP_FULL_ACCESS: - onScopeChange(fullAccessScopes); + case "full_access": + onScopesChanged(fullAccessScopes); break; - case SCOPE_GROUP_READ_ONLY: - onScopeChange(readOnlyScopes); + case "read_only": + onScopesChanged(readOnlyScopes); break; default: { - onScopeChange(new Set()); + onScopesChanged(new Set()); break; } } @@ -96,21 +96,15 @@ const Scopes: React.FC = ({ } else { newScopes.add(scope); } - onScopeChange(newScopes); + onScopesChanged(newScopes); }; return ( <>

Choose wallet permissions

-
- {( - [ - SCOPE_GROUP_FULL_ACCESS, - SCOPE_GROUP_READ_ONLY, - SCOPE_GROUP_CUSTOM, - ] as ScopeGroup[] - ).map((sg, index) => { +
+ {scopeGroups.map((sg, index) => { const ScopeGroupIcon = scopeGroupIconMap[sg]; return (
= ({ >

{scopeGroupTitle[sg]}

- + {scopeGroupDescriptions[sg]}
@@ -135,24 +129,24 @@ const Scopes: React.FC = ({

Authorize the app to:

    - {capabilities.scopes.map((rm, index) => { + {capabilities.scopes.map((scope, index) => { return (
  • handleScopeChange(rm)} - checked={scopes.has(rm)} + onCheckedChange={() => handleScopeChange(scope)} + checked={scopes.has(scope)} /> -
  • diff --git a/frontend/src/components/connections/AppCardConnectionInfo.tsx b/frontend/src/components/connections/AppCardConnectionInfo.tsx index 1aad6f72..64a4aab3 100644 --- a/frontend/src/components/connections/AppCardConnectionInfo.tsx +++ b/frontend/src/components/connections/AppCardConnectionInfo.tsx @@ -4,12 +4,7 @@ import { Link } from "react-router-dom"; import { Button } from "src/components/ui/button"; import { Progress } from "src/components/ui/progress"; import { formatAmount } from "src/lib/utils"; -import { - App, - BudgetRenewalType, - NIP_47_MAKE_INVOICE_METHOD, - NIP_47_PAY_INVOICE_METHOD, -} from "src/types"; +import { App, BudgetRenewalType } from "src/types"; type AppCardConnectionInfoProps = { connection: App; @@ -74,7 +69,7 @@ export function AppCardConnectionInfo({
- ) : connection.scopes.indexOf(NIP_47_PAY_INVOICE_METHOD) > -1 ? ( + ) : connection.scopes.indexOf("pay_invoice") > -1 ? ( <>
@@ -102,10 +97,16 @@ export function AppCardConnectionInfo({ Share wallet information
- {connection.scopes.indexOf(NIP_47_MAKE_INVOICE_METHOD) > -1 && ( + {connection.scopes.indexOf("make_invoice") > -1 && (
- Create Invoices + Receive payments +
+ )} + {connection.scopes.indexOf("list_transactions") > -1 && ( +
+ + Read transaction history
)}
diff --git a/frontend/src/screens/apps/NewApp.tsx b/frontend/src/screens/apps/NewApp.tsx index 0af955da..1eedbfbf 100644 --- a/frontend/src/screens/apps/NewApp.tsx +++ b/frontend/src/screens/apps/NewApp.tsx @@ -7,17 +7,6 @@ import { BudgetRenewalType, CreateAppRequest, CreateAppResponse, - NIP_47_GET_BALANCE_METHOD, - NIP_47_GET_INFO_METHOD, - NIP_47_LIST_TRANSACTIONS_METHOD, - NIP_47_LOOKUP_INVOICE_METHOD, - NIP_47_MAKE_INVOICE_METHOD, - NIP_47_MULTI_PAY_INVOICE_METHOD, - NIP_47_MULTI_PAY_KEYSEND_METHOD, - NIP_47_NOTIFICATIONS_PERMISSION, - NIP_47_PAY_INVOICE_METHOD, - NIP_47_PAY_KEYSEND_METHOD, - NIP_47_SIGN_MESSAGE_METHOD, Nip47NotificationType, Nip47RequestMethod, Scope, @@ -117,34 +106,34 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => { const scopes = new Set(); if ( - requestMethodsSet.has(NIP_47_PAY_KEYSEND_METHOD) || - requestMethodsSet.has(NIP_47_PAY_INVOICE_METHOD) || - requestMethodsSet.has(NIP_47_MULTI_PAY_INVOICE_METHOD) || - requestMethodsSet.has(NIP_47_MULTI_PAY_KEYSEND_METHOD) + requestMethodsSet.has("pay_invoice") || + requestMethodsSet.has("pay_keysend") || + requestMethodsSet.has("multi_pay_invoice") || + requestMethodsSet.has("multi_pay_keysend") ) { - scopes.add(NIP_47_PAY_INVOICE_METHOD); + scopes.add("pay_invoice"); } - if (requestMethodsSet.has(NIP_47_GET_INFO_METHOD)) { - scopes.add(NIP_47_GET_INFO_METHOD); + if (requestMethodsSet.has("get_info")) { + scopes.add("get_info"); } - if (requestMethodsSet.has(NIP_47_GET_BALANCE_METHOD)) { - scopes.add(NIP_47_GET_BALANCE_METHOD); + if (requestMethodsSet.has("get_balance")) { + scopes.add("get_balance"); } - if (requestMethodsSet.has(NIP_47_MAKE_INVOICE_METHOD)) { - scopes.add(NIP_47_MAKE_INVOICE_METHOD); + if (requestMethodsSet.has("make_invoice")) { + scopes.add("make_invoice"); } - if (requestMethodsSet.has(NIP_47_LOOKUP_INVOICE_METHOD)) { - scopes.add(NIP_47_LOOKUP_INVOICE_METHOD); + if (requestMethodsSet.has("lookup_invoice")) { + scopes.add("lookup_invoice"); } - if (requestMethodsSet.has(NIP_47_LIST_TRANSACTIONS_METHOD)) { - scopes.add(NIP_47_LIST_TRANSACTIONS_METHOD); + if (requestMethodsSet.has("list_transactions")) { + scopes.add("list_transactions"); } - if (requestMethodsSet.has(NIP_47_SIGN_MESSAGE_METHOD)) { - scopes.add(NIP_47_SIGN_MESSAGE_METHOD); + if (requestMethodsSet.has("sign_message")) { + scopes.add("sign_message"); } if (notificationTypes.length) { - scopes.add(NIP_47_NOTIFICATIONS_PERMISSION); + scopes.add("notifications"); } return scopes; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index ab73dc20..20c5e269 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,36 +1,15 @@ import { - ArrowDownUp, Bell, CirclePlus, HandCoins, Info, LucideIcon, - MoveDown, NotebookTabs, PenLine, Search, - SquarePen, WalletMinimal, } from "lucide-react"; -export const NIP_47_PAY_INVOICE_METHOD = "pay_invoice"; -export const NIP_47_GET_BALANCE_METHOD = "get_balance"; -export const NIP_47_GET_INFO_METHOD = "get_info"; -export const NIP_47_MAKE_INVOICE_METHOD = "make_invoice"; -export const NIP_47_LOOKUP_INVOICE_METHOD = "lookup_invoice"; -export const NIP_47_LIST_TRANSACTIONS_METHOD = "list_transactions"; -export const NIP_47_SIGN_MESSAGE_METHOD = "sign_message"; - -export const NIP_47_NOTIFICATIONS_PERMISSION = "notifications"; - -export const NIP_47_PAY_KEYSEND_METHOD = "pay_keysend"; -export const NIP_47_MULTI_PAY_KEYSEND_METHOD = "multi_pay_keysend"; -export const NIP_47_MULTI_PAY_INVOICE_METHOD = "multi_pay_invoice"; - -export const SCOPE_GROUP_FULL_ACCESS = "full_access"; -export const SCOPE_GROUP_READ_ONLY = "read_only"; -export const SCOPE_GROUP_CUSTOM = "custom"; - export type BackendType = | "LND" | "BREEZ" @@ -69,40 +48,21 @@ export type Scope = | "sign_message" | "notifications"; // covers all notification types -export type ReadOnlyScope = - | "get_balance" - | "get_info" - | "lookup_invoice" - | "list_transactions" - | "notifications"; - -export type ScopeGroup = "full_access" | "read_only" | "custom"; - export type Nip47NotificationType = "payment_received" | "payment_sent"; export type ScopeIconMap = { [key in Scope]: LucideIcon; }; -export type ScopeGroupIconMap = { - [key in ScopeGroup]: LucideIcon; -}; - export const scopeIconMap: ScopeIconMap = { - [NIP_47_GET_BALANCE_METHOD]: WalletMinimal, - [NIP_47_GET_INFO_METHOD]: Info, - [NIP_47_LIST_TRANSACTIONS_METHOD]: NotebookTabs, - [NIP_47_LOOKUP_INVOICE_METHOD]: Search, - [NIP_47_MAKE_INVOICE_METHOD]: CirclePlus, - [NIP_47_PAY_INVOICE_METHOD]: HandCoins, - [NIP_47_SIGN_MESSAGE_METHOD]: PenLine, - [NIP_47_NOTIFICATIONS_PERMISSION]: Bell, -}; - -export const scopeGroupIconMap: ScopeGroupIconMap = { - [SCOPE_GROUP_FULL_ACCESS]: ArrowDownUp, - [SCOPE_GROUP_READ_ONLY]: MoveDown, - [SCOPE_GROUP_CUSTOM]: SquarePen, + get_balance: WalletMinimal, + get_info: Info, + list_transactions: NotebookTabs, + lookup_invoice: Search, + make_invoice: CirclePlus, + pay_invoice: HandCoins, + sign_message: PenLine, + notifications: Bell, }; export type WalletCapabilities = { @@ -120,26 +80,14 @@ export const validBudgetRenewals: BudgetRenewalType[] = [ ]; export const scopeDescriptions: Record = { - [NIP_47_GET_BALANCE_METHOD]: "Read your balance", - [NIP_47_GET_INFO_METHOD]: "Read your node info", - [NIP_47_LIST_TRANSACTIONS_METHOD]: "Read transaction history", - [NIP_47_LOOKUP_INVOICE_METHOD]: "Lookup status of invoices", - [NIP_47_MAKE_INVOICE_METHOD]: "Create invoices", - [NIP_47_PAY_INVOICE_METHOD]: "Send payments", - [NIP_47_SIGN_MESSAGE_METHOD]: "Sign messages", - [NIP_47_NOTIFICATIONS_PERMISSION]: "Receive wallet notifications", -}; - -export const scopeGroupTitle: Record = { - [SCOPE_GROUP_FULL_ACCESS]: "Full Access", - [SCOPE_GROUP_READ_ONLY]: "Read Only", - [SCOPE_GROUP_CUSTOM]: "Custom", -}; - -export const scopeGroupDescriptions: Record = { - [SCOPE_GROUP_FULL_ACCESS]: "Complete wallet control", - [SCOPE_GROUP_READ_ONLY]: "Only view wallet info", - [SCOPE_GROUP_CUSTOM]: "Define permissions", + get_balance: "Read your balance", + get_info: "Read your node info", + list_transactions: "Read transaction history", + lookup_invoice: "Lookup status of invoices", + make_invoice: "Create invoices", + pay_invoice: "Send payments", + sign_message: "Sign messages", + notifications: "Receive wallet notifications", }; export const expiryOptions: Record = { From 0aa4a9c95946ca3494d0d92f527c473af072a461 Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Sun, 14 Jul 2024 16:29:44 +0700 Subject: [PATCH 32/33] feat: basic isolated apps UI --- README.md | 1 + alby/alby_oauth_service.go | 1 + api/api.go | 19 +- api/models.go | 3 + db/db_service.go | 16 +- db/models.go | 2 +- frontend/src/components/Permissions.tsx | 184 +++++++++--------- frontend/src/components/Scopes.tsx | 39 +++- .../connections/AppCardConnectionInfo.tsx | 28 ++- frontend/src/screens/apps/NewApp.tsx | 3 + frontend/src/screens/apps/ShowApp.tsx | 127 ++++++------ frontend/src/types.ts | 4 + 12 files changed, 267 insertions(+), 160 deletions(-) diff --git a/README.md b/README.md index 5554dfe0..18d8ebc1 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,7 @@ If the client creates the secret the client only needs to share the public key o - `budget_renewal` (optional) reset the budget at the end of the given budget renewal. Can be `never` (default), `daily`, `weekly`, `monthly`, `yearly` - `request_methods` (optional) url encoded, space separated list of request types that you need permission for: `pay_invoice` (default), `get_balance` (see NIP47). For example: `..&request_methods=pay_invoice%20get_balance` - `notification_types` (optional) url encoded, space separated list of notification types that you need permission for: For example: `..¬ification_types=payment_received%20payment_sent` +- `isolated` (optional) makes an isolated app connection with its own balance and only access to its own transaction list. e.g. `&isolated=true` Example: diff --git a/alby/alby_oauth_service.go b/alby/alby_oauth_service.go index 26cc2d16..36e6871c 100644 --- a/alby/alby_oauth_service.go +++ b/alby/alby_oauth_service.go @@ -405,6 +405,7 @@ func (svc *albyOAuthService) LinkAccount(ctx context.Context, lnClient lnclient. renewal, nil, scopes, + false, ) if err != nil { diff --git a/api/api.go b/api/api.go index 9f79da7c..c2472f9a 100644 --- a/api/api.go +++ b/api/api.go @@ -68,7 +68,14 @@ func (api *api) CreateApp(createAppRequest *CreateAppRequest) (*CreateAppRespons } } - app, pairingSecretKey, err := api.dbSvc.CreateApp(createAppRequest.Name, createAppRequest.Pubkey, createAppRequest.MaxAmountSat, createAppRequest.BudgetRenewal, expiresAt, createAppRequest.Scopes) + app, pairingSecretKey, err := api.dbSvc.CreateApp( + createAppRequest.Name, + createAppRequest.Pubkey, + createAppRequest.MaxAmountSat, + createAppRequest.BudgetRenewal, + expiresAt, + createAppRequest.Scopes, + createAppRequest.Isolated) if err != nil { return nil, err @@ -212,6 +219,11 @@ func (api *api) GetApp(dbApp *db.App) *App { Scopes: requestMethods, BudgetUsage: budgetUsage, BudgetRenewal: paySpecificPermission.BudgetRenewal, + Isolated: dbApp.Isolated, + } + + if dbApp.Isolated { + response.Balance = queries.GetIsolatedBalance(api.db, dbApp.ID) } if lastEventResult.RowsAffected > 0 { @@ -244,6 +256,11 @@ func (api *api) ListApps() ([]App, error) { CreatedAt: dbApp.CreatedAt, UpdatedAt: dbApp.UpdatedAt, NostrPubkey: dbApp.NostrPubkey, + Isolated: dbApp.Isolated, + } + + if dbApp.Isolated { + apiApp.Balance = queries.GetIsolatedBalance(api.db, dbApp.ID) } for _, appPermission := range permissionsMap[dbApp.ID] { diff --git a/api/models.go b/api/models.go index bec349c9..1b98f962 100644 --- a/api/models.go +++ b/api/models.go @@ -68,6 +68,8 @@ type App struct { MaxAmountSat uint64 `json:"maxAmount"` BudgetUsage uint64 `json:"budgetUsage"` BudgetRenewal string `json:"budgetRenewal"` + Isolated bool `json:"isolated"` + Balance uint64 `json:"balance"` } type ListAppsResponse struct { @@ -89,6 +91,7 @@ type CreateAppRequest struct { ExpiresAt string `json:"expiresAt"` Scopes []string `json:"scopes"` ReturnTo string `json:"returnTo"` + Isolated bool `json:"isolated"` } type StartRequest struct { diff --git a/db/db_service.go b/db/db_service.go index 63b163e5..9cca9c72 100644 --- a/db/db_service.go +++ b/db/db_service.go @@ -2,9 +2,12 @@ package db import ( "encoding/hex" + "errors" "fmt" + "slices" "time" + "github.com/getAlby/hub/constants" "github.com/getAlby/hub/events" "github.com/getAlby/hub/logger" "github.com/nbd-wtf/go-nostr" @@ -23,7 +26,16 @@ func NewDBService(db *gorm.DB, eventPublisher events.EventPublisher) *dbService } } -func (svc *dbService) CreateApp(name string, pubkey string, maxAmountSat uint64, budgetRenewal string, expiresAt *time.Time, scopes []string) (*App, string, error) { +func (svc *dbService) CreateApp(name string, pubkey string, maxAmountSat uint64, budgetRenewal string, expiresAt *time.Time, scopes []string, isolated bool) (*App, string, error) { + if isolated && (slices.Contains(scopes, constants.GET_INFO_SCOPE)) { + // cannot return node info because the isolated app is a custodial subaccount + return nil, "", errors.New("Isolated app cannot have get_info scope") + } + if isolated && (slices.Contains(scopes, constants.SIGN_MESSAGE_SCOPE)) { + // cannot sign messages because the isolated app is a custodial subaccount + return nil, "", errors.New("Isolated app cannot have sign_message scope") + } + var pairingPublicKey string var pairingSecretKey string if pubkey == "" { @@ -39,7 +51,7 @@ func (svc *dbService) CreateApp(name string, pubkey string, maxAmountSat uint64, } } - app := App{Name: name, NostrPubkey: pairingPublicKey} + app := App{Name: name, NostrPubkey: pairingPublicKey, Isolated: isolated} err := svc.db.Transaction(func(tx *gorm.DB) error { err := tx.Save(&app).Error diff --git a/db/models.go b/db/models.go index fb002319..727cd23f 100644 --- a/db/models.go +++ b/db/models.go @@ -79,7 +79,7 @@ type Transaction struct { } type DBService interface { - CreateApp(name string, pubkey string, maxAmountSat uint64, budgetRenewal string, expiresAt *time.Time, scopes []string) (*App, string, error) + CreateApp(name string, pubkey string, maxAmountSat uint64, budgetRenewal string, expiresAt *time.Time, scopes []string, isolated bool) (*App, string, error) } const ( diff --git a/frontend/src/components/Permissions.tsx b/frontend/src/components/Permissions.tsx index e5a2f5f5..3c802177 100644 --- a/frontend/src/components/Permissions.tsx +++ b/frontend/src/components/Permissions.tsx @@ -65,10 +65,12 @@ const Permissions: React.FC = ({ }, [canEditPermissions, isNewConnection]); const [showBudgetOptions, setShowBudgetOptions] = React.useState( - permissions.scopes.has("pay_invoice") + //permissions.scopes.has("pay_invoice") + false ); const [showExpiryOptions, setShowExpiryOptions] = React.useState( - isNewConnection ? !!permissions.expiresAt : true + false + // isNewConnection ? !!permissions.expiresAt : true ); const handlePermissionsChange = React.useCallback( @@ -80,9 +82,9 @@ const Permissions: React.FC = ({ [permissions, onPermissionsChange] ); - const handleScopeChange = React.useCallback( - (scopes: Set) => { - handlePermissionsChange({ scopes }); + const onScopesChanged = React.useCallback( + (scopes: Set, isolated: boolean) => { + handlePermissionsChange({ scopes, isolated }); }, [handlePermissionsChange] ); @@ -114,7 +116,8 @@ const Permissions: React.FC = ({ ) : ( <> @@ -138,91 +141,98 @@ const Permissions: React.FC = ({
)} - {permissions.scopes.has("pay_invoice") && - (!isBudgetAmountEditable ? ( -
-
-

- - Budget Renewal: - {" "} - {permissions.budgetRenewal || "Never"} -

-

- - Budget Amount: - {" "} - {permissions.maxAmount - ? new Intl.NumberFormat().format(permissions.maxAmount) - : "∞"} - {" sats "} - {!isNewConnection && - `(${new Intl.NumberFormat().format(budgetUsage || 0)} sats used)`} -

-
-
- ) : ( - <> - {!showBudgetOptions && ( - - )} - {showBudgetOptions && ( - <> - - - - )} - - ))} - - {!isExpiryEditable ? ( + {!permissions.isolated && permissions.scopes.has("pay_invoice") && ( <> -

Connection expiry

-

- {permissions.expiresAt && - new Date(permissions.expiresAt).getFullYear() !== 1 - ? new Date(permissions.expiresAt).toString() - : "This app will never expire"} -

+ {!isBudgetAmountEditable ? ( +
+
+

+ + Budget Renewal: + {" "} + {permissions.budgetRenewal || "Never"} +

+

+ + Budget Amount: + {" "} + {permissions.maxAmount + ? new Intl.NumberFormat().format(permissions.maxAmount) + : "∞"} + {" sats "} + {!isNewConnection && + `(${new Intl.NumberFormat().format(budgetUsage || 0)} sats used)`} +

+
+
+ ) : ( + <> + {!showBudgetOptions && ( + + )} + {showBudgetOptions && ( + <> + + + + )} + + )} - ) : ( + )} + + {!permissions.isolated && ( <> - {!showExpiryOptions && ( - - )} + {!isExpiryEditable ? ( + <> +

Connection expiry

+

+ {permissions.expiresAt && + new Date(permissions.expiresAt).getFullYear() !== 1 + ? new Date(permissions.expiresAt).toString() + : "This app will never expire"} +

+ + ) : ( + <> + {!showExpiryOptions && ( + + )} - {showExpiryOptions && ( - + {showExpiryOptions && ( + + )} + )} )} diff --git a/frontend/src/components/Scopes.tsx b/frontend/src/components/Scopes.tsx index 7fda55c1..82736d66 100644 --- a/frontend/src/components/Scopes.tsx +++ b/frontend/src/components/Scopes.tsx @@ -40,14 +40,17 @@ const scopeGroupDescriptions: Record = { interface ScopesProps { capabilities: WalletCapabilities; scopes: Set; - onScopesChanged: (scopes: Set) => void; + isolated: boolean; + onScopesChanged: (scopes: Set, isolated: boolean) => void; } const Scopes: React.FC = ({ capabilities, scopes, + isolated, onScopesChanged, }) => { + // TODO: remove the use of Set here - it is unnecessary and complicates the code const fullAccessScopes: Set = React.useMemo(() => { return new Set(capabilities.scopes); }, [capabilities.scopes]); @@ -62,11 +65,30 @@ const Scopes: React.FC = ({ "notifications", ]; - const scopes: Scope[] = capabilities.scopes; - return new Set(scopes.filter((scope) => readOnlyScopes.includes(scope))); + return new Set( + capabilities.scopes.filter((scope) => readOnlyScopes.includes(scope)) + ); + }, [capabilities.scopes]); + + const isolatedScopes: Set = React.useMemo(() => { + const isolatedScopes: Scope[] = [ + "pay_invoice", + "get_balance", + "make_invoice", + "lookup_invoice", + "list_transactions", + "notifications", + ]; + + return new Set( + capabilities.scopes.filter((scope) => isolatedScopes.includes(scope)) + ); }, [capabilities.scopes]); const [scopeGroup, setScopeGroup] = React.useState(() => { + if (isolated) { + return "isolated"; + } if (!scopes.size) { return "full_access"; } @@ -77,13 +99,16 @@ const Scopes: React.FC = ({ setScopeGroup(scopeGroup); switch (scopeGroup) { case "full_access": - onScopesChanged(fullAccessScopes); + onScopesChanged(fullAccessScopes, false); break; case "read_only": - onScopesChanged(readOnlyScopes); + onScopesChanged(readOnlyScopes, false); + break; + case "isolated": + onScopesChanged(isolatedScopes, true); break; default: { - onScopesChanged(new Set()); + onScopesChanged(new Set(), false); break; } } @@ -96,7 +121,7 @@ const Scopes: React.FC = ({ } else { newScopes.add(scope); } - onScopesChanged(newScopes); + onScopesChanged(newScopes, false); }; return ( diff --git a/frontend/src/components/connections/AppCardConnectionInfo.tsx b/frontend/src/components/connections/AppCardConnectionInfo.tsx index 64a4aab3..2e9c5e31 100644 --- a/frontend/src/components/connections/AppCardConnectionInfo.tsx +++ b/frontend/src/components/connections/AppCardConnectionInfo.tsx @@ -1,5 +1,5 @@ import dayjs from "dayjs"; -import { CircleCheck, PlusCircle } from "lucide-react"; +import { BrickWall, CircleCheck, PlusCircle } from "lucide-react"; import { Link } from "react-router-dom"; import { Button } from "src/components/ui/button"; import { Progress } from "src/components/ui/progress"; @@ -32,7 +32,31 @@ export function AppCardConnectionInfo({ return ( <> - {connection.maxAmount > 0 ? ( + {connection.isolated ? ( + <> +
+
+ + Isolated +
+
+
+
+ {connection.lastEventAt && ( +

+ Last used: {dayjs(connection.lastEventAt).fromNow()} +

+ )} +
+
+

Balance

+

+ {formatAmount(connection.balance)} sats +

+
+
+ + ) : connection.maxAmount > 0 ? ( <>
diff --git a/frontend/src/screens/apps/NewApp.tsx b/frontend/src/screens/apps/NewApp.tsx index 1eedbfbf..3f0e0f5c 100644 --- a/frontend/src/screens/apps/NewApp.tsx +++ b/frontend/src/screens/apps/NewApp.tsx @@ -63,6 +63,7 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => { "budget_renewal" ) as BudgetRenewalType; const budgetMaxAmountParam = queryParams.get("max_amount") ?? ""; + const isolatedParam = queryParams.get("isolated") ?? ""; const expiresAtParam = queryParams.get("expires_at") ?? ""; const reqMethodsParam = queryParams.get("request_methods") ?? ""; @@ -161,6 +162,7 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => { ? budgetRenewalParam : "monthly", expiresAt: parseExpiresParam(expiresAtParam), + isolated: isolatedParam === "true", }); const handleSubmit = async (event: React.FormEvent) => { @@ -183,6 +185,7 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => { scopes: Array.from(permissions.scopes), expiresAt: permissions.expiresAt?.toISOString(), returnTo: returnTo, + isolated: permissions.isolated, }; const createAppResponse = await request("/api/apps", { diff --git a/frontend/src/screens/apps/ShowApp.tsx b/frontend/src/screens/apps/ShowApp.tsx index 2ccb6086..7ddbf1e6 100644 --- a/frontend/src/screens/apps/ShowApp.tsx +++ b/frontend/src/screens/apps/ShowApp.tsx @@ -5,12 +5,7 @@ import { useApp } from "src/hooks/useApp"; import { useCSRF } from "src/hooks/useCSRF"; import { useDeleteApp } from "src/hooks/useDeleteApp"; import { useInfo } from "src/hooks/useInfo"; -import { - AppPermissions, - BudgetRenewalType, - Scope, - UpdateAppRequest, -} from "src/types"; +import { AppPermissions, BudgetRenewalType, UpdateAppRequest } from "src/types"; import { handleRequestError } from "src/utils/handleRequestError"; import { request } from "src/utils/request"; // build the project for this to appear @@ -40,6 +35,7 @@ import { import { Table, TableBody, TableCell, TableRow } from "src/components/ui/table"; import { useToast } from "src/components/ui/use-toast"; import { useCapabilities } from "src/hooks/useCapabilities"; +import { formatAmount } from "src/lib/utils"; function ShowApp() { const { data: info } = useInfo(); @@ -72,6 +68,7 @@ function ShowApp() { maxAmount: app.maxAmount, budgetRenewal: app.budgetRenewal as BudgetRenewalType, expiresAt: app.expiresAt ? new Date(app.expiresAt) : undefined, + isolated: app.isolated, }); } }, [app]); @@ -170,6 +167,14 @@ function ShowApp() { {app.nostrPubkey} + {app.isolated && ( + + Balance + + {formatAmount(app.balance)} sats + + + )} Last used @@ -192,63 +197,65 @@ function ShowApp() { - - - -
- Permissions -
- {editMode && ( -
- + {!app.isolated && ( + + + +
+ Permissions +
+ {editMode && ( +
+ - -
- )} + +
+ )} - {!editMode && ( - <> - - - )} + {!editMode && ( + <> + + + )} +
-
- - - - - - + + + + + + + )}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a6a598da..ffb07e52 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -118,6 +118,8 @@ export interface App { updatedAt: string; lastEventAt?: string; expiresAt?: string; + isolated: boolean; + balance: number; scopes: Scope[]; maxAmount: number; @@ -130,6 +132,7 @@ export interface AppPermissions { maxAmount: number; budgetRenewal: BudgetRenewalType; expiresAt?: Date; + isolated: boolean; } export interface InfoResponse { @@ -160,6 +163,7 @@ export interface CreateAppRequest { expiresAt: string | undefined; scopes: Scope[]; returnTo: string; + isolated: boolean; } export interface CreateAppResponse { From 9bea336b78b88e8980b17b58504e17fbf895ae00 Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Sun, 14 Jul 2024 23:51:20 +0700 Subject: [PATCH 33/33] fix: new app connection, edit app connection, deep linking --- README.md | 2 +- .../src/components/BudgetRenewalSelect.tsx | 12 +- frontend/src/components/ExpirySelect.tsx | 24 +-- frontend/src/components/Permissions.tsx | 166 ++++++++---------- frontend/src/components/Scopes.tsx | 50 ++++-- frontend/src/screens/apps/NewApp.tsx | 53 +++--- frontend/src/screens/apps/ShowApp.tsx | 90 +++++----- frontend/src/types.ts | 2 +- 8 files changed, 210 insertions(+), 189 deletions(-) diff --git a/README.md b/README.md index 18d8ebc1..0b28e003 100644 --- a/README.md +++ b/README.md @@ -243,7 +243,7 @@ If the client creates the secret the client only needs to share the public key o - `budget_renewal` (optional) reset the budget at the end of the given budget renewal. Can be `never` (default), `daily`, `weekly`, `monthly`, `yearly` - `request_methods` (optional) url encoded, space separated list of request types that you need permission for: `pay_invoice` (default), `get_balance` (see NIP47). For example: `..&request_methods=pay_invoice%20get_balance` - `notification_types` (optional) url encoded, space separated list of notification types that you need permission for: For example: `..¬ification_types=payment_received%20payment_sent` -- `isolated` (optional) makes an isolated app connection with its own balance and only access to its own transaction list. e.g. `&isolated=true` +- `isolated` (optional) makes an isolated app connection with its own balance and only access to its own transaction list. e.g. `&isolated=true`. If using this option, you should not pass any custom request methods or notification types, nor set a budget or expiry. Example: diff --git a/frontend/src/components/BudgetRenewalSelect.tsx b/frontend/src/components/BudgetRenewalSelect.tsx index 182c4a8c..12842cef 100644 --- a/frontend/src/components/BudgetRenewalSelect.tsx +++ b/frontend/src/components/BudgetRenewalSelect.tsx @@ -13,11 +13,13 @@ import { BudgetRenewalType, validBudgetRenewals } from "src/types"; interface BudgetRenewalProps { value: BudgetRenewalType; onChange: (value: BudgetRenewalType) => void; + onClose?: () => void; } const BudgetRenewalSelect: React.FC = ({ value, onChange, + onClose, }) => { return ( <> @@ -36,10 +38,12 @@ const BudgetRenewalSelect: React.FC = ({ ))} - onChange("never")} - /> + {onClose && ( + + )}
diff --git a/frontend/src/components/ExpirySelect.tsx b/frontend/src/components/ExpirySelect.tsx index 37160657..a0ccd10e 100644 --- a/frontend/src/components/ExpirySelect.tsx +++ b/frontend/src/components/ExpirySelect.tsx @@ -12,7 +12,7 @@ import { expiryOptions } from "src/types"; const daysFromNow = (date?: Date) => { if (!date) { - return 0; + return undefined; } const now = dayjs(); const targetDate = dayjs(date); @@ -26,11 +26,14 @@ interface ExpiryProps { const ExpirySelect: React.FC = ({ value, onChange }) => { const [expiryDays, setExpiryDays] = React.useState(daysFromNow(value)); - const [customExpiry, setCustomExpiry] = React.useState( - daysFromNow(value) - ? !Object.values(expiryOptions).includes(daysFromNow(value)) - : false - ); + const [customExpiry, setCustomExpiry] = React.useState(() => { + const _daysFromNow = daysFromNow(value); + return _daysFromNow !== undefined + ? !Object.values(expiryOptions) + .filter((value) => value !== 0) + .includes(_daysFromNow) + : false; + }); return ( <>

Connection expiration

@@ -41,11 +44,12 @@ const ExpirySelect: React.FC = ({ value, onChange }) => { key={expiry} onClick={() => { setCustomExpiry(false); - let date; + let date: Date | undefined; if (expiryOptions[expiry]) { - date = new Date(); - date.setDate(date.getUTCDate() + expiryOptions[expiry]); - date.setHours(23, 59, 59); + date = dayjs() + .add(expiryOptions[expiry], "day") + .endOf("day") + .toDate(); } onChange(date); setExpiryDays(expiryOptions[expiry]); diff --git a/frontend/src/components/Permissions.tsx b/frontend/src/components/Permissions.tsx index 3c802177..1b525892 100644 --- a/frontend/src/components/Permissions.tsx +++ b/frontend/src/components/Permissions.tsx @@ -17,73 +17,46 @@ import { interface PermissionsProps { capabilities: WalletCapabilities; - initialPermissions: AppPermissions; - onPermissionsChange: (permissions: AppPermissions) => void; - canEditPermissions?: boolean; + permissions: AppPermissions; + setPermissions: React.Dispatch>; + readOnly?: boolean; + scopesReadOnly?: boolean; + budgetReadOnly?: boolean; + expiresAtReadOnly?: boolean; budgetUsage?: number; - isNewConnection?: boolean; + isNewConnection: boolean; } const Permissions: React.FC = ({ capabilities, - initialPermissions, - onPermissionsChange, - canEditPermissions, + permissions, + setPermissions, isNewConnection, budgetUsage, + readOnly, + scopesReadOnly, + budgetReadOnly, + expiresAtReadOnly, }) => { - const [permissions, setPermissions] = React.useState(initialPermissions); - - const [isScopesEditable, setScopesEditable] = React.useState( - isNewConnection ? !initialPermissions.scopes.size : canEditPermissions - ); - const [isBudgetAmountEditable, setBudgetAmountEditable] = React.useState( - isNewConnection - ? Number.isNaN(initialPermissions.maxAmount) - : canEditPermissions - ); - const [isExpiryEditable, setExpiryEditable] = React.useState( - isNewConnection ? !initialPermissions.expiresAt : canEditPermissions - ); - - // triggered when changes are saved in show app - React.useEffect(() => { - if (isNewConnection || canEditPermissions) { - return; - } - setPermissions(initialPermissions); - }, [canEditPermissions, isNewConnection, initialPermissions]); - - // triggered when edit mode is toggled in show app - React.useEffect(() => { - if (isNewConnection) { - return; - } - setScopesEditable(canEditPermissions); - setBudgetAmountEditable(canEditPermissions); - setExpiryEditable(canEditPermissions); - }, [canEditPermissions, isNewConnection]); - const [showBudgetOptions, setShowBudgetOptions] = React.useState( - //permissions.scopes.has("pay_invoice") - false + permissions.scopes.includes("pay_invoice") && permissions.maxAmount > 0 ); const [showExpiryOptions, setShowExpiryOptions] = React.useState( - false - // isNewConnection ? !!permissions.expiresAt : true + !!permissions.expiresAt ); const handlePermissionsChange = React.useCallback( (changedPermissions: Partial) => { - const updatedPermissions = { ...permissions, ...changedPermissions }; - setPermissions(updatedPermissions); - onPermissionsChange(updatedPermissions); + setPermissions((currentPermissions) => ({ + ...currentPermissions, + ...changedPermissions, + })); }, - [permissions, onPermissionsChange] + [setPermissions] ); const onScopesChanged = React.useCallback( - (scopes: Set, isolated: boolean) => { + (scopes: Scope[], isolated: boolean) => { handlePermissionsChange({ scopes, isolated }); }, [handlePermissionsChange] @@ -97,8 +70,8 @@ const Permissions: React.FC = ({ ); const handleBudgetRenewalChange = React.useCallback( - (value: string) => { - handlePermissionsChange({ budgetRenewal: value as BudgetRenewalType }); + (budgetRenewal: BudgetRenewalType) => { + handlePermissionsChange({ budgetRenewal }); }, [handlePermissionsChange] ); @@ -112,13 +85,21 @@ const Permissions: React.FC = ({ return (
- {isScopesEditable ? ( + {!readOnly && !scopesReadOnly ? ( + ) : 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

@@ -141,44 +122,21 @@ const Permissions: React.FC = ({
)} - {!permissions.isolated && permissions.scopes.has("pay_invoice") && ( + + {!permissions.isolated && permissions.scopes.includes("pay_invoice") && ( <> - {!isBudgetAmountEditable ? ( -
-
-

- - Budget Renewal: - {" "} - {permissions.budgetRenewal || "Never"} -

-

- - Budget Amount: - {" "} - {permissions.maxAmount - ? new Intl.NumberFormat().format(permissions.maxAmount) - : "∞"} - {" sats "} - {!isNewConnection && - `(${new Intl.NumberFormat().format(budgetUsage || 0)} sats used)`} -

-
-
- ) : ( + {!readOnly && !budgetReadOnly ? ( <> {!showBudgetOptions && (