diff --git a/README.md b/README.md index 5554dfe0..0b28e003 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`. 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/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 1fc7c2f3..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] { @@ -317,15 +334,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) { @@ -746,7 +759,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/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/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/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/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/public/images/illustrations/alby-account-dark.svg b/frontend/public/images/illustrations/alby-account-dark.svg new file mode 100644 index 00000000..c9b939c1 --- /dev/null +++ b/frontend/public/images/illustrations/alby-account-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/images/illustrations/alby-account-light.svg b/frontend/public/images/illustrations/alby-account-light.svg new file mode 100644 index 00000000..f4e79c2b --- /dev/null +++ b/frontend/public/images/illustrations/alby-account-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/images/illustrations/link-account.png b/frontend/public/images/illustrations/link-account.png deleted file mode 100644 index 881147a1..00000000 Binary files a/frontend/public/images/illustrations/link-account.png and /dev/null differ 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..12842cef 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,40 @@ import { BudgetRenewalType, validBudgetRenewals } from "src/types"; interface BudgetRenewalProps { value: BudgetRenewalType; onChange: (value: BudgetRenewalType) => void; - disabled?: boolean; + onClose?: () => void; } const BudgetRenewalSelect: React.FC = ({ value, onChange, - disabled, + onClose, }) => { return ( - + <> + +
+ +
+ ); }; diff --git a/frontend/src/components/ExpirySelect.tsx b/frontend/src/components/ExpirySelect.tsx new file mode 100644 index 00000000..a0ccd10e --- /dev/null +++ b/frontend/src/components/ExpirySelect.tsx @@ -0,0 +1,110 @@ +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 undefined; + } + 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(() => { + const _daysFromNow = daysFromNow(value); + return _daysFromNow !== undefined + ? !Object.values(expiryOptions) + .filter((value) => value !== 0) + .includes(_daysFromNow) + : false; + }); + return ( + <> +

Connection expiration

+
+ {Object.keys(expiryOptions).map((expiry) => { + return ( +
{ + setCustomExpiry(false); + let date: Date | undefined; + if (expiryOptions[expiry]) { + date = dayjs() + .add(expiryOptions[expiry], "day") + .endOf("day") + .toDate(); + } + 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..1b525892 100644 --- a/frontend/src/components/Permissions.tsx +++ b/frontend/src/components/Permissions.tsx @@ -1,273 +1,223 @@ 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, Scope, - expiryOptions, - iconMap, + WalletCapabilities, scopeDescriptions, + scopeIconMap, } from "src/types"; interface PermissionsProps { - initialPermissions: AppPermissions; - onPermissionsChange: (permissions: AppPermissions) => void; + capabilities: WalletCapabilities; + permissions: AppPermissions; + setPermissions: React.Dispatch>; + readOnly?: boolean; + scopesReadOnly?: boolean; + budgetReadOnly?: boolean; + expiresAtReadOnly?: boolean; budgetUsage?: number; - canEditPermissions: boolean; - isNewConnection?: boolean; + isNewConnection: boolean; } const Permissions: React.FC = ({ - initialPermissions, - onPermissionsChange, - canEditPermissions, + capabilities, + permissions, + setPermissions, isNewConnection, budgetUsage, + readOnly, + scopesReadOnly, + budgetReadOnly, + expiresAtReadOnly, }) => { - 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 handlePermissionsChange = ( - changedPermissions: Partial - ) => { - const updatedPermissions = { ...permissions, ...changedPermissions }; - setPermissions(updatedPermissions); - onPermissionsChange(updatedPermissions); - }; - - const handleScopeChange = (scope: Scope) => { - if (!canEditPermissions) { - return; - } - - let budgetRenewal = permissions.budgetRenewal; + const [showBudgetOptions, setShowBudgetOptions] = React.useState( + permissions.scopes.includes("pay_invoice") && permissions.maxAmount > 0 + ); + const [showExpiryOptions, setShowExpiryOptions] = React.useState( + !!permissions.expiresAt + ); - 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) => { + setPermissions((currentPermissions) => ({ + ...currentPermissions, + ...changedPermissions, + })); + }, + [setPermissions] + ); - handlePermissionsChange({ - scopes: newScopes, - budgetRenewal, - }); - }; + const onScopesChanged = React.useCallback( + (scopes: Scope[], isolated: boolean) => { + handlePermissionsChange({ scopes, isolated }); + }, + [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( + (budgetRenewal: BudgetRenewalType) => { + handlePermissionsChange({ budgetRenewal }); + }, + [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 && ( - +
    + {!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

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

    {scopeDescriptions[scope]}

    - {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"} -
    - )} -
    - )} -
  • - ); - })} -
-
+ ); + })} +
+ + )} - {( - isNewConnection ? !permissions.expiresAt || days : canEditPermissions - ) ? ( + {!permissions.isolated && permissions.scopes.includes("pay_invoice") && ( <> - {!expireOptions && ( - - )} - - {expireOptions && ( -
-

Connection expiration

- {!isNewConnection && ( -

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

+ {!readOnly && !budgetReadOnly ? ( + <> + {!showBudgetOptions && ( + + )} + {showBudgetOptions && ( + <> + { + handleBudgetRenewalChange("never"); + handleBudgetMaxAmountChange(0); + setShowBudgetOptions(false); + }} + /> + + )} -
- {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} -
- ); - })} + + ) : ( +
+
+

+ + 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)`} +

)} - ) : ( + )} + + {!permissions.isolated && ( <> -

Connection expiry

-

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

+ {!readOnly && !expiresAtReadOnly ? ( + <> + {!showExpiryOptions && ( + + )} + + {showExpiryOptions && ( + + )} + + ) : ( + <> +

Connection expiry

+

+ {permissions.expiresAt + ? 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..958d8e9a --- /dev/null +++ b/frontend/src/components/Scopes.tsx @@ -0,0 +1,201 @@ +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 { 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: Scope[]; + isolated: boolean; + isNewConnection: boolean; + onScopesChanged: (scopes: Scope[], isolated: boolean) => void; +} + +const Scopes: React.FC = ({ + capabilities, + scopes, + isolated, + isNewConnection, + onScopesChanged, +}) => { + const fullAccessScopes: Scope[] = React.useMemo(() => { + return [...capabilities.scopes]; + }, [capabilities.scopes]); + + const readOnlyScopes: Scope[] = React.useMemo(() => { + const readOnlyScopes: Scope[] = [ + "get_balance", + "get_info", + "make_invoice", + "lookup_invoice", + "list_transactions", + "notifications", + ]; + + return capabilities.scopes.filter((scope) => + readOnlyScopes.includes(scope) + ); + }, [capabilities.scopes]); + + const isolatedScopes: Scope[] = React.useMemo(() => { + const isolatedScopes: Scope[] = [ + "pay_invoice", + "get_balance", + "make_invoice", + "lookup_invoice", + "list_transactions", + "notifications", + ]; + + return capabilities.scopes.filter((scope) => + isolatedScopes.includes(scope) + ); + }, [capabilities.scopes]); + + const [scopeGroup, setScopeGroup] = React.useState(() => { + if (isolated) { + return "isolated"; + } + if (scopes.length === capabilities.scopes.length) { + return "full_access"; + } + if ( + scopes.length === readOnlyScopes.length && + readOnlyScopes.every((readOnlyScope) => scopes.includes(readOnlyScope)) + ) { + return "read_only"; + } + + return "custom"; + }); + + const handleScopeGroupChange = (scopeGroup: ScopeGroup) => { + setScopeGroup(scopeGroup); + switch (scopeGroup) { + case "full_access": + onScopesChanged(fullAccessScopes, false); + break; + case "read_only": + onScopesChanged(readOnlyScopes, false); + break; + case "isolated": + onScopesChanged(isolatedScopes, true); + break; + default: { + onScopesChanged([], false); + break; + } + } + }; + + const handleScopeChange = (scope: Scope) => { + let newScopes = [...scopes]; + if (newScopes.includes(scope)) { + newScopes = newScopes.filter((existing) => existing !== scope); + } else { + newScopes.push(scope); + } + + onScopesChanged(newScopes, false); + }; + + return ( + <> +
+

Choose wallet permissions

+
+ {scopeGroups.map((sg, index) => { + const ScopeGroupIcon = scopeGroupIconMap[sg]; + return ( +
{ + if (!isNewConnection && !isolated && sg === "isolated") { + // do not allow user to change non-isolated connection to isolated + alert("Please create a new isolated connection instead"); + return; + } + handleScopeGroupChange(sg); + }} + > + +

{scopeGroupTitle[sg]}

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

Authorize the app to:

+
    + {capabilities.scopes.map((scope, index) => { + return ( +
  • +
    + handleScopeChange(scope)} + checked={scopes.includes(scope)} + /> + +
    +
  • + ); + })} +
+
+ )} + + ); +}; + +export default Scopes; 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/components/TransactionItem.tsx b/frontend/src/components/TransactionItem.tsx index 23f50a4f..123f9968 100644 --- a/frontend/src/components/TransactionItem.tsx +++ b/frontend/src/components/TransactionItem.tsx @@ -77,15 +77,15 @@ 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" )} />
)}
-
+

{app ? app.name : type == "incoming" ? "Received" : "Sent"}

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

-
+

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

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

+

~{tx.totalAmountFiat}

)} */} @@ -144,8 +144,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" )} />
@@ -156,13 +156,13 @@ function TransactionItem({ tx }: Props) { )}{" "} {Math.floor(tx.amount / 1000) == 1 ? "sat" : "sats"}

- {/*

+ {/*

Fiat Amount

*/}
-

Date & Time

+

Date & Time

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

{type == "outgoing" && (
-

Fee

+

Fee

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

-

Description

+

Description

{tx.description}

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

Preimage

+

Preimage

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

-

Hash

+

Hash

{tx.payment_hash} diff --git a/frontend/src/components/connections/AlbyConnectionCard.tsx b/frontend/src/components/connections/AlbyConnectionCard.tsx index da7302f2..b5d05ccc 100644 --- a/frontend/src/components/connections/AlbyConnectionCard.tsx +++ b/frontend/src/components/connections/AlbyConnectionCard.tsx @@ -32,13 +32,13 @@ 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"; import { LinkStatus, useLinkAccount } from "src/hooks/useLinkAccount"; import { App, BudgetRenewalType } from "src/types"; -import linkAccountIllustration from "/images/illustrations/link-account.png"; +import albyAccountDark from "/images/illustrations/alby-account-dark.svg"; +import albyAccountLight from "/images/illustrations/alby-account-light.svg"; function AlbyConnectionCard({ connection }: { connection?: App }) { const { data: albyMe } = useAlbyMe(); @@ -94,23 +94,26 @@ function AlbyConnectionCard({ connection }: { connection?: App }) { every app you access through your Alby Account will handle payments via the Hub. + 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..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 ? ( <>
@@ -100,7 +124,13 @@ export function AppCardConnectionInfo({ {connection.scopes.indexOf("make_invoice") > -1 && (
- Create Invoices + Receive payments +
+ )} + {connection.scopes.indexOf("list_transactions") > -1 && ( +
+ + Read transaction history
)}
diff --git a/frontend/src/components/icons/Apple.tsx b/frontend/src/components/icons/Apple.tsx index e149512d..55d5c8bf 100644 --- a/frontend/src/components/icons/Apple.tsx +++ b/frontend/src/components/icons/Apple.tsx @@ -9,7 +9,7 @@ export function AppleIcon(props: SVGAttributes) { fill="none" xmlns="http://www.w3.org/2000/svg" > - + diff --git a/frontend/src/components/icons/NostrWalletConnectIcon.tsx b/frontend/src/components/icons/NostrWalletConnectIcon.tsx index 95c0c2a4..e972e1ca 100644 --- a/frontend/src/components/icons/NostrWalletConnectIcon.tsx +++ b/frontend/src/components/icons/NostrWalletConnectIcon.tsx @@ -13,16 +13,16 @@ export function NostrWalletConnectIcon(props: SVGAttributes) { ); 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/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/dialog.tsx b/frontend/src/components/ui/dialog.tsx index 1e78678a..7ad60561 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -1,6 +1,6 @@ -import * as React from "react"; import * as DialogPrimitive from "@radix-ui/react-dialog"; import { Cross2Icon } from "@radix-ui/react-icons"; +import * as React from "react"; import { cn } from "src/lib/utils"; @@ -36,7 +36,7 @@ const DialogContent = React.forwardRef< , + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +export { Popover, PopoverContent, PopoverTrigger }; diff --git a/frontend/src/screens/BackupNode.tsx b/frontend/src/screens/BackupNode.tsx index b2f93f3f..1ac47534 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,28 @@ 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 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 ? (

Enter unlock password

@@ -114,9 +136,10 @@ export function BackupNode() { type="submit" disabled={loading} size="lg" + className="w-full" onClick={() => setShowPasswordScreen(true)} > - Create Backup + Create Backup To Migrate Node
)} diff --git a/frontend/src/screens/Intro.tsx b/frontend/src/screens/Intro.tsx index f70d9267..f2a64e23 100644 --- a/frontend/src/screens/Intro.tsx +++ b/frontend/src/screens/Intro.tsx @@ -100,7 +100,7 @@ export function Intro() { api={api} icon={ShieldCheck} title="Your Keys Are Safe" - description="You wallet is encrypted by a password of your choice. No one can access your funds but you." + description="Your wallet is encrypted by a password of your choice. No one can access your funds but you." /> diff --git a/frontend/src/screens/apps/NewApp.tsx b/frontend/src/screens/apps/NewApp.tsx index e56b5efe..55e6692e 100644 --- a/frontend/src/screens/apps/NewApp.tsx +++ b/frontend/src/screens/apps/NewApp.tsx @@ -53,24 +53,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 isolatedParam = queryParams.get("isolated") ?? ""; + 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 initialScopes: Scope[] = React.useMemo(() => { const methods = reqMethodsParam ? reqMethodsParam.split(" ") : capabilities.methods; @@ -90,7 +89,9 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => { const notificationTypes = notificationTypesParam ? notificationTypesParam.split(" ") - : capabilities.notificationTypes; + : reqMethodsParam + ? [] // do not set notifications if only request methods provided + : capabilities.notificationTypes; const notificationTypesSet = new Set( notificationTypes as Nip47NotificationType[] @@ -108,42 +109,43 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => { ); } - const scopes = new Set(); + const scopes: Scope[] = []; if ( - requestMethodsSet.has("pay_keysend") || requestMethodsSet.has("pay_invoice") || + requestMethodsSet.has("pay_keysend") || requestMethodsSet.has("multi_pay_invoice") || requestMethodsSet.has("multi_pay_keysend") ) { - scopes.add("pay_invoice"); + scopes.push("pay_invoice"); } - if (requestMethodsSet.has("get_info")) { - scopes.add("get_info"); + if (requestMethodsSet.has("get_info") && isolatedParam !== "true") { + scopes.push("get_info"); } if (requestMethodsSet.has("get_balance")) { - scopes.add("get_balance"); + scopes.push("get_balance"); } if (requestMethodsSet.has("make_invoice")) { - scopes.add("make_invoice"); + scopes.push("make_invoice"); } if (requestMethodsSet.has("lookup_invoice")) { - scopes.add("lookup_invoice"); + scopes.push("lookup_invoice"); } if (requestMethodsSet.has("list_transactions")) { - scopes.add("list_transactions"); + scopes.push("list_transactions"); } - if (requestMethodsSet.has("sign_message")) { - scopes.add("sign_message"); + if (requestMethodsSet.has("sign_message") && isolatedParam !== "true") { + scopes.push("sign_message"); } if (notificationTypes.length) { - scopes.add("notifications"); + scopes.push("notifications"); } return scopes; }, [ capabilities.methods, capabilities.notificationTypes, + isolatedParam, notificationTypesParam, reqMethodsParam, ]); @@ -151,18 +153,23 @@ 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: budgetMaxAmountParam ? parseInt(budgetMaxAmountParam) : 0, budgetRenewal: validBudgetRenewals.includes(budgetRenewalParam) ? budgetRenewalParam - : "monthly", + : budgetMaxAmountParam + ? "never" + : "monthly", expiresAt: parseExpiresParam(expiresAtParam), + isolated: isolatedParam === "true", }); const handleSubmit = async (event: React.FormEvent) => { @@ -171,15 +178,21 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => { throw new Error("No CSRF token"); } + if (!permissions.scopes.length) { + toast({ title: "Please specify wallet permissions." }); + return; + } + try { const createAppRequest: CreateAppRequest = { name: appName, pubkey, budgetRenewal: permissions.budgetRenewal, - maxAmount: permissions.maxAmount, - scopes: Array.from(permissions.scopes), + maxAmount: permissions.maxAmount || 0, + scopes: permissions.scopes, expiresAt: permissions.expiresAt?.toISOString(), returnTo: returnTo, + isolated: permissions.isolated, }; const createAppResponse = await request("/api/apps", { @@ -254,12 +267,16 @@ 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..a8266d48 100644 --- a/frontend/src/screens/apps/ShowApp.tsx +++ b/frontend/src/screens/apps/ShowApp.tsx @@ -4,12 +4,12 @@ import { useLocation, useNavigate, useParams } from "react-router-dom"; 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 { + App, AppPermissions, BudgetRenewalType, - Scope, UpdateAppRequest, + WalletCapabilities, } from "src/types"; import { handleRequestError } from "src/utils/handleRequestError"; @@ -39,13 +39,40 @@ 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"; +import { formatAmount } from "src/lib/utils"; function ShowApp() { - const { data: info } = useInfo(); - const { data: csrf } = useCSRF(); - const { toast } = useToast(); const { pubkey } = useParams() as { pubkey: string }; const { data: app, mutate: refetchApp, error } = useApp(pubkey); + const { data: capabilities } = useCapabilities(); + + if (error) { + return

{error.message}

; + } + + if (!app || !capabilities) { + return ; + } + + return ( + + ); +} + +type AppInternalProps = { + app: App; + capabilities: WalletCapabilities; + refetchApp: () => void; +}; + +function AppInternal({ app, refetchApp, capabilities }: AppInternalProps) { + const { data: csrf } = useCSRF(); + const { toast } = useToast(); const navigate = useNavigate(); const location = useLocation(); const [editMode, setEditMode] = React.useState(false); @@ -60,31 +87,13 @@ function ShowApp() { }); const [permissions, setPermissions] = React.useState({ - scopes: new Set(), - maxAmount: 0, - budgetRenewal: "", - expiresAt: undefined, + scopes: app.scopes, + maxAmount: app.maxAmount, + budgetRenewal: app.budgetRenewal as BudgetRenewalType, + expiresAt: app.expiresAt ? new Date(app.expiresAt) : undefined, + isolated: app.isolated, }); - React.useEffect(() => { - if (app) { - setPermissions({ - scopes: new Set(app.scopes), - maxAmount: app.maxAmount, - budgetRenewal: app.budgetRenewal as BudgetRenewalType, - expiresAt: app.expiresAt ? new Date(app.expiresAt) : undefined, - }); - } - }, [app]); - - if (error) { - return

{error.message}

; - } - - if (!app || !info) { - return ; - } - const handleSave = async () => { try { if (!csrf) { @@ -115,10 +124,6 @@ function ShowApp() { } }; - if (!app) { - return ; - } - return ( <>
@@ -137,7 +142,7 @@ function ShowApp() { } contentRight={ - + @@ -175,6 +180,14 @@ function ShowApp() { {app.nostrPubkey} + {app.isolated && ( + + Balance + + {formatAmount(app.balance)} sats + + + )} Last used @@ -186,8 +199,7 @@ function ShowApp() { Expires At - {app.expiresAt && - new Date(app.expiresAt).getFullYear() !== 1 + {app.expiresAt ? new Date(app.expiresAt).toString() : "Never"} @@ -197,63 +209,57 @@ function ShowApp() { - - - -
- Permissions -
- {editMode && ( -
- + {!app.isolated && ( + + + +
+ Permissions +
+ {editMode && ( +
+ - -
- )} + +
+ )} - {!editMode && ( - <> - - - )} + {!editMode && ( + <> + + + )} +
-
- - - - - - + + + + + + + )}
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/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", diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 3ff8569d..7b5abcc1 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -10,16 +10,6 @@ import { 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 type BackendType = | "LND" | "BREEZ" @@ -60,19 +50,19 @@ export type Scope = export type Nip47NotificationType = "payment_received" | "payment_sent"; -export type IconMap = { +export type ScopeIconMap = { [key in Scope]: LucideIcon; }; -export const iconMap: IconMap = { - [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 scopeIconMap: ScopeIconMap = { + 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 = { @@ -90,14 +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", + 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 = { @@ -109,8 +99,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, @@ -130,6 +118,8 @@ export interface App { updatedAt: string; lastEventAt?: string; expiresAt?: string; + isolated: boolean; + balance: number; scopes: Scope[]; maxAmount: number; @@ -138,10 +128,11 @@ export interface App { } export interface AppPermissions { - scopes: Set; + scopes: Scope[]; maxAmount: number; budgetRenewal: BudgetRenewalType; expiresAt?: Date; + isolated: boolean; } export interface InfoResponse { @@ -172,6 +163,7 @@ export interface CreateAppRequest { expiresAt: string | undefined; scopes: Scope[]; returnTo: string; + isolated: boolean; } export interface CreateAppResponse { @@ -203,6 +195,7 @@ export type Channel = { forwardingFeeBaseMsat: number; unspendablePunishmentReserve: number; counterpartyUnspendablePunishmentReserve: number; + error?: string; }; export type UpdateChannelRequest = { 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" diff --git a/lnclient/ldk/ldk.go b/lnclient/ldk/ldk.go index ecd43291..335c76d9 100644 --- a/lnclient/ldk/ldk.go +++ b/lnclient/ldk/ldk.go @@ -224,6 +224,7 @@ func NewLDKService(ctx context.Context, cfg config.Config, eventPublisher events "0364913d18a19c671bb36dd04d6ad5be0fe8f2894314c36a9db3f03c2d414907e1@192.243.215.102:9735", // LQwD "035e4ff418fc8b5554c5d9eea66396c227bd429a3251c8cbc711002ba215bfc226@170.75.163.209:9735", // WoS "02fcc5bfc48e83f06c04483a2985e1c390cb0f35058baa875ad2053858b8e80dbd@35.239.148.251:9735", // Blink + "027100442c3b79f606f80f322d98d499eefcb060599efc5d4ecb00209c2cb54190@3.230.33.224:9735", // c= } logger.Logger.Info("Connecting to some peers to retrieve P2P gossip data") for _, peer := range peers { @@ -839,6 +840,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), @@ -846,7 +857,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, @@ -854,6 +865,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 e3340612..caf864b5 100644 --- a/lnclient/models.go +++ b/lnclient/models.go @@ -93,6 +93,7 @@ type Channel struct { ForwardingFeeBaseMsat uint32 `json:"forwardingFeeBaseMsat"` UnspendablePunishmentReserve uint64 `json:"unspendablePunishmentReserve"` CounterpartyUnspendablePunishmentReserve uint64 `json:"counterpartyUnspendablePunishmentReserve"` + Error *string `json:"error"` } type NodeStatus struct { 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 d0a549a6..edb41bd7 100644 --- a/service/models.go +++ b/service/models.go @@ -13,8 +13,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 466125ad..c65ec004 100644 --- a/service/service.go +++ b/service/service.go @@ -207,7 +207,7 @@ func finishRestoreNode(workDir string) { } func (svc *service) Shutdown() { - svc.StopLNClient() + svc.StopApp() svc.eventPublisher.Publish(&events.Event{ Event: "nwc_stopped", }) @@ -215,13 +215,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 } @@ -253,8 +246,3 @@ func (svc *service) GetTransactionsService() transactions.TransactionsService { 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 }