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 && (
+
+
+ Custom budget amount (sats)
+
+ {
+ 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 (
-
-
-
-
-
- {validBudgetRenewals.map((renewalOption) => (
-
- {renewalOption.charAt(0).toUpperCase() + renewalOption.slice(1)}
-
- ))}
-
-
+ <>
+
+ Budget Renewal
+
+
+
+
+
+
+
+ {validBudgetRenewals.map((renewalOption) => (
+
+ {renewalOption}
+
+ ))}
+
+ onChange("never")}
+ />
+
+
+ >
);
};
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[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 && (
+
{
+ handleBudgetMaxAmountChange(100000);
+ setShowBudgetOptions(true);
+ }}
+ className={cn(
+ "mr-4",
+ (!isExpiryEditable || showExpiryOptions) && "mb-4"
)}
-
- );
- })}
-
-
+ >
+
+ 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 && (
setExpireOptions(true)}
+ onClick={() => setShowExpiryOptions(true)}
>
- Set expiration date
+ Set expiration time
)}
- {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:
+
+
+ )}
+ >
+ );
+};
+
+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.
-
-
Budget renewal
+
+
-
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) => {
)}
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={
-
+
Delete
@@ -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"