Skip to content

Commit

Permalink
feat: basic isolated apps UI
Browse files Browse the repository at this point in the history
  • Loading branch information
rolznz committed Jul 14, 2024
1 parent 7346641 commit 0aa4a9c
Show file tree
Hide file tree
Showing 12 changed files with 267 additions and 160 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: `..&notification_types=payment_received%20payment_sent`
- `isolated` (optional) makes an isolated app connection with its own balance and only access to its own transaction list. e.g. `&isolated=true`

Example:

Expand Down
1 change: 1 addition & 0 deletions alby/alby_oauth_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,7 @@ func (svc *albyOAuthService) LinkAccount(ctx context.Context, lnClient lnclient.
renewal,
nil,
scopes,
false,
)

if err != nil {
Expand Down
19 changes: 18 additions & 1 deletion api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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] {
Expand Down
3 changes: 3 additions & 0 deletions api/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
16 changes: 14 additions & 2 deletions db/db_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 == "" {
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion db/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
184 changes: 97 additions & 87 deletions frontend/src/components/Permissions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,12 @@ const Permissions: React.FC<PermissionsProps> = ({
}, [canEditPermissions, isNewConnection]);

const [showBudgetOptions, setShowBudgetOptions] = React.useState(
permissions.scopes.has("pay_invoice")
//permissions.scopes.has("pay_invoice")
false
);
const [showExpiryOptions, setShowExpiryOptions] = React.useState(
isNewConnection ? !!permissions.expiresAt : true
false
// isNewConnection ? !!permissions.expiresAt : true
);

const handlePermissionsChange = React.useCallback(
Expand All @@ -80,9 +82,9 @@ const Permissions: React.FC<PermissionsProps> = ({
[permissions, onPermissionsChange]
);

const handleScopeChange = React.useCallback(
(scopes: Set<Scope>) => {
handlePermissionsChange({ scopes });
const onScopesChanged = React.useCallback(
(scopes: Set<Scope>, isolated: boolean) => {
handlePermissionsChange({ scopes, isolated });
},
[handlePermissionsChange]
);
Expand Down Expand Up @@ -114,7 +116,8 @@ const Permissions: React.FC<PermissionsProps> = ({
<Scopes
capabilities={capabilities}
scopes={permissions.scopes}
onScopesChanged={handleScopeChange}
isolated={permissions.isolated}
onScopesChanged={onScopesChanged}
/>
) : (
<>
Expand All @@ -138,91 +141,98 @@ const Permissions: React.FC<PermissionsProps> = ({
</div>
</>
)}
{permissions.scopes.has("pay_invoice") &&
(!isBudgetAmountEditable ? (
<div className="pl-4 ml-2 border-l-2 border-l-primary mb-4">
<div className="flex flex-col gap-2 text-muted-foreground text-sm">
<p className="capitalize">
<span className="text-primary-foreground font-medium">
Budget Renewal:
</span>{" "}
{permissions.budgetRenewal || "Never"}
</p>
<p>
<span className="text-primary-foreground font-medium">
Budget Amount:
</span>{" "}
{permissions.maxAmount
? new Intl.NumberFormat().format(permissions.maxAmount)
: "∞"}
{" sats "}
{!isNewConnection &&
`(${new Intl.NumberFormat().format(budgetUsage || 0)} sats used)`}
</p>
</div>
</div>
) : (
<>
{!showBudgetOptions && (
<Button
type="button"
variant="secondary"
onClick={() => {
handleBudgetMaxAmountChange(100000);
setShowBudgetOptions(true);
}}
className={cn(
"mr-4",
(!isExpiryEditable || showExpiryOptions) && "mb-4"
)}
>
<PlusCircle className="w-4 h-4 mr-2" />
Set budget
</Button>
)}
{showBudgetOptions && (
<>
<BudgetRenewalSelect
value={permissions.budgetRenewal}
onChange={handleBudgetRenewalChange}
/>
<BudgetAmountSelect
value={permissions.maxAmount}
onChange={handleBudgetMaxAmountChange}
/>
</>
)}
</>
))}

{!isExpiryEditable ? (
{!permissions.isolated && permissions.scopes.has("pay_invoice") && (
<>
<p className="text-sm font-medium mb-2">Connection expiry</p>
<p className="text-muted-foreground text-sm">
{permissions.expiresAt &&
new Date(permissions.expiresAt).getFullYear() !== 1
? new Date(permissions.expiresAt).toString()
: "This app will never expire"}
</p>
{!isBudgetAmountEditable ? (
<div className="pl-4 ml-2 border-l-2 border-l-primary mb-4">
<div className="flex flex-col gap-2 text-muted-foreground text-sm">
<p className="capitalize">
<span className="text-primary-foreground font-medium">
Budget Renewal:
</span>{" "}
{permissions.budgetRenewal || "Never"}
</p>
<p>
<span className="text-primary-foreground font-medium">
Budget Amount:
</span>{" "}
{permissions.maxAmount
? new Intl.NumberFormat().format(permissions.maxAmount)
: "∞"}
{" sats "}
{!isNewConnection &&
`(${new Intl.NumberFormat().format(budgetUsage || 0)} sats used)`}
</p>
</div>
</div>
) : (
<>
{!showBudgetOptions && (
<Button
type="button"
variant="secondary"
onClick={() => {
handleBudgetMaxAmountChange(100000);
setShowBudgetOptions(true);
}}
className={cn(
"mr-4",
(!isExpiryEditable || showExpiryOptions) && "mb-4"
)}
>
<PlusCircle className="w-4 h-4 mr-2" />
Set budget
</Button>
)}
{showBudgetOptions && (
<>
<BudgetRenewalSelect
value={permissions.budgetRenewal}
onChange={handleBudgetRenewalChange}
/>
<BudgetAmountSelect
value={permissions.maxAmount}
onChange={handleBudgetMaxAmountChange}
/>
</>
)}
</>
)}
</>
) : (
)}

{!permissions.isolated && (
<>
{!showExpiryOptions && (
<Button
type="button"
variant="secondary"
onClick={() => setShowExpiryOptions(true)}
>
<PlusCircle className="w-4 h-4 mr-2" />
Set expiration time
</Button>
)}
{!isExpiryEditable ? (
<>
<p className="text-sm font-medium mb-2">Connection expiry</p>
<p className="text-muted-foreground text-sm">
{permissions.expiresAt &&
new Date(permissions.expiresAt).getFullYear() !== 1
? new Date(permissions.expiresAt).toString()
: "This app will never expire"}
</p>
</>
) : (
<>
{!showExpiryOptions && (
<Button
type="button"
variant="secondary"
onClick={() => setShowExpiryOptions(true)}
>
<PlusCircle className="w-4 h-4 mr-2" />
Set expiration time
</Button>
)}

{showExpiryOptions && (
<ExpirySelect
value={permissions.expiresAt}
onChange={handleExpiryChange}
/>
{showExpiryOptions && (
<ExpirySelect
value={permissions.expiresAt}
onChange={handleExpiryChange}
/>
)}
</>
)}
</>
)}
Expand Down
Loading

0 comments on commit 0aa4a9c

Please sign in to comment.