From e297251bfbb4b764137f4bced3f50a4f217fcec2 Mon Sep 17 00:00:00 2001 From: Roland <33993199+rolznz@users.noreply.github.com> Date: Sun, 14 Jul 2024 23:58:05 +0700 Subject: [PATCH] Feat: permissions revamp v2 (#273) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: revamp permissions component * chore: changes * chore: changes * chore: add view mode for show app screen * chore: further changes * feat: new illustration for linking account (#254) * feat: new illustration for linking account * fix: update paths * fix: icon props (#256) * fix: use date from frontend * chore: add expiryselect component * chore: further changes * chore: further changes * chore: add date-fns for date picker * chore: further changes * chore: further changes * fix: add central LDK gossip node to help gossip new public channels (#262) * fix: make dialog responsive (#258) * chore: further changes * typo * chore: spacing issues * chore: use scopes from capabilities * chore: change scope type descriptions * chore: styling fixes * chore: fix typings * chore: budget renewal component * fix: LDK mark channel as inactive and show error if counterparty forwarding info missing (#267) fix: mark channel as inactive and show error if counterparty forwarding info missing * chore: remove unnecessary dark classes (#255) * fix: links to open first channel in sidebar and onboarding checklist (#268) * feat: improve migrate node UI (#269) * fix: migrate node copy (#270) * fix: stop nostr when app is shutdown and use context to stop lnclient (#271) * fix: permissions revamp WIP * feat: basic isolated apps UI * fix: new app connection, edit app connection, deep linking --------- Co-authored-by: im-adithya Co-authored-by: René Aaron <100827540+reneaaron@users.noreply.github.com> Co-authored-by: Michael Bumann --- README.md | 1 + alby/alby_oauth_service.go | 1 + api/api.go | 30 +- api/models.go | 3 + cmd/http/main.go | 2 +- db/db_service.go | 16 +- db/models.go | 2 +- frontend/package.json | 3 + .../illustrations/alby-account-dark.svg | 1 + .../illustrations/alby-account-light.svg | 1 + .../images/illustrations/link-account.png | Bin 47903 -> 0 bytes .../src/components/BudgetAmountSelect.tsx | 81 +++- .../src/components/BudgetRenewalSelect.tsx | 43 +- frontend/src/components/ExpirySelect.tsx | 110 +++++ frontend/src/components/Permissions.tsx | 416 ++++++++---------- frontend/src/components/Scopes.tsx | 201 +++++++++ frontend/src/components/SidebarHint.tsx | 2 +- frontend/src/components/TransactionItem.tsx | 28 +- .../connections/AlbyConnectionCard.tsx | 23 +- .../connections/AppCardConnectionInfo.tsx | 36 +- frontend/src/components/icons/Apple.tsx | 2 +- .../icons/NostrWalletConnectIcon.tsx | 12 +- .../src/components/layouts/SettingsLayout.tsx | 2 +- frontend/src/components/ui/calendar.tsx | 64 +++ frontend/src/components/ui/dialog.tsx | 14 +- frontend/src/components/ui/popover.tsx | 29 ++ frontend/src/screens/BackupNode.tsx | 29 +- frontend/src/screens/Intro.tsx | 2 +- frontend/src/screens/apps/NewApp.tsx | 75 ++-- frontend/src/screens/apps/ShowApp.tsx | 184 ++++---- frontend/src/screens/channels/Channels.tsx | 18 +- .../screens/wallet/OnboardingChecklist.tsx | 2 +- frontend/src/types.ts | 55 +-- frontend/yarn.lock | 73 +++ lnclient/ldk/ldk.go | 14 +- lnclient/models.go | 1 + main_wails.go | 2 +- service/models.go | 3 +- service/service.go | 14 +- service/start.go | 33 +- service/stop.go | 48 +- 41 files changed, 1147 insertions(+), 529 deletions(-) create mode 100644 frontend/public/images/illustrations/alby-account-dark.svg create mode 100644 frontend/public/images/illustrations/alby-account-light.svg delete mode 100644 frontend/public/images/illustrations/link-account.png create mode 100644 frontend/src/components/ExpirySelect.tsx create mode 100644 frontend/src/components/Scopes.tsx create mode 100644 frontend/src/components/ui/calendar.tsx create mode 100644 frontend/src/components/ui/popover.tsx diff --git a/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 881147a154645684a1e127588fb435688bf28afc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 47903 zcmafYbyyouv^JVz!9BPpI1~~J6eqa5yAvQ3TA;MJ6ABb}ha$x(R*E|mcPMV96qi!U z<#+G*|2NOs2+1Z_FEe%B=9xWak8X8bpNnQsH4GW5fhB<_T`P4!o6q|vD zhH;~%rl;_9$;ZdXz`#fgq9Y?CBOxIn{NF-AK!AgTgO87ohlhuYi_0#gf3whd`+s$@ ziI~�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
  • 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

    x2!0i8WPGDvZ@0y4GnmL!kc&EHDKY7v2- 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 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 }