diff --git a/api/api.go b/api/api.go index 7aa7e431..5cf3d84c 100644 --- a/api/api.go +++ b/api/api.go @@ -777,7 +777,6 @@ func (api *api) parseExpiresAt(expiresAtString string) (*time.Time, error) { logger.Logger.WithField("expiresAt", expiresAtString).Error("Invalid expiresAt") return nil, fmt.Errorf("invalid expiresAt: %v", err) } - expiresAtValue = time.Date(expiresAtValue.Year(), expiresAtValue.Month(), expiresAtValue.Day(), 23, 59, 59, 0, expiresAtValue.Location()) expiresAt = &expiresAtValue } return expiresAt, nil diff --git a/frontend/package.json b/frontend/package.json index cc46a5d2..2354ae04 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/BudgetAmountSelect.tsx b/frontend/src/components/BudgetAmountSelect.tsx index 4f598175..1e3d77df 100644 --- a/frontend/src/components/BudgetAmountSelect.tsx +++ b/frontend/src/components/BudgetAmountSelect.tsx @@ -1,31 +1,74 @@ +import React from "react"; +import { Input } from "src/components/ui/input"; +import { Label } from "src/components/ui/label"; +import { cn } from "src/lib/utils"; import { budgetOptions } from "src/types"; function BudgetAmountSelect({ value, onChange, }: { - value?: number; + value: number; onChange: (value: number) => void; }) { + const [customBudget, setCustomBudget] = React.useState( + value ? !Object.values(budgetOptions).includes(value) : false + ); return ( -
- {Object.keys(budgetOptions).map((budget) => { - const amount = budgetOptions[budget]; - return ( -
onChange(amount)} - className={`col-span-2 md:col-span-1 cursor-pointer rounded border-2 ${ - value === amount ? "border-primary" : "border-muted" - } text-center py-4`} - > - {budget} -
- {amount ? "sats" : "#reckless"} -
- ); - })} -
+ <> +
+ {Object.keys(budgetOptions).map((budget) => { + return ( +
{ + setCustomBudget(false); + onChange(budgetOptions[budget]); + }} + className={cn( + "cursor-pointer rounded text-nowrap border-2 text-center p-4", + !customBudget && value == budgetOptions[budget] + ? "border-primary" + : "border-muted" + )} + > + {`${budget} ${budgetOptions[budget] ? " sats" : ""}`} +
+ ); + })} +
{ + setCustomBudget(true); + onChange(0); + }} + className={cn( + "cursor-pointer rounded border-2 text-center p-4 dark:text-white", + customBudget ? "border-primary" : "border-muted" + )} + > + Custom... +
+
+ {customBudget && ( +
+ + { + onChange(parseInt(e.target.value)); + }} + /> +
+ )} + ); } diff --git a/frontend/src/components/BudgetRenewalSelect.tsx b/frontend/src/components/BudgetRenewalSelect.tsx index b058fd38..182c4a8c 100644 --- a/frontend/src/components/BudgetRenewalSelect.tsx +++ b/frontend/src/components/BudgetRenewalSelect.tsx @@ -1,4 +1,6 @@ +import { XIcon } from "lucide-react"; import React from "react"; +import { Label } from "src/components/ui/label"; import { Select, SelectContent, @@ -11,27 +13,36 @@ import { BudgetRenewalType, validBudgetRenewals } from "src/types"; interface BudgetRenewalProps { value: BudgetRenewalType; onChange: (value: BudgetRenewalType) => void; - disabled?: boolean; } const BudgetRenewalSelect: React.FC = ({ value, onChange, - disabled, }) => { return ( - + <> + +
+ +
+ ); }; diff --git a/frontend/src/components/ExpirySelect.tsx b/frontend/src/components/ExpirySelect.tsx new file mode 100644 index 00000000..37160657 --- /dev/null +++ b/frontend/src/components/ExpirySelect.tsx @@ -0,0 +1,106 @@ +import dayjs from "dayjs"; +import { CalendarIcon } from "lucide-react"; +import React from "react"; +import { Calendar } from "src/components/ui/calendar"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "src/components/ui/popover"; +import { cn } from "src/lib/utils"; +import { expiryOptions } from "src/types"; + +const daysFromNow = (date?: Date) => { + if (!date) { + return 0; + } + const now = dayjs(); + const targetDate = dayjs(date); + return targetDate.diff(now, "day"); +}; + +interface ExpiryProps { + value?: Date | undefined; + onChange: (expiryDate?: Date) => void; +} + +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 + ); + return ( + <> +

Connection expiration

+
+ {Object.keys(expiryOptions).map((expiry) => { + return ( +
{ + setCustomExpiry(false); + let date; + if (expiryOptions[expiry]) { + date = new Date(); + date.setDate(date.getUTCDate() + expiryOptions[expiry]); + date.setHours(23, 59, 59); + } + onChange(date); + setExpiryDays(expiryOptions[expiry]); + }} + className={cn( + "cursor-pointer rounded text-nowrap border-2 text-center p-4", + !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 p-4", + customExpiry ? "border-primary" : "border-muted" + )} + > + + + {customExpiry && value + ? dayjs(value).format("DD MMMM YYYY") + : "Custom..."} + +
+
+ + { + if (!date) { + return; + } + date.setHours(23, 59, 59); + setCustomExpiry(true); + onChange(date); + setExpiryDays(daysFromNow(date)); + }} + initialFocus + /> + +
+
+ + ); +}; + +export default ExpirySelect; diff --git a/frontend/src/components/Permissions.tsx b/frontend/src/components/Permissions.tsx index 2dbbf905..5bcac7c0 100644 --- a/frontend/src/components/Permissions.tsx +++ b/frontend/src/components/Permissions.tsx @@ -1,30 +1,32 @@ import { PlusCircle } from "lucide-react"; -import React, { useEffect, useState } from "react"; +import React from "react"; import BudgetAmountSelect from "src/components/BudgetAmountSelect"; import BudgetRenewalSelect from "src/components/BudgetRenewalSelect"; +import ExpirySelect from "src/components/ExpirySelect"; +import Scopes from "src/components/Scopes"; import { Button } from "src/components/ui/button"; -import { Checkbox } from "src/components/ui/checkbox"; -import { Label } from "src/components/ui/label"; -import { useCapabilities } from "src/hooks/useCapabilities"; import { cn } from "src/lib/utils"; import { AppPermissions, BudgetRenewalType, + NIP_47_PAY_INVOICE_METHOD, Scope, - expiryOptions, - iconMap, + WalletCapabilities, scopeDescriptions, + scopeIconMap, } from "src/types"; interface PermissionsProps { + capabilities: WalletCapabilities; initialPermissions: AppPermissions; onPermissionsChange: (permissions: AppPermissions) => void; + canEditPermissions?: boolean; budgetUsage?: number; - canEditPermissions: boolean; isNewConnection?: boolean; } const Permissions: React.FC = ({ + capabilities, initialPermissions, onPermissionsChange, canEditPermissions, @@ -32,243 +34,199 @@ const Permissions: React.FC = ({ budgetUsage, }) => { const [permissions, setPermissions] = React.useState(initialPermissions); - const [days, setDays] = useState(isNewConnection ? 0 : -1); - const [expireOptions, setExpireOptions] = useState(!isNewConnection); - const { data: capabilities } = useCapabilities(); - useEffect(() => { - setPermissions(initialPermissions); - }, [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 + ); - const handlePermissionsChange = ( - changedPermissions: Partial - ) => { - const updatedPermissions = { ...permissions, ...changedPermissions }; - setPermissions(updatedPermissions); - onPermissionsChange(updatedPermissions); - }; + // triggered when changes are saved in show app + React.useEffect(() => { + if (isNewConnection || canEditPermissions) { + return; + } + setPermissions(initialPermissions); + }, [canEditPermissions, isNewConnection, initialPermissions]); - const handleScopeChange = (scope: Scope) => { - if (!canEditPermissions) { + // triggered when edit mode is toggled in show app + React.useEffect(() => { + if (isNewConnection) { return; } + setScopesEditable(canEditPermissions); + setBudgetAmountEditable(canEditPermissions); + setExpiryEditable(canEditPermissions); + }, [canEditPermissions, isNewConnection]); - let budgetRenewal = permissions.budgetRenewal; + const [showBudgetOptions, setShowBudgetOptions] = React.useState( + isNewConnection ? !!permissions.maxAmount : true + ); + const [showExpiryOptions, setShowExpiryOptions] = React.useState( + isNewConnection ? !!permissions.expiresAt : true + ); - const newScopes = new Set(permissions.scopes); - if (newScopes.has(scope)) { - newScopes.delete(scope); - } else { - newScopes.add(scope); - if (scope === "pay_invoice") { - budgetRenewal = "monthly"; - } - } + const handlePermissionsChange = React.useCallback( + (changedPermissions: Partial) => { + const updatedPermissions = { ...permissions, ...changedPermissions }; + setPermissions(updatedPermissions); + onPermissionsChange(updatedPermissions); + }, + [permissions, onPermissionsChange] + ); - handlePermissionsChange({ - scopes: newScopes, - budgetRenewal, - }); - }; + const handleScopeChange = React.useCallback( + (scopes: Set) => { + handlePermissionsChange({ scopes }); + }, + [handlePermissionsChange] + ); - const handleMaxAmountChange = (amount: number) => { - handlePermissionsChange({ maxAmount: amount }); - }; + const handleBudgetMaxAmountChange = React.useCallback( + (amount: number) => { + handlePermissionsChange({ maxAmount: amount }); + }, + [handlePermissionsChange] + ); - const handleBudgetRenewalChange = (value: string) => { - handlePermissionsChange({ budgetRenewal: value as BudgetRenewalType }); - }; + const handleBudgetRenewalChange = React.useCallback( + (value: string) => { + handlePermissionsChange({ budgetRenewal: value as BudgetRenewalType }); + }, + [handlePermissionsChange] + ); - const handleDaysChange = (days: number) => { - setDays(days); - if (!days) { - 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 handleExpiryChange = React.useCallback( + (expiryDate?: Date) => { + handlePermissionsChange({ expiresAt: expiryDate }); + }, + [handlePermissionsChange] + ); return ( -
-
-
    - {capabilities?.scopes.map((scope, index) => { - const ScopeIcon = iconMap[scope]; - return ( -
  • -
    - {ScopeIcon && ( - +
    + {isScopesEditable ? ( + + ) : ( + <> +

    Scopes

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

    {scopeDescriptions[rm]}

    - {scope == "pay_invoice" && ( -
    - {canEditPermissions ? ( - <> -
    -

    Budget Renewal:

    - {!canEditPermissions ? ( - permissions.budgetRenewal - ) : ( - - )} -
    - - - ) : 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"} -
    - )} -
    + ); + })} +
    + + )} + {capabilities.scopes.includes(NIP_47_PAY_INVOICE_METHOD) && + permissions.scopes.has(NIP_47_PAY_INVOICE_METHOD) && + (!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 && ( +
  • - ); - })} -
-
+ > + + Set budget renewal + + )} + {showBudgetOptions && ( + <> + + + + )} + + ))} - {( - isNewConnection ? !permissions.expiresAt || days : canEditPermissions - ) ? ( + {!isExpiryEditable ? ( + <> +

Connection expiry

+

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

+ + ) : ( <> - {!expireOptions && ( + {!showExpiryOptions && ( )} - {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} -
- ); - })} -
-
+ {showExpiryOptions && ( + )} - ) : ( - <> -

Connection expiry

-

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

- )}
); diff --git a/frontend/src/components/Scopes.tsx b/frontend/src/components/Scopes.tsx new file mode 100644 index 00000000..f0958785 --- /dev/null +++ b/frontend/src/components/Scopes.tsx @@ -0,0 +1,168 @@ +import React, { useEffect } 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"; + +interface ScopesProps { + capabilities: WalletCapabilities; + scopes: Set; + onScopeChange: (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, +}) => { + const fullAccessScopes: Set = React.useMemo(() => { + return new Set(capabilities.scopes); + }, [capabilities.scopes]); + + const readOnlyScopes: Set = React.useMemo(() => { + 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) + ) + ); + }, [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; + } + return SCOPE_GROUP_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); + break; + case SCOPE_GROUP_READ_ONLY: + onScopeChange(readOnlyScopes); + break; + default: { + onScopeChange(new Set()); + break; + } + } + }; + + const handleScopeChange = (scope: Scope) => { + const newScopes = new Set(scopes); + if (newScopes.has(scope)) { + newScopes.delete(scope); + } else { + newScopes.add(scope); + } + onScopeChange(newScopes); + }; + + return ( + <> +
+

Choose wallet permissions

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

{scopeGroupTitle[sg]}

+ + {scopeGroupDescriptions[sg]} + +
+ ); + })} +
+
+ + {scopeGroup == "custom" && ( +
+

Authorize the app to:

+
    + {capabilities.scopes.map((rm, index) => { + return ( +
  • +
    + handleScopeChange(rm)} + checked={scopes.has(rm)} + /> + +
    +
  • + ); + })} +
+
+ )} + + ); +}; + +export default Scopes; diff --git a/frontend/src/components/connections/AlbyConnectionCard.tsx b/frontend/src/components/connections/AlbyConnectionCard.tsx index 4141f9f5..b5d05ccc 100644 --- a/frontend/src/components/connections/AlbyConnectionCard.tsx +++ b/frontend/src/components/connections/AlbyConnectionCard.tsx @@ -32,7 +32,6 @@ import { DialogHeader, DialogTrigger, } from "src/components/ui/dialog"; -import { Label } from "src/components/ui/label"; import { LoadingButton } from "src/components/ui/loading-button"; import { Separator } from "src/components/ui/separator"; import { useAlbyMe } from "src/hooks/useAlbyMe"; @@ -105,17 +104,16 @@ function AlbyConnectionCard({ connection }: { connection?: App }) { You can add a budget that will restrict how much can be spent from the Hub with your Alby Account. -
- +
+
- linkAccount(maxAmount, budgetRenewal)} diff --git a/frontend/src/components/connections/AppCardConnectionInfo.tsx b/frontend/src/components/connections/AppCardConnectionInfo.tsx index 9b1347a5..1aad6f72 100644 --- a/frontend/src/components/connections/AppCardConnectionInfo.tsx +++ b/frontend/src/components/connections/AppCardConnectionInfo.tsx @@ -4,7 +4,12 @@ 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 } from "src/types"; +import { + App, + BudgetRenewalType, + NIP_47_MAKE_INVOICE_METHOD, + NIP_47_PAY_INVOICE_METHOD, +} from "src/types"; type AppCardConnectionInfoProps = { connection: App; @@ -69,7 +74,7 @@ export function AppCardConnectionInfo({
- ) : connection.scopes.indexOf("pay_invoice") > -1 ? ( + ) : connection.scopes.indexOf(NIP_47_PAY_INVOICE_METHOD) > -1 ? ( <>
@@ -97,7 +102,7 @@ export function AppCardConnectionInfo({ Share wallet information
- {connection.scopes.indexOf("make_invoice") > -1 && ( + {connection.scopes.indexOf(NIP_47_MAKE_INVOICE_METHOD) > -1 && (
Create Invoices 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..0af955da 100644 --- a/frontend/src/screens/apps/NewApp.tsx +++ b/frontend/src/screens/apps/NewApp.tsx @@ -7,6 +7,17 @@ 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, @@ -53,27 +64,23 @@ 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; + const methods = reqMethodsParam ? reqMethodsParam.split(" ") : []; const requestMethodsSet = new Set( methods as Nip47RequestMethod[] @@ -90,7 +97,7 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => { const notificationTypes = notificationTypesParam ? notificationTypesParam.split(" ") - : capabilities.notificationTypes; + : []; const notificationTypesSet = new Set( notificationTypes as Nip47NotificationType[] @@ -110,34 +117,34 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => { const scopes = new Set(); if ( - requestMethodsSet.has("pay_keysend") || - requestMethodsSet.has("pay_invoice") || - requestMethodsSet.has("multi_pay_invoice") || - requestMethodsSet.has("multi_pay_keysend") + 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) ) { - scopes.add("pay_invoice"); + scopes.add(NIP_47_PAY_INVOICE_METHOD); } - if (requestMethodsSet.has("get_info")) { - scopes.add("get_info"); + if (requestMethodsSet.has(NIP_47_GET_INFO_METHOD)) { + scopes.add(NIP_47_GET_INFO_METHOD); } - if (requestMethodsSet.has("get_balance")) { - scopes.add("get_balance"); + if (requestMethodsSet.has(NIP_47_GET_BALANCE_METHOD)) { + scopes.add(NIP_47_GET_BALANCE_METHOD); } - if (requestMethodsSet.has("make_invoice")) { - scopes.add("make_invoice"); + if (requestMethodsSet.has(NIP_47_MAKE_INVOICE_METHOD)) { + scopes.add(NIP_47_MAKE_INVOICE_METHOD); } - if (requestMethodsSet.has("lookup_invoice")) { - scopes.add("lookup_invoice"); + if (requestMethodsSet.has(NIP_47_LOOKUP_INVOICE_METHOD)) { + scopes.add(NIP_47_LOOKUP_INVOICE_METHOD); } - if (requestMethodsSet.has("list_transactions")) { - scopes.add("list_transactions"); + if (requestMethodsSet.has(NIP_47_LIST_TRANSACTIONS_METHOD)) { + scopes.add(NIP_47_LIST_TRANSACTIONS_METHOD); } - if (requestMethodsSet.has("sign_message")) { - scopes.add("sign_message"); + if (requestMethodsSet.has(NIP_47_SIGN_MESSAGE_METHOD)) { + scopes.add(NIP_47_SIGN_MESSAGE_METHOD); } if (notificationTypes.length) { - scopes.add("notifications"); + scopes.add(NIP_47_NOTIFICATIONS_PERMISSION); } return scopes; @@ -151,14 +158,16 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => { const parseExpiresParam = (expiresParam: string): Date | undefined => { const expiresParamTimestamp = parseInt(expiresParam); if (!isNaN(expiresParamTimestamp)) { - return new Date(expiresParamTimestamp * 1000); + const expiry = new Date(expiresParamTimestamp * 1000); + expiry.setHours(23, 59, 59); + return expiry; } return undefined; }; const [permissions, setPermissions] = useState({ scopes: initialScopes, - maxAmount: parseInt(maxAmountParam || "100000"), + maxAmount: parseInt(budgetMaxAmountParam), budgetRenewal: validBudgetRenewals.includes(budgetRenewalParam) ? budgetRenewalParam : "monthly", @@ -171,12 +180,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: permissions.maxAmount || 0, scopes: Array.from(permissions.scopes), expiresAt: permissions.expiresAt?.toISOString(), returnTo: returnTo, @@ -254,11 +268,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..2ccb6086 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); @@ -59,12 +61,9 @@ function ShowApp() { navigate("/apps"); }); - const [permissions, setPermissions] = React.useState({ - scopes: new Set(), - maxAmount: 0, - budgetRenewal: "", - expiresAt: undefined, - }); + const [permissions, setPermissions] = React.useState( + null + ); React.useEffect(() => { if (app) { @@ -81,7 +80,7 @@ function ShowApp() { return

{error.message}

; } - if (!app || !info) { + if (!app || !info || !capabilities || !permissions) { return ; } @@ -115,10 +114,6 @@ function ShowApp() { } }; - if (!app) { - return ; - } - return ( <>
@@ -137,7 +132,7 @@ function ShowApp() { } contentRight={ - + @@ -219,7 +214,6 @@ function ShowApp() { : undefined, }); setEditMode(!editMode); - // TODO: clicking cancel and then editing again will leave the days option wrong }} > Cancel @@ -247,10 +241,11 @@ function ShowApp() { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index cd942f40..ab73dc20 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,14 @@ 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" @@ -58,13 +69,26 @@ 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 IconMap = { +export type ScopeIconMap = { [key in Scope]: LucideIcon; }; -export const iconMap: IconMap = { +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, @@ -75,6 +99,12 @@ export const iconMap: IconMap = { [NIP_47_NOTIFICATIONS_PERMISSION]: Bell, }; +export const scopeGroupIconMap: ScopeGroupIconMap = { + [SCOPE_GROUP_FULL_ACCESS]: ArrowDownUp, + [SCOPE_GROUP_READ_ONLY]: MoveDown, + [SCOPE_GROUP_CUSTOM]: SquarePen, +}; + export type WalletCapabilities = { methods: Nip47RequestMethod[]; scopes: Scope[]; @@ -100,6 +130,18 @@ export const scopeDescriptions: Record = { [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", +}; + export const expiryOptions: Record = { "1 week": 7, "1 month": 30, @@ -109,8 +151,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, diff --git a/frontend/yarn.lock b/frontend/yarn.lock index d0a22f51..14264eb0 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -555,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" @@ -806,6 +813,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" @@ -823,6 +851,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" @@ -1068,6 +1112,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" @@ -1076,6 +1127,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" @@ -1091,6 +1149,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" @@ -1782,6 +1845,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" @@ -3027,6 +3095,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"