Merge branch 'task-react' into server-self-host

* task-react: (47 commits)
  chore: remove views and further changes
  chore: fixes in new and show
  feat: add success screen
  chore: split user from info endpoint
  chore: extract csrf out of info endpoint
  fix: use POST to logout
  chore: move login redirect to react
  fix api response codes if user is nil
  feat: add toasts and new app error handling
  feat: add api models
  fix: remove unnecessary user info display in new app page
  fix: navbar link styles
  fix: root styling
  fix: about page image and spacing
  fix: caret in back link
  chore: rename components
  fix: app expires at display
  fix: last accessed display
  fix: plus icon
  fix: use container for content
  ...
This commit is contained in:
Michael Bumann 2023-12-28 11:07:15 +01:00
commit 5bbab4cbad
108 changed files with 4805 additions and 3338 deletions

5
.gitignore vendored
View file

@ -5,4 +5,7 @@
node_modules
nostr-wallet-connect
nwc.db
.breez
.breez
frontend/dist
frontend/node_modules

View file

@ -1,3 +1,7 @@
{
"editor.defaultFormatter": "golang.go"
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[go]": {
"editor.defaultFormatter": "golang.go"
},
"editor.formatOnSave": true
}

View file

@ -19,6 +19,8 @@ RUN go mod download
COPY . .
RUN go build -o main .
# TODO: build react app?
# Start a new, final image to reduce size.
FROM alpine as final
@ -29,7 +31,8 @@ FROM alpine as final
# Copy the binaries and entrypoint from the builder image.
COPY --from=builder /build/main /bin/
COPY --from=builder /build/public /public/
COPY --from=builder /build/views /views/
# NOTE: should not be needed - assets should be embedded in the go app
#COPY --from=builder /build/public /public/
#COPY --from=builder /build/views /views/
ENTRYPOINT [ "/bin/main" ]

View file

@ -3,15 +3,13 @@
This application allows you to control your Lightning node or wallet over Nostr.
Connect applications like [Damus](https://damus.io/) or [Amethyst](https://linktr.ee/amethyst.social) to your node.
**Specification**: [NIP-47](https://github.com/nostr-protocol/nips/blob/master/47.md)
## Supported Backends
* [Alby](https://getalby.com) (see: alby.go)
* LND (see: lnd.go)
* want more? please open an issue.
- [Alby](https://getalby.com) (see: alby.go)
- LND (see: lnd.go)
- want more? please open an issue.
## Installation
@ -25,16 +23,32 @@ As data storage SQLite or PostgreSQL (recommended) can be used.
# edit the config for your needs
vim .env
To get a new random Nostr key use `openssl rand -hex 32` or similar.
To get a new random Nostr key use `openssl rand -hex 32` or similar.
## Development
`go run .` or `gow -e=go,mod,html,css run .` using [gow](https://github.com/mitranim/gow)
### Server (LND)
To build the CSS run:
1. Create a Lightning Polar setup with two LND nodes and uncomment the Polar LND section in your `.env` file.
1. `npm install`
2. `npm run css`
2. Compile the frontend or run `touch frontend/dist/tmp` to ensure there are embeddable files available.
3. `go run .` or `gow -e=go,mod,html,css run .` using [gow](https://github.com/mitranim/gow)
### Server (Alby Wallet API)
Generate a new OAuth client for <http://localhost:8080> from the [Alby developer portal](https://getalby.com/developer) and set `ALBY_CLIENT_ID` and `ALBY_CLIENT_SECRET` in your .env file.
### React Frontend (LND)
Go to `/frontend`
1. `yarn install`
2. `yarn dev`
### React Frontend (Alby Wallet API)
Follow standard LND instructions. After logging in, you will be redirected to the wrong port (8080), so manually re-open <http://localhost:5173>.
### Testing
@ -64,6 +78,7 @@ To build the CSS run:
Clients can use a deeplink to allow the user to add a new connection. Depending on the client this URL has different query options:
#### NWC created secret
The default option is that the NWC app creates a secret and the user uses the nostr wallet connect URL string to enable the client application.
##### Query parameter options
@ -75,22 +90,25 @@ Example:
`/apps/new?name=myapp`
#### Client created secret
If the client creates the secret the client only needs to share the public key of that secret for authorization. The user authorized that pubkey and no sensitivate data needs to be shared.
##### Query parameter options for /new
- `name`: the name of the client app
- `pubkey`: the public key of the client's secret for the user to authorize
- `return_to`: (optional) if a `return_to` URL is provided the user will be redirected to that URL after authorization. The `lud16`, `relay` and `pubkey` query parameters will be added to the URL.
- `expires_at` (optional) connection cannot be used after this date. Unix timestamp in seconds.
- `max_amount` (optional) maximum amount in sats that can be sent per renewal period
- `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`
- `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`
Example:
`/apps/new?name=myapp&pubkey=47c5a21...&return_to=https://example.com`
#### Web-flow: client created secret
Web clients can open a new prompt popup to load the authorization page.
Once the user has authorized the app connection a `nwc:success` message is sent to the opening page (using `postMessage`) to indicate that the connection is authorized. See the `initNWC()` function in the [alby-js-sdk](https://github.com/getAlby/alby-js-sdk#nostr-wallet-connect-documentation)
@ -102,15 +120,14 @@ const nwc = new webln.NWC();
// initNWC opens a prompt with /apps/new?c=myapp&pubkey=xxxx
// the promise resolves once the user has authorized the connection (when the `nwc:success` message is received) and the popup is closed automatically
// the promise rejects if the user cancels by closing the prompt popup
await nwc.initNWC({name: 'myapp'});
````
await nwc.initNWC({ name: "myapp" });
```
## Help
If you need help contact support@getalby.com or reach out on Nostr: npub1getal6ykt05fsz5nqu4uld09nfj3y3qxmv8crys4aeut53unfvlqr80nfm
You can also visit the chat of our Community on [Telegram](https://t.me/getalby).
## ⚡Donations
Want to support the work on Alby?
@ -118,7 +135,6 @@ Want to support the work on Alby?
Support the Alby team ⚡hello@getalby.com
You can also contribute to our [bounty program](https://github.com/getAlby/lightning-browser-extension/wiki/Bounties): ⚡bounties@getalby.com
## NIP-47 Supported Methods
✅ NIP-47 info event
@ -140,6 +156,7 @@ You can also contribute to our [bounty program](https://github.com/getAlby/light
`lookup_invoice`
`list_transactions`
- ⚠️ from and until in request not supported
- ⚠️ failed payments will not be returned
@ -150,6 +167,7 @@ You can also contribute to our [bounty program](https://github.com/getAlby/light
### Alby OAuth API
`get_info`
- ⚠️ block_hash not supported
- ⚠️ block_height not supported
- ⚠️ pubkey not supported
@ -161,15 +179,19 @@ You can also contribute to our [bounty program](https://github.com/getAlby/light
`pay_invoice`
`pay_keysend`
- ⚠️ preimage in request not supported
`make_invoice`
- ⚠️ expiry in request not supported
`lookup_invoice`
- ⚠️ fees_paid in response not supported
`list_transactions`
- ⚠️ offset and unpaid in request not supported
- ⚠️ fees_paid in response not supported
- ⚠️ unsettled and failed transactions will not be returned

View file

@ -4,10 +4,7 @@ import (
"crypto/subtle"
"embed"
"encoding/hex"
"errors"
"fmt"
"html/template"
"io"
"io/fs"
"net/http"
"net/url"
@ -16,6 +13,9 @@ import (
"time"
echologrus "github.com/davrux/echo-logrus/v4"
// "github.com/getAlby/lndhub.go/lib/responses"
"github.com/getAlby/nostr-wallet-connect/frontend"
"github.com/getAlby/nostr-wallet-connect/models/api"
"github.com/gorilla/sessions"
"github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4"
@ -26,59 +26,16 @@ import (
"gorm.io/gorm"
)
//go:embed public/*
//go:embed frontend/dist/*
var embeddedAssets embed.FS
//go:embed views/*
var embeddedViews embed.FS
type TemplateRegistry struct {
templates map[string]*template.Template
}
// Implement e.Renderer interface
func (t *TemplateRegistry) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
tmpl, ok := t.templates[name]
if !ok {
err := errors.New("Template not found -> " + name)
return err
}
return tmpl.ExecuteTemplate(w, "layout.html", data)
}
func (svc *Service) RegisterSharedRoutes(e *echo.Echo) {
templates := make(map[string]*template.Template)
templates["apps/index.html"] = template.Must(template.ParseFS(embeddedViews, "views/apps/index.html", "views/layout.html"))
templates["apps/new.html"] = template.Must(template.ParseFS(embeddedViews, "views/apps/new.html", "views/layout.html"))
templates["apps/show.html"] = template.Must(template.ParseFS(embeddedViews, "views/apps/show.html", "views/layout.html"))
templates["apps/create.html"] = template.Must(template.ParseFS(embeddedViews, "views/apps/create.html", "views/layout.html"))
templates["alby/index.html"] = template.Must(template.ParseFS(embeddedViews, "views/backends/alby/index.html", "views/layout.html"))
templates["about.html"] = template.Must(template.ParseFS(embeddedViews, "views/about.html", "views/layout.html"))
templates["404.html"] = template.Must(template.ParseFS(embeddedViews, "views/404.html", "views/layout.html"))
templates["lnd/index.html"] = template.Must(template.ParseFS(embeddedViews, "views/backends/lnd/index.html", "views/layout.html"))
templates["breez/index.html"] = template.Must(template.ParseFS(embeddedViews, "views/backends/breez/index.html", "views/layout.html"))
e.Renderer = &TemplateRegistry{
templates: templates,
}
e.HideBanner = true
e.Use(echologrus.Middleware())
if svc.cfg.BasicAuthUser != "" && svc.cfg.BasicAuthPassword != "" {
e.Use(middleware.BasicAuth(func(username, password string, c echo.Context) (bool, error) {
// Be careful to use constant time comparison to prevent timing attacks
if subtle.ConstantTimeCompare([]byte(username), []byte(svc.cfg.BasicAuthUser)) == 1 &&
subtle.ConstantTimeCompare([]byte(password), []byte(svc.cfg.BasicAuthPassword)) == 1 {
return true, nil
}
return false, nil
}))
}
e.Use(middleware.Recover())
e.Use(middleware.RequestID())
e.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
TokenLookup: "form:_csrf",
TokenLookup: "header:X-CSRF-Token,form:_csrf",
}))
e.Use(session.Middleware(sessions.NewCookieStore([]byte(svc.cfg.CookieSecret))))
e.Use(ddEcho.Middleware(ddEcho.WithServiceName("nostr-wallet-connect")))
@ -86,37 +43,15 @@ func (svc *Service) RegisterSharedRoutes(e *echo.Echo) {
assetSubdir, _ := fs.Sub(embeddedAssets, "public")
assetHandler := http.FileServer(http.FS(assetSubdir))
e.GET("/public/*", echo.WrapHandler(http.StripPrefix("/public/", assetHandler)))
e.GET("/apps", svc.AppsListHandler)
e.GET("/apps/new", svc.AppsNewHandler)
e.GET("/apps/:pubkey", svc.AppsShowHandler)
e.POST("/apps", svc.AppsCreateHandler)
e.POST("/apps/delete/:pubkey", svc.AppsDeleteHandler)
e.GET("/logout", svc.LogoutHandler)
e.GET("/about", svc.AboutHandler)
e.GET("/", svc.IndexHandler)
}
func (svc *Service) IndexHandler(c echo.Context) error {
sess, _ := session.Get(CookieName, c)
returnTo := sess.Values["return_to"]
user, err := svc.GetUser(c)
if err != nil {
return err
}
if user != nil && returnTo != nil {
delete(sess.Values, "return_to")
sess.Options.MaxAge = 0
sess.Options.SameSite = http.SameSiteLaxMode
if svc.cfg.CookieDomain != "" {
sess.Options.Domain = svc.cfg.CookieDomain
}
sess.Save(c.Request(), c.Response())
return c.Redirect(302, fmt.Sprintf("%s", returnTo))
}
if user != nil {
return c.Redirect(302, "/apps")
}
return c.Render(http.StatusOK, fmt.Sprintf("%s/index.html", strings.ToLower(svc.cfg.LNBackendType)), map[string]interface{}{})
e.GET("/api/csrf", svc.CSRFHandler)
e.GET("/api/apps", svc.AppsListHandler)
e.GET("/api/apps/:pubkey", svc.AppsShowHandler)
e.POST("/api/apps", svc.AppsCreateHandler)
e.DELETE("/api/apps/:pubkey", svc.AppsDeleteHandler)
e.GET("/api/user/me", svc.UserMeHandler)
e.GET("/api/info", svc.InfoHandler)
e.POST("/api/logout", svc.LogoutHandler)
frontend.RegisterHandlers(e)
}
func (svc *Service) AboutHandler(c echo.Context) error {
@ -132,217 +67,125 @@ func (svc *Service) AboutHandler(c echo.Context) error {
func (svc *Service) AppsListHandler(c echo.Context) error {
user, err := svc.GetUser(c)
if err != nil {
return err
return c.JSON(http.StatusBadRequest, ErrorResponse{
Error: true,
Message: fmt.Sprintf("Bad arguments %s", err.Error()),
})
}
if user == nil {
return c.Redirect(302, "/")
return c.NoContent(http.StatusUnauthorized)
}
apps := user.Apps
apps := []api.App{}
for _, app := range user.Apps {
apiApp := api.App{
// ID: app.ID,
Name: app.Name,
Description: app.Description,
CreatedAt: app.CreatedAt,
UpdatedAt: app.UpdatedAt,
NostrPubkey: app.NostrPubkey,
}
lastEvents := make(map[uint]NostrEvent)
eventsCounts := make(map[uint]int64)
for _, app := range apps {
var lastEvent NostrEvent
var eventsCount int64
svc.db.Where("app_id = ?", app.ID).Order("id desc").Limit(1).Find(&lastEvent)
svc.db.Model(&NostrEvent{}).Where("app_id = ?", app.ID).Count(&eventsCount)
lastEvents[app.ID] = lastEvent
eventsCounts[app.ID] = eventsCount
result := svc.db.Where("app_id = ?", app.ID).Order("id desc").Limit(1).Find(&lastEvent)
if result.RowsAffected > 0 {
apiApp.LastEventAt = &lastEvent.CreatedAt
}
apps = append(apps, apiApp)
}
return c.Render(http.StatusOK, "apps/index.html", map[string]interface{}{
"Apps": apps,
"User": user,
"LastEvents": lastEvents,
"EventsCounts": eventsCounts,
})
return c.JSON(http.StatusOK, apps)
}
func (svc *Service) AppsShowHandler(c echo.Context) error {
csrf, _ := c.Get(middleware.DefaultCSRFConfig.ContextKey).(string)
// csrf, _ := c.Get(middleware.DefaultCSRFConfig.ContextKey).(string)
user, err := svc.GetUser(c)
if err != nil {
return err
return c.JSON(http.StatusBadRequest, ErrorResponse{
Error: true,
Message: fmt.Sprintf("Bad arguments %s", err.Error()),
})
}
if user == nil {
return c.Redirect(302, "/")
return c.NoContent(http.StatusUnauthorized)
}
app := App{}
svc.db.Where("user_id = ? AND nostr_pubkey = ?", user.ID, c.Param("pubkey")).First(&app)
if app.NostrPubkey == "" {
return c.Render(http.StatusNotFound, "404.html", map[string]interface{}{
"User": user,
return c.JSON(http.StatusNotFound, ErrorResponse{
Error: true,
Message: "App does not exist",
})
}
lastEvent := NostrEvent{}
svc.db.Where("app_id = ?", app.ID).Order("id desc").Limit(1).Find(&lastEvent)
var eventsCount int64
svc.db.Model(&NostrEvent{}).Where("app_id = ?", app.ID).Count(&eventsCount)
var lastEvent NostrEvent
lastEventResult := svc.db.Where("app_id = ?", app.ID).Order("id desc").Limit(1).Find(&lastEvent)
//var eventsCount int64
//svc.db.Model(&NostrEvent{}).Where("app_id = ?", app.ID).Count(&eventsCount)
paySpecificPermission := AppPermission{}
appPermissions := []AppPermission{}
expiresAt := time.Time{}
var expiresAt *time.Time
svc.db.Where("app_id = ?", app.ID).Find(&appPermissions)
requestMethods := []string{}
for _, appPerm := range appPermissions {
if expiresAt.IsZero() && !appPerm.ExpiresAt.IsZero() {
expiresAt = appPerm.ExpiresAt
if !appPerm.ExpiresAt.IsZero() {
expiresAt = &appPerm.ExpiresAt
}
if appPerm.RequestMethod == NIP_47_PAY_INVOICE_METHOD {
//find the pay_invoice-specific permissions
paySpecificPermission = appPerm
}
requestMethods = append(requestMethods, nip47MethodDescriptions[appPerm.RequestMethod])
requestMethods = append(requestMethods, appPerm.RequestMethod)
}
expiresAtFormatted := expiresAt.Format("January 2, 2006 03:04 PM")
renewsIn := ""
//renewsIn := ""
budgetUsage := int64(0)
maxAmount := paySpecificPermission.MaxAmount
if maxAmount > 0 {
budgetUsage = svc.GetBudgetUsage(&paySpecificPermission)
endOfBudget := GetEndOfBudget(paySpecificPermission.BudgetRenewal, app.CreatedAt)
renewsIn = getEndOfBudgetString(endOfBudget)
}
return c.Render(http.StatusOK, "apps/show.html", map[string]interface{}{
"App": app,
"PaySpecificPermission": paySpecificPermission,
"RequestMethods": requestMethods,
"ExpiresAt": expiresAt,
"ExpiresAtFormatted": expiresAtFormatted,
"User": user,
"LastEvent": lastEvent,
"EventsCount": eventsCount,
"BudgetUsage": budgetUsage,
"RenewsIn": renewsIn,
"Csrf": csrf,
})
response := api.App{
Name: app.Name,
Description: app.Description,
CreatedAt: app.CreatedAt,
UpdatedAt: app.UpdatedAt,
NostrPubkey: app.NostrPubkey,
ExpiresAt: expiresAt,
MaxAmount: maxAmount,
RequestMethods: requestMethods,
BudgetUsage: budgetUsage,
BudgetRenewal: paySpecificPermission.BudgetRenewal,
}
if lastEventResult.RowsAffected > 0 {
response.LastEventAt = &lastEvent.CreatedAt
}
return c.JSON(http.StatusOK, response)
}
func getEndOfBudgetString(endOfBudget time.Time) (result string) {
if endOfBudget.IsZero() {
return "--"
}
endOfBudgetDuration := endOfBudget.Sub(time.Now())
//less than a day
if endOfBudgetDuration.Hours() < 24 {
hours := int(endOfBudgetDuration.Hours())
minutes := int(endOfBudgetDuration.Minutes()) % 60
return fmt.Sprintf("%d hours and %d minutes", hours, minutes)
}
//less than a month
if endOfBudgetDuration.Hours() < 24*30 {
days := int(endOfBudgetDuration.Hours() / 24)
return fmt.Sprintf("%d days", days)
}
//more than a month
months := int(endOfBudgetDuration.Hours() / 24 / 30)
days := int(endOfBudgetDuration.Hours()/24) % 30
if days > 0 {
return fmt.Sprintf("%d months %d days", months, days)
}
return fmt.Sprintf("%d months", months)
}
func (svc *Service) AppsNewHandler(c echo.Context) error {
appName := c.QueryParam("name")
if appName == "" {
// c - for client (deprecated)
appName = c.QueryParam("c")
}
pubkey := c.QueryParam("pubkey")
returnTo := c.QueryParam("return_to")
maxAmount := c.QueryParam("max_amount")
budgetRenewal := strings.ToLower(c.QueryParam("budget_renewal"))
expiresAt := c.QueryParam("expires_at") // YYYY-MM-DD or MM/DD/YYYY or timestamp in seconds
if expiresAtTimestamp, err := strconv.Atoi(expiresAt); err == nil {
expiresAt = time.Unix(int64(expiresAtTimestamp), 0).Format(time.RFC3339)
}
expiresAtISO, _ := time.Parse(time.RFC3339, expiresAt)
expiresAtFormatted := expiresAtISO.Format("January 2, 2006 03:04 PM")
requestMethods := c.QueryParam("request_methods")
customRequestMethods := requestMethods
if requestMethods == "" {
// if no request methods are given, enable them all by default
keys := []string{}
for key := range nip47MethodDescriptions {
keys = append(keys, key)
}
requestMethods = strings.Join(keys, " ")
}
func (svc *Service) CSRFHandler(c echo.Context) error {
csrf, _ := c.Get(middleware.DefaultCSRFConfig.ContextKey).(string)
user, err := svc.GetUser(c)
if err != nil {
return err
}
if user == nil {
sess, _ := session.Get(CookieName, c)
sess.Values["return_to"] = c.Path() + "?" + c.QueryString()
sess.Options.MaxAge = 0
sess.Options.SameSite = http.SameSiteLaxMode
if svc.cfg.CookieDomain != "" {
sess.Options.Domain = svc.cfg.CookieDomain
}
sess.Save(c.Request(), c.Response())
return c.Redirect(302, fmt.Sprintf("/%s/auth?c=%s", strings.ToLower(svc.cfg.LNBackendType), appName))
}
//construction to return a map with all possible permissions
//and indicate which ones are checked by default in the front-end
type RequestMethodHelper struct {
Description string
Icon string
Checked bool
}
requestMethodHelper := map[string]*RequestMethodHelper{}
for k, v := range nip47MethodDescriptions {
requestMethodHelper[k] = &RequestMethodHelper{
Description: v,
Icon: nip47MethodIcons[k],
}
}
for _, m := range strings.Split(requestMethods, " ") {
if _, ok := nip47MethodDescriptions[m]; ok {
requestMethodHelper[m].Checked = true
}
}
return c.Render(http.StatusOK, "apps/new.html", map[string]interface{}{
"User": user,
"Name": appName,
"Pubkey": pubkey,
"ReturnTo": returnTo,
"MaxAmount": maxAmount,
"BudgetRenewal": budgetRenewal,
"ExpiresAt": expiresAt,
"ExpiresAtFormatted": expiresAtFormatted,
"RequestMethods": requestMethods,
"CustomRequestMethods": customRequestMethods,
"RequestMethodHelper": requestMethodHelper,
"Csrf": csrf,
})
return c.JSON(http.StatusOK, csrf)
}
func (svc *Service) AppsCreateHandler(c echo.Context) error {
user, err := svc.GetUser(c)
if err != nil {
return err
return c.JSON(http.StatusBadRequest, ErrorResponse{
Error: true,
Message: fmt.Sprintf("Bad arguments %s", err.Error()),
})
}
if user == nil {
return c.Redirect(302, "/")
return c.NoContent(http.StatusUnauthorized)
}
name := c.FormValue("name")
@ -357,18 +200,25 @@ func (svc *Service) AppsCreateHandler(c echo.Context) error {
decoded, err := hex.DecodeString(pairingPublicKey)
if err != nil || len(decoded) != 32 {
svc.Logger.Errorf("Invalid public key format: %s", pairingPublicKey)
return c.Redirect(302, "/apps")
return c.JSON(http.StatusBadRequest, ErrorResponse{
Error: true,
Message: fmt.Sprintf("Invalid public key format: %s", pairingPublicKey),
})
}
}
app := App{Name: name, NostrPubkey: pairingPublicKey}
maxAmount, _ := strconv.Atoi(c.FormValue("MaxAmount"))
budgetRenewal := c.FormValue("BudgetRenewal")
maxAmount, _ := strconv.Atoi(c.FormValue("maxAmount"))
budgetRenewal := c.FormValue("budgetRenewal")
expiresAt := time.Time{}
if c.FormValue("ExpiresAt") != "" {
expiresAt, err = time.Parse(time.RFC3339, c.FormValue("ExpiresAt"))
if c.FormValue("expiresAt") != "" {
expiresAt, err = time.Parse(time.RFC3339, c.FormValue("expiresAt"))
if err != nil {
return fmt.Errorf("Invalid ExpiresAt: %v", err)
svc.Logger.Errorf("Invalid expiresAt: %s", pairingPublicKey)
return c.JSON(http.StatusBadRequest, ErrorResponse{
Error: true,
Message: fmt.Sprintf("Invalid expiresAt: %v", err),
})
}
}
@ -382,7 +232,7 @@ func (svc *Service) AppsCreateHandler(c echo.Context) error {
return err
}
requestMethods := c.FormValue("RequestMethods")
requestMethods := c.FormValue("requestMethods")
if requestMethods == "" {
return fmt.Errorf("Won't create an app without request methods.")
}
@ -415,7 +265,10 @@ func (svc *Service) AppsCreateHandler(c echo.Context) error {
"pairingPublicKey": pairingPublicKey,
"name": name,
}).Errorf("Failed to save app: %v", err)
return c.Redirect(302, "/apps")
return c.JSON(http.StatusInternalServerError, ErrorResponse{
Error: true,
Message: fmt.Sprintf("Failed to save app: %v", err),
})
}
publicRelayUrl := svc.cfg.PublicRelay
@ -423,6 +276,11 @@ func (svc *Service) AppsCreateHandler(c echo.Context) error {
publicRelayUrl = svc.cfg.Relay
}
responseBody := &api.CreateAppResponse{}
responseBody.Name = name
responseBody.Pubkey = pairingPublicKey
responseBody.PairingSecret = pairingSecretKey
if c.FormValue("returnTo") != "" {
returnToUrl, err := url.Parse(c.FormValue("returnTo"))
if err == nil {
@ -433,7 +291,7 @@ func (svc *Service) AppsCreateHandler(c echo.Context) error {
query.Add("lud16", user.LightningAddress)
}
returnToUrl.RawQuery = query.Encode()
return c.Redirect(302, returnToUrl.String())
responseBody.ReturnTo = returnToUrl.String()
}
}
@ -441,28 +299,23 @@ func (svc *Service) AppsCreateHandler(c echo.Context) error {
if user.LightningAddress != "" {
lud16 = fmt.Sprintf("&lud16=%s", user.LightningAddress)
}
pairingUri := template.URL(fmt.Sprintf("nostr+walletconnect://%s?relay=%s&secret=%s%s", svc.cfg.IdentityPubkey, publicRelayUrl, pairingSecretKey, lud16))
return c.Render(http.StatusOK, "apps/create.html", map[string]interface{}{
"User": user,
"PairingUri": pairingUri,
"PairingSecret": pairingSecretKey,
"Pubkey": pairingPublicKey,
"Name": name,
})
responseBody.PairingUri = fmt.Sprintf("nostr+walletconnect://%s?relay=%s&secret=%s%s", svc.cfg.IdentityPubkey, publicRelayUrl, pairingSecretKey, lud16)
return c.JSON(http.StatusOK, responseBody)
}
func (svc *Service) AppsDeleteHandler(c echo.Context) error {
user, err := svc.GetUser(c)
// TODO: error handling
if err != nil {
return err
}
if user == nil {
return c.Redirect(302, "/")
return c.NoContent(http.StatusUnauthorized)
}
app := App{}
svc.db.Where("user_id = ? AND nostr_pubkey = ?", user.ID, c.Param("pubkey")).First(&app)
svc.db.Delete(&app)
return c.Redirect(302, "/apps")
return c.NoContent(http.StatusNoContent)
}
func (svc *Service) LogoutHandler(c echo.Context) error {
@ -472,5 +325,27 @@ func (svc *Service) LogoutHandler(c echo.Context) error {
sess.Options.Domain = svc.cfg.CookieDomain
}
sess.Save(c.Request(), c.Response())
return c.Redirect(302, "/")
return c.NoContent(http.StatusNoContent)
}
func (svc *Service) InfoHandler(c echo.Context) error {
responseBody := &api.InfoResponse{}
responseBody.BackendType = svc.cfg.LNBackendType
return c.JSON(http.StatusOK, responseBody)
}
func (svc *Service) UserMeHandler(c echo.Context) error {
user, err := svc.GetUser(c)
if err != nil {
// TODO: error handling
return err
}
if user == nil {
return c.NoContent(http.StatusNotFound)
}
responseBody := api.User{
Email: user.Email,
}
return c.JSON(http.StatusOK, responseBody)
}

18
frontend/.eslintrc.cjs Normal file
View file

@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

23
frontend/frontend.go Normal file
View file

@ -0,0 +1,23 @@
package frontend
import (
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
func RegisterHandlers(e *echo.Echo) {
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
Skipper: nil,
// Root directory from where the static content is served.
Root: "frontend/dist",
// Index file for serving a directory.
// Optional. Default value "index.html".
Index: "index.html",
// Enable HTML5 mode by forwarding all not-found requests to root so that
// SPA (single-page application) can handle the routing.
HTML5: true,
Browse: false,
IgnoreBase: false,
Filesystem: nil,
}))
}

13
frontend/index.html Normal file
View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Alby - Nostr Wallet Connect</title>
<link rel="icon" href="/src/assets/images/nwc-logo.svg" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

38
frontend/package.json Normal file
View file

@ -0,0 +1,38 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hot-toast": "^2.4.1",
"react-qr-code": "^2.0.12",
"react-router-dom": "^6.21.0",
"swr": "^2.2.4"
},
"devDependencies": {
"@tailwindcss/aspect-ratio": "^0.4.2",
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.10",
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@vitejs/plugin-react-swc": "^3.3.2",
"autoprefixer": "^10.4.16",
"eslint": "^8.45.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.3",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
"typescript": "^5.0.2",
"vite": "^4.4.5"
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View file

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Before After
Before After

38
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,38 @@
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import About from "./screens/About";
import AppsList from "./screens/apps/AppsList";
import ShowApp from "./screens/apps/ShowApp";
import Login from "./screens/Login";
import NotFound from "./screens/NotFound";
import Navbar from "./components/Navbar";
import Footer from "./components/Footer";
import NewApp from "./screens/apps/NewApp";
import AppCreated from "./screens/apps/AppCreated";
import Toaster from "./components/Toast/Toaster";
function App() {
return (
<div className="dark:bg-black min-h-full">
<Toaster />
<BrowserRouter>
<Routes>
<Route path="/" element={<Navbar />}>
<Route index element={<Navigate to="/apps" replace />} />
<Route path="apps" element={<AppsList />} />
<Route path="apps/:pubkey" element={<ShowApp />} />
<Route path="apps/new" element={<NewApp />} />
<Route path="apps/created" element={<AppCreated />} />
<Route path="about" element={<About />} />
</Route>
<Route path="login" element={<Login />} />
<Route path="/*" element={<NotFound />} />
</Routes>
<Footer />
</BrowserRouter>
</div>
);
}
export default App;

View file

@ -0,0 +1 @@
Convert these to React components (see components/icons)

View file

Before

Width:  |  Height:  |  Size: 344 B

After

Width:  |  Height:  |  Size: 344 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 324 B

After

Width:  |  Height:  |  Size: 324 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 2 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Before After
Before After

View file

@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1210" height="525" fill="none">
<circle r="36.7" fill="#000" transform="matrix(-1 0 0 1 94.1 138.1)" />
<path stroke="#000" stroke-width="18.3" d="m88 132.6 68.5 68.5" />
<circle cx="393.6" cy="138.1" r="36.7" fill="#000" />
<path stroke="#000" stroke-width="18.3" d="m400.4 132.6-68.5 68.5" />
<path fill="#FFDF6F" fill-rule="evenodd"
d="M104.5 357a48.5 48.5 0 0 1-27.4-52.1c16.6-92.5 85.5-161.9 168-161.9 82.7 0 151.8 69.7 168.1 162.6a48.5 48.5 0 0 1-27.6 52 327.6 327.6 0 0 1-139.9 31.2c-50.5 0-98.4-11.4-141.2-31.8Z"
clip-rule="evenodd" />
<path fill="#000"
d="m413.2 305.6-9 1.6 9-1.6Zm-27.6 52-4-8.3 4 8.3Zm-299.4-51c16-89.5 82-154.4 159-154.4v-18.3c-88.2 0-160 73.8-177.1 169.4l18 3.2Zm159-154.4c77 0 143.2 65.3 159 155l18-3.2c-16.8-96-88.8-170.1-177-170.1v18.3Zm136.5 197.1a318.4 318.4 0 0 1-136 30.3v18.3c51.4 0 100.1-11.5 143.8-32l-7.8-16.6Zm-136 30.3c-49.1 0-95.6-11.1-137.2-30.9l-7.9 16.6c44 21 93.2 32.6 145.1 32.6v-18.3Zm158.5-72.4c3 17.3-6.2 34.5-22.5 42.1l7.8 16.6c23.4-11 37.3-36 32.7-62l-18 3.3Zm-336.1-4a57.6 57.6 0 0 0 32.5 62l7.9-16.5a39.3 39.3 0 0 1-22.3-42.2L68 303.3Z" />
<path fill="#000" fill-rule="evenodd"
d="M139.8 334c-15.9-6.5-25.3-23.4-19.8-39.7 17-50.2 66.6-86.5 125.1-86.5s108.1 36.3 125.1 86.5c5.6 16.3-3.9 33.2-19.8 39.7a278.6 278.6 0 0 1-105.3 20.5c-37.2 0-72.8-7.3-105.3-20.5Z"
clip-rule="evenodd" />
<ellipse cx="287.3" cy="286.1" fill="#fff" rx="30.6" ry="24.4" />
<ellipse cx="199.8" cy="286.1" fill="#fff" rx="30.6" ry="24.4" />
<path fill="#000"
d="M623.9 134.4h58.2l81.3 215-57.3 5.8-15.6-46h-75.2l-15 42.8h-58l81.6-217.6Zm50 129L652 204.2l-20.8 59.2h42.5ZM837.5 352h-58.2V129.6l58.2-3.5V352Zm61.4 0h-29.4V129.6l58.2-3.5v69.4c3.6-1.3 7.4-2.2 11.2-2.9a93.2 93.2 0 0 1 52.8 7.4 63.8 63.8 0 0 1 28.2 26.6c7 12 10.5 27.2 10.5 45.7a92 92 0 0 1-10.5 46.1 66 66 0 0 1-28.8 27.8 93.4 93.4 0 0 1-43 9.3 65.3 65.3 0 0 1-39.3-13.4L899 352Zm42.9-115.8a27 27 0 0 0-14.1 4.8v65.2c5.8 3 11.3 4.5 16.6 4.5a22 22 0 0 0 18.3-9.9 46 46 0 0 0 7.7-28.2c0-12-2.5-21-7.4-27.2-4.9-6.1-12-9.2-21.1-9.2ZM1088 352.6l1.3-3.5-55.7-142.7 53.8-15.4 31.7 93.5 29.7-87.7h58l-68.2 173.4c-3.2 8.4-8.4 16-15.7 23-7.3 7.1-15.7 13-25.3 18a128 128 0 0 1-29.7 10.9l-19.6-45.1 16-7c5.8-2.6 11-5.4 15.4-8.4 4.5-3 7.3-6 8.3-9Z" />
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before After
Before After

View file

@ -0,0 +1,6 @@
<svg class="mr-2" width="24" height="24" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M13.6281 5.03035L20.9945 12.43L20.9956 12.431C21.4028 12.84 21.8601 13.0639 22.279 13.1062C22.6711 13.1459 23.0667 13.0322 23.4219 12.6949L19.4029 2.22631C18.9491 1.2345 17.6421 0.998839 16.8705 1.7739L13.6281 5.03035ZM20.5966 12.8277L20.5967 12.8278L20.5965 12.828C21.5558 13.7914 22.8871 14.0212 23.8507 13.0622L20.5884 16.3091L19.0591 17.8312L17.9915 18.8938L16.1182 17.0137C17.026 15.6333 16.8781 13.76 15.6662 12.5406L14.3908 11.2573C14.2095 11.0748 13.9176 11.074 13.7351 11.2553L13.1055 11.8809L11.5093 10.2748C11.2323 9.99609 10.7744 10.0126 10.5176 10.3196C10.2928 10.5907 10.3315 10.9906 10.5786 11.2392L12.1549 12.8252L10.9035 14.0685L9.3073 12.4624C9.03028 12.1837 8.5705 12.1981 8.31561 12.5072C8.0928 12.7764 8.13155 13.1762 8.37864 13.4249L9.95488 15.0109L9.32725 15.6346C9.14479 15.8159 9.14392 16.1077 9.32522 16.2902L10.6006 17.5735C11.8123 18.7927 13.6847 18.9526 15.0709 18.054L16.9452 19.9351L16.3577 20.5197L14.3527 22.5153C13.3889 23.4743 11.8301 23.4707 10.8708 22.5072L0.717064 12.3077C-0.242097 11.344 -0.238627 9.78546 0.725161 8.82627L6.96087 2.61994C7.92465 1.66089 9.4834 1.66451 10.4427 2.62803L20.5966 12.8277Z"
fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Before After
Before After

View file

@ -0,0 +1,23 @@
import albyLogoWithText from "../assets/images/alby-logo-with-text.svg";
import albyLogoWithTextDark from "../assets/images/alby-logo-with-text-dark.svg";
import { useDarkMode } from "../hooks/useDarkMode";
function Footer() {
const isDarkMode = useDarkMode();
return (
<footer className="mt-20 mb-4 flex justify-center items-center gap-2">
<span className="text-gray-500 dark:text-neutral-300 text-xs">
Made with 💜 by
</span>
<a href="https://getalby.com?utm_source=nwc" rel="noreferrer noopener">
<img
id="alby-logo"
src={isDarkMode ? albyLogoWithTextDark : albyLogoWithText}
className="w-16 inline"
/>
</a>
</footer>
);
}
export default Footer;

View file

@ -0,0 +1,28 @@
function Loading({ color = "currentColor" }) {
return (
<>
<svg
className="animate-spin h-6 w-6 text-purple-700"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke={color}
strokeWidth="4"
></circle>
<path
fill={color}
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span className="sr-only">Loading...</span>
</>
);
}
export default Loading;

View file

@ -0,0 +1,155 @@
import { Outlet, useLocation } from "react-router-dom";
import { useInfo } from "../hooks/useInfo";
import { useLogin } from "../hooks/useLogin";
import nwcLogo from "../assets/images/nwc-logo.svg";
import caretIcon from "../assets/icons/caret.svg";
import aboutIcon from "../assets/icons/about.svg";
import React from "react";
import { LogoutIcon } from "./icons/LogoutIcon";
import { handleFetchError, validateFetchResponse } from "../utils/fetch";
import { useCSRF } from "../hooks/useCSRF";
import { useUser } from "../hooks/useUser";
function Navbar() {
const { data: info } = useInfo();
const { data: user } = useUser();
const location = useLocation();
const linkStyles =
"text-gray-400 font-medium hover:text-gray-600 dark:hover:text-gray-300 transition";
const selectedLinkStyles = "text-gray-900 dark:text-gray-100";
return (
<>
<div className="bg-gray-50 dark:bg-surface-00dp">
<div className="bg-white border-b border-gray-200 dark:bg-surface-01dp dark:border-neutral-700 mb-6">
<nav className="container max-w-screen-lg mx-auto px-4 lg:px-0 py-3">
<div className="flex justify-between items-center">
<div className="flex items-center space-x-12 justify-center">
<a
href="/"
className="font-headline text-[20px] dark:text-white flex gap-2 justify-center items-center"
>
<img
alt="NWC Logo"
className="w-8 inline"
width="128"
height="120"
src={nwcLogo}
/>
<span className="dark:text-white text-lg font-semibold hidden sm:inline">
Nostr Wallet Connect
</span>
</a>
<div
className={`${user ? "hidden md:flex" : "flex"} space-x-4`}
>
<a
className={`${linkStyles} ${
location.pathname === "/apps" && selectedLinkStyles
}`}
href="/apps"
>
Connections
</a>
<a
className={`${linkStyles} ${
location.pathname === "/about" && selectedLinkStyles
}`}
href="/about"
>
About
</a>
</div>
</div>
{info?.backendType === "ALBY" && <ProfileDropdown />}
</div>
</nav>
</div>
</div>
<div className="flex justify-center">
<div className="container max-w-screen-lg">
<Outlet />
</div>
</div>
</>
);
}
function ProfileDropdown() {
useLogin();
const { data: csrf } = useCSRF();
const { data: user } = useUser();
const [isOpen, setOpen] = React.useState(false);
if (!user) {
return null;
}
async function logout() {
try {
if (!csrf) {
throw new Error("info not loaded");
}
const response = await fetch("/api/logout", {
method: "POST",
headers: {
"X-CSRF-Token": csrf,
},
});
await validateFetchResponse(response);
window.location.href = "/";
} catch (error) {
handleFetchError("Failed to logout", error);
}
}
// TODO: add a proper dropdown component
return (
<div className="flex items-center relative">
<p
className="text-gray-400 text-xs font-medium sm:text-base cursor-pointer select-none "
onClick={() => setOpen((current) => !current)}
>
<span>{user.email}</span>
<img
id="caret"
className={`inline cursor-pointer w-4 ml-2 ${
isOpen ? "rotate-180" : ""
}`}
src={caretIcon}
/>
</p>
{isOpen && (
<div
className="font-medium flex flex-col px-4 w-40 logout absolute right top-8 right-0 justify-left cursor-pointer rounded-lg border border-gray-200 dark:border-gray-600 text-center bg-white dark:bg-surface-01dp shadow"
id="dropdown"
>
<a
className="md:hidden flex items-center justify-left py-2 text-gray-400 dark:text-gray-400"
href="/about"
>
<img
className="inline cursor-pointer w-4 mr-3"
src={aboutIcon}
alt="about-svg"
/>
<p className="font-normal">About</p>
</a>
<div
className="flex items-center justify-left py-2 text-red-500"
onClick={logout}
>
<LogoutIcon className="inline cursor-pointer w-4 mr-3" />
<p className="font-normal">Logout</p>
</div>
</div>
)}
</div>
);
}
export default Navbar;

View file

@ -0,0 +1,38 @@
import ReactQRCode from "react-qr-code";
import { useDarkMode } from "../hooks/useDarkMode";
export type Props = {
value: string;
size?: number;
className?: string;
// set the level to Q if there are overlays
// Q will improve error correction (so we can add overlays covering up to 25% of the QR)
// at the price of decreased information density (meaning the QR codes "pixels" have to be
// smaller to encode the same information).
// While that isn't that much of a problem for lightning addresses (because they are usually quite short),
// for invoices that contain larger amount of data those QR codes can get "harder" to read.
// (meaning you have to aim your phone very precisely and have to wait longer for the reader
// to recognize the QR code)
level?: "Q" | undefined;
};
function QRCode({ value, size, level, className }: Props) {
const isDarkMode = useDarkMode();
console.log(isDarkMode);
const fgColor = isDarkMode ? "#FFFFFF" : "#242424";
const bgColor = isDarkMode ? "#242424" : "#FFFFFF";
return (
<ReactQRCode
value={value}
size={size}
fgColor={fgColor}
bgColor={bgColor}
className={`"w-full h-auto rounded-md" ${className}`}
level={level}
/>
);
}
export default QRCode;

View file

@ -0,0 +1,12 @@
import { Toaster as HotToaster } from "react-hot-toast";
export default function Toaster() {
return (
<HotToaster
position="bottom-center"
toastOptions={{
duration: 4_000,
}}
/>
);
}

View file

@ -0,0 +1,45 @@
import { ReactNode } from "react";
import {
CheckmarkIcon,
ErrorIcon,
ToastOptions,
toast as hotToast,
} from "react-hot-toast";
type ToastType = "error" | "success";
interface ToastMethods {
success: (message: string | ReactNode, options?: ToastOptions) => void;
error: (message: string | ReactNode, options?: ToastOptions) => void;
custom: (
children: ReactNode,
type: ToastType,
options?: ToastOptions
) => void;
}
const toast: ToastMethods = {
success: (message: string | ReactNode, options?: ToastOptions) => {
toast.custom(message, "success", options);
},
error: (message: string | ReactNode, options?: ToastOptions) => {
toast.custom(message, "error", { duration: options?.duration ?? 8_000 });
},
custom: (children: ReactNode, type: ToastType, options?: ToastOptions) => {
hotToast.custom(
(t: { visible: boolean; id: string }) =>
t.visible ? (
<div className="bg-white dark:bg-surface-02dp px-4 py-3 drop-shadow-lg rounded-lg overflow-hidden flex flex-row items-center gap-3 text-gray-800 dark:text-neutral-200">
<div className="shrink-0">
{type == "success" && <CheckmarkIcon />}
{type == "error" && <ErrorIcon />}
</div>
<div>{children}</div>
</div>
) : null,
options
);
},
};
export default toast;

View file

@ -0,0 +1,21 @@
import { SVGAttributes } from "react";
export function CheckIcon(props: SVGAttributes<SVGElement>) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M18.3814 5.35432C18.738 5.56495 18.8564 6.02484 18.6458 6.3815L11.5591 18.3815C11.4426 18.5787 11.2424 18.7119 11.0156 18.7431C10.7887 18.7743 10.56 18.7002 10.3946 18.5418L5.48126 13.8366C5.18211 13.5501 5.17184 13.0753 5.45833 12.7761C5.74482 12.477 6.21958 12.4667 6.51874 12.7532L10.7487 16.804L17.3542 5.61874C17.5648 5.26208 18.0247 5.14369 18.3814 5.35432Z"
fill="black"
/>
</svg>
);
}

View file

@ -0,0 +1,17 @@
import { SVGAttributes } from "react";
export function CopyIcon(props: SVGAttributes<SVGElement>) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<rect width="10" height="14" x="6" y="6" rx="1.5" />
<path d="M8.064 5.064A1.5 1.5 0 018.5 5h7A1.5 1.5 0 0117 6.5v11a1.5 1.5 0 01-.064.436A1.5 1.5 0 0018 16.5v-11A1.5 1.5 0 0016.5 4h-7a1.5 1.5 0 00-1.436 1.064z" />
</svg>
);
}

View file

@ -0,0 +1,16 @@
import { SVGAttributes } from "react";
export function EditIcon(props: SVGAttributes<SVGElement>) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path d="M3.995 17.207V19.5a.5.5 0 00.5.5h2.298a.5.5 0 00.353-.146l9.448-9.448-3-3-9.452 9.448a.5.5 0 00-.147.353zM14.832 6.167l3 3 1.46-1.46a1 1 0 000-1.414l-1.585-1.586a1 1 0 00-1.414 0l-1.46 1.46z" />
</svg>
);
}

View file

@ -0,0 +1,21 @@
import { SVGAttributes } from "react";
export function EyeIcon(props: SVGAttributes<SVGElement>) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path d="M12 14a2 2 0 100-4 2 2 0 000 4z" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M21 12c0 2.761-4.03 5-9 5s-9-2.239-9-5 4.03-5 9-5 9 2.239 9 5zm-5 0a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
);
}

View file

@ -0,0 +1,27 @@
import { SVGAttributes } from "react";
export function InvoiceIcon(props: SVGAttributes<SVGElement>) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M7.5 4A1.5 1.5 0 006 5.5v13A1.5 1.5 0 007.5 20h9a1.5 1.5 0 001.5-1.5v-13A1.5 1.5 0 0016.5 4h-9zm6.854 4.354a.5.5 0 00-.708-.708l-4 4a.5.5 0 00.708.708l4-4zM11.5 8.5a1 1 0 11-2 0 1 1 0 012 0zm2 4a1 1 0 100-2 1 1 0 000 2zm-5 2.5a.5.5 0 01.5-.5h6a.5.5 0 010 1H9a.5.5 0 01-.5-.5zm.5 1.5a.5.5 0 000 1h6a.5.5 0 000-1H9z"
/>
</svg>
);
}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
></svg>;

View file

@ -0,0 +1,16 @@
import { SVGAttributes } from "react";
export function LightningIcon(props: SVGAttributes<SVGElement>) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path d="M18.496 10.709l-8.636 8.88c-.24.246-.638-.039-.482-.345l3.074-6.066a.3.3 0 00-.268-.436H5.718a.3.3 0 01-.214-.51l8.01-8.115c.232-.235.618.023.489.328L11.706 9.86a.3.3 0 00.28.417l6.291-.078a.3.3 0 01.22.509z" />
</svg>
);
}

View file

@ -0,0 +1,21 @@
import { SVGAttributes } from "react";
export function LogoIcon(props: SVGAttributes<SVGElement>) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M13.6281 5.03035L20.9945 12.43L20.9956 12.431C21.4028 12.84 21.8601 13.0639 22.279 13.1062C22.6711 13.1459 23.0667 13.0322 23.4219 12.6949L19.4029 2.22631C18.9491 1.2345 17.6421 0.998839 16.8705 1.7739L13.6281 5.03035ZM20.5966 12.8277L20.5967 12.8278L20.5965 12.828C21.5558 13.7914 22.8871 14.0212 23.8507 13.0622L20.5884 16.3091L19.0591 17.8312L17.9915 18.8938L16.1182 17.0137C17.026 15.6333 16.8781 13.76 15.6662 12.5406L14.3908 11.2573C14.2095 11.0748 13.9176 11.074 13.7351 11.2553L13.1055 11.8809L11.5093 10.2748C11.2323 9.99609 10.7744 10.0126 10.5176 10.3196C10.2928 10.5907 10.3315 10.9906 10.5786 11.2392L12.1549 12.8252L10.9035 14.0685L9.3073 12.4624C9.03028 12.1837 8.5705 12.1981 8.31561 12.5072C8.0928 12.7764 8.13155 13.1762 8.37864 13.4249L9.95488 15.0109L9.32725 15.6346C9.14479 15.8159 9.14392 16.1077 9.32522 16.2902L10.6006 17.5735C11.8123 18.7927 13.6847 18.9526 15.0709 18.054L16.9452 19.9351L16.3577 20.5197L14.3527 22.5153C13.3889 23.4743 11.8301 23.4707 10.8708 22.5072L0.717064 12.3077C-0.242097 11.344 -0.238627 9.78546 0.725161 8.82627L6.96087 2.61994C7.92465 1.66089 9.4834 1.66451 10.4427 2.62803L20.5966 12.8277Z"
fill="currentColor"
/>
</svg>
);
}

View file

@ -0,0 +1,36 @@
import { SVGAttributes } from "react";
export function LogoutIcon(props: SVGAttributes<SVGElement>) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M19.2854 12.0002L11.2727 12.0002"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
<path
d="M16.5101 15.6364L19.9999 12L16.5101 8.36363"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
<path
d="M13.4545 7V4H4V20H13.4545V17"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
);
}

View file

@ -0,0 +1,18 @@
import { SVGAttributes } from "react";
export function PlusIcon(props: SVGAttributes<SVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
{...props}
>
<path
fillRule="evenodd"
d="M12 3.25a.75.75 0 01.75.75v7.25H20a.75.75 0 010 1.5h-7.25V20a.75.75 0 01-1.5 0v-7.25H4a.75.75 0 010-1.5h7.25V4a.75.75 0 01.75-.75z"
clipRule="evenodd"
></path>
</svg>
);
}

View file

@ -0,0 +1,25 @@
import { SVGAttributes } from "react";
export function SearchIcon(props: SVGAttributes<SVGElement>) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M10.5 5.5a5 5 0 100 10 5 5 0 000-10zm-6.5 5a6.5 6.5 0 1113 0 6.5 6.5 0 01-13 0z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M14.47 14.47a.75.75 0 011.06 0l4 4a.75.75 0 11-1.06 1.06l-4-4a.75.75 0 010-1.06z"
/>
</svg>
);
}

View file

@ -0,0 +1,33 @@
import { SVGAttributes } from "react";
export function TransactionsIcon(props: SVGAttributes<SVGElement>) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<circle cx="5.5" cy="7.5" r="1.5" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M8 6.5a.5.5 0 01.5-.5h11a.5.5 0 010 1h-11a.5.5 0 01-.5-.5zM8 8.5a.5.5 0 01.5-.5h6a.5.5 0 010 1h-6a.5.5 0 01-.5-.5z"
/>
<circle cx="5.5" cy="12" r="1.5" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M8 11a.5.5 0 01.5-.5h8a.5.5 0 010 1h-8A.5.5 0 018 11zM8 13a.5.5 0 01.5-.5h7a.5.5 0 010 1h-7A.5.5 0 018 13z"
/>
<circle cx="5.5" cy="16.5" r="1.5" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M8 15.5a.5.5 0 01.5-.5H18a.5.5 0 010 1H8.5a.5.5 0 01-.5-.5zM8 17.5a.5.5 0 01.5-.5h4a.5.5 0 010 1h-4a.5.5 0 01-.5-.5z"
/>
</svg>
);
}

View file

@ -0,0 +1,25 @@
import { SVGAttributes } from "react";
export function WalletIcon(props: SVGAttributes<SVGElement>) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12 8a2 2 0 012-2h4a2 2 0 012 2v8a2 2 0 01-2 2h-4a2 2 0 01-2-2V8zm2-1a1 1 0 00-1 1v8a1 1 0 001 1h4a1 1 0 001-1V8a1 1 0 00-1-1h-4z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M5.5 6A1.5 1.5 0 004 7.5v9A1.5 1.5 0 005.5 18h10a1.5 1.5 0 001.5-1.5v-9A1.5 1.5 0 0015.5 6h-10zm2 7.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3z"
/>
</svg>
);
}

51
frontend/src/fonts.css Normal file
View file

@ -0,0 +1,51 @@
@font-face {
font-family: "Inter var";
font-weight: 100 900;
font-display: swap;
font-style: normal;
font-named-instance: "Regular";
src: url(/fonts/Inter-roman.var.woff2) format("woff2");
}
@font-face {
font-family: "Inter var";
font-weight: 100 900;
font-display: swap;
font-style: italic;
font-named-instance: "Italic";
src: url(/fonts/Inter-italic.var.woff2) format("woff2");
}
/* NOTE: source: https://gwfh.mranftl.com/fonts/work-sans?subsets=latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Work Sans";
font-style: normal;
font-weight: 500;
src: url("/fonts/work-sans-v18-latin-500.eot"); /* IE9 Compat Modes */
src: url("/fonts/work-sans-v18-latin-500.eot?#iefix")
format("embedded-opentype"),
/* IE6-IE8 */ url("/fonts/work-sans-v18-latin-500.woff2") format("woff2"),
/* Super Modern Browsers */ url("/fonts/work-sans-v18-latin-500.woff")
format("woff"),
/* Modern Browsers */ url("/fonts/work-sans-v18-latin-500.ttf")
format("truetype"),
/* Safari, Android, iOS */
url("/fonts/work-sans-v18-latin-500.svg#WorkSans") format("svg"); /* Legacy iOS */
}
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Work Sans";
font-style: normal;
font-weight: 600;
src: url("/fonts/work-sans-v18-latin-600.eot"); /* IE9 Compat Modes */
src: url("/fonts/work-sans-v18-latin-600.eot?#iefix")
format("embedded-opentype"),
/* IE6-IE8 */ url("/fonts/work-sans-v18-latin-600.woff2") format("woff2"),
/* Super Modern Browsers */ url("/fonts/work-sans-v18-latin-600.woff")
format("woff"),
/* Modern Browsers */ url("/fonts/work-sans-v18-latin-600.ttf")
format("truetype"),
/* Safari, Android, iOS */
url("/fonts/work-sans-v18-latin-600.svg#WorkSans") format("svg"); /* Legacy iOS */
}

View file

@ -0,0 +1,7 @@
import useSWR from "swr";
import { App } from "../types";
import { swrFetcher } from "../swr";
export function useApp(pubkey: string | undefined) {
return useSWR<App>(pubkey && `/api/apps/${pubkey}`, swrFetcher);
}

View file

@ -0,0 +1,7 @@
import useSWR from "swr";
import { App } from "../types";
import { swrFetcher } from "../swr";
export function useApps() {
return useSWR<App[]>("/api/apps", swrFetcher);
}

View file

@ -0,0 +1,6 @@
import useSWR from "swr";
import { swrFetcher } from "../swr";
export function useCSRF() {
return useSWR<string>("/api/csrf", swrFetcher);
}

View file

@ -0,0 +1,23 @@
import React from "react";
export function useDarkMode() {
const [isDarkMode, setIsDarkMode] = React.useState(false);
React.useEffect(() => {
const darkModeMediaQuery = window.matchMedia(
"(prefers-color-scheme: dark)"
);
const handleDarkModeChange = (event: MediaQueryListEvent) => {
setIsDarkMode(event.matches);
};
darkModeMediaQuery.addEventListener("change", handleDarkModeChange);
setIsDarkMode(darkModeMediaQuery.matches);
return () => {
darkModeMediaQuery.removeEventListener("change", handleDarkModeChange);
};
}, []);
return isDarkMode;
}

View file

@ -0,0 +1,7 @@
import useSWR from "swr";
import { InfoResponse } from "../types";
import { swrFetcher } from "../swr";
export function useInfo() {
return useSWR<InfoResponse>("/api/info", swrFetcher);
}

View file

@ -0,0 +1,31 @@
import { useLocation, useNavigate } from "react-router-dom";
import React from "react";
import { useUser } from "./useUser";
const RETURN_TO_KEY = "returnTo";
export function useLogin() {
const { data: user, isLoading } = useUser();
const location = useLocation();
const navigate = useNavigate();
React.useEffect(() => {
if (!isLoading) {
if (user) {
const returnTo = window.localStorage.getItem(RETURN_TO_KEY);
if (returnTo) {
window.localStorage.removeItem(RETURN_TO_KEY);
navigate(returnTo);
}
}
if (!user && ["/login", "/about"].indexOf(location.pathname) < 0) {
const returnTo = location.pathname + location.search;
window.localStorage.setItem(RETURN_TO_KEY, returnTo);
navigate("/login");
}
}
}, [user, isLoading, location.pathname, location.search, navigate]);
return null;
}

View file

@ -0,0 +1,7 @@
import useSWR from "swr";
import { swrFetcher } from "../swr";
import { User } from "../types";
export function useUser() {
return useSWR<User>("/api/user/me", swrFetcher);
}

15
frontend/src/index.css Normal file
View file

@ -0,0 +1,15 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html {
display: flex;
min-height: 100%;
}
body,
#root {
min-height: 100%;
display: flex;
flex-direction: column;
flex: 1;
}

11
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,11 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import "./fonts.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View file

@ -0,0 +1,183 @@
import nostrBitcoin from "../assets/images/nostr-bitcoin.png";
function About() {
return (
<>
<div className="bg-white rounded-md shadow p-4 lg:p-8 mb-8 dark:bg-surface-02dp">
<h3 className="font-bold text-2xl font-headline mb-4 dark:text-white">
tl;dr
</h3>
<p className="leading-relaxed dark:text-neutral-400">
Nostr Wallet Connect (NWC) is a way for applications like Nostr
clients to access a remote Lightning wallet through a{" "}
<a
href="https://github.com/getAlby/nips/blob/master/47.md"
className="dark:text-gray-300 underline"
>
standardized protocol
</a>
. For instance, you can link your{" "}
<a
href="https://getalby.com"
className="dark:text-gray-300 underline"
>
Alby account
</a>{" "}
with any other application using this feature.
</p>
</div>
<div className="bg-white rounded-md shadow p-4 lg:p-8 mb-8 dark:bg-surface-02dp">
<h3 className="font-bold text-2xl font-headline mb-4 dark:text-white">
How to connect your app?
</h3>
<ol className="list-decimal list-inside leading-relaxed dark:text-neutral-400">
<li className="mb-2">
<strong className="dark:text-white">
Create a new app connection
</strong>
<p className="ml-6">
Enable the application by creating a permissioned connection to
your wallet. This access can be revoked at any time.
</p>
</li>
<li className="mb-2">
<strong className="dark:text-white">Connect your app</strong>
<p className="ml-6">
Connect your application by adding the connection secret. Either
by scanning the connect QR code or clicking a link.
</p>
</li>
<li className="mb-2">
<strong className="dark:text-white">Done</strong>
<p className="ml-6">
Zapp and Boost! Your lightning wallet is available where you need
it.
<br />
And if you ever want to disable it again, simply disconnect the
connection again.
</p>
</li>
</ol>
</div>
<div className="bg-white rounded-md shadow p-4 lg:p-8 mb-8 dark:bg-surface-02dp">
<h3 className="font-bold text-2xl font-headline mb-4 dark:text-white">
FAQs
</h3>
<h4 className="font-bold text-l font-headline mt-4 dark:text-white">
How does it work?
</h4>
<p className="leading-relaxed dark:text-neutral-400">
Nostr Wallet Connect uses the existing Nostr infrastructure to allow
applications to communicate with lightning wallets. This makes it easy
for developers to integrate and gives the users full flexibility on
choice.
<br />
To learn more about the details have a look at&nbsp;
<a
href="https://github.com/getAlby/nips/blob/7-wallet-connect-patch/47.md"
className="dark:text-gray-300 underline"
>
the specs (NIP47)
</a>
</p>
<h4 className="font-bold text-l font-headline mt-6 dark:text-white">
Which apps support it?
</h4>
<p className="leading-relaxed dark:text-neutral-400">
<a
href="https://linktr.ee/amethyst.social"
className="dark:text-gray-300 underline"
>
Amethyst
</a>
is the first Nostr Client to fully support Nostr Wallet Connect. More
are working on support for it and are following shortly.
</p>
<h4 className="font-bold text-l font-headline mt-6 dark:text-white">
Is it open source?
</h4>
<p className="leading-relaxed dark:text-neutral-400">
Of course! Nostr Wallet Connect is an open standard and our
implementation is&nbsp;
<a
href="https://github.com/getAlby/nostr-wallet-connect/"
className="dark:text-gray-300 underline"
>
open source on GitHub
</a>
</p>
<h4 className="font-bold text-l font-headline mt-6 dark:text-white">
Can I run my own?
</h4>
<p className="leading-relaxed dark:text-neutral-400">
Yes, Nostr Wallet Connect is for every wallet and can easily be
self-hosted.
<br />
We are looking for contributors to add support for more wallet
backends. If you're interested please contact us
<a
href="https://github.com/getAlby/nostr-wallet-connect/"
className="dark:text-gray-300 underline"
>
on GitHub
</a>
or
<a
href="mailto:hello@getalby.com"
className="dark:text-gray-300 underline"
>
hello@getalby.com
</a>
</p>
</div>
<div className="bg-white rounded-md shadow p-4 lg:p-8 dark:bg-surface-02dp">
<h3 className="font-bold text-2xl font-headline mb-4 dark:text-white">
For developers
</h3>
<p className="mb-1 leading-relaxed dark:text-neutral-400">
Nostr Wallet Connect is an open protocol enabling applications to
interact with bitcoin lightning wallets. It allows users to connect
their existing wallets to your application allowing you to easily
integrate bitcoin lightning functionality but still focus on the core
functionality of your app.
</p>
<ul className="list-inside list-disc mb-8 dark:text-neutral-400">
<li>
<a
href="https://github.com/getAlby/nips/blob/master/47.md"
className="dark:text-gray-300 underline"
>
Read the specs (NIP47)
</a>
</li>
<li>
<a
href="https://github.com/getAlby/alby-js-sdk#nostr-wallet-connect-documentation"
className="dark:text-gray-300 underline"
>
Look at the JavaScript SDK
</a>
</li>
</ul>
<p className="sm:text-2xl font-bold font-headline mb-4 text-center dark:text-white">
Happy zapping!
<img
src={nostrBitcoin}
alt="Bitcoin on Nostr"
className="w-80 mx-auto"
/>
</p>
</div>
</>
);
}
export default About;

View file

@ -0,0 +1,54 @@
import nwcLogo from "../assets/images/nwc-logo.svg";
import albyHead from "../assets/images/alby-head.svg";
import { useInfo } from "../hooks/useInfo";
function Login() {
const { data: info } = useInfo();
const isAlby = info?.backendType === "ALBY";
return (
<div className="text-center pt-4">
<img
alt="Nostr Wallet Connect logo"
className="mx-auto my-4"
width="128"
height="120"
src={nwcLogo}
/>
<h1 className="font-headline text-3xl sm:text-4xl mb-2 dark:text-white">
Nostr Wallet Connect
</h1>
<h2 className="text-md text-gray-700 dark:text-neutral-300">
Securely connect your {isAlby ? "Alby Account" : "LND Wallet"} to Nostr
clients and applications.
</h2>
{isAlby && (
<p className="my-8">
<a
className=" inline-flex cursor-pointer items-center justify-center rounded-md transition-all px-10 py-4 text-black bg-gradient-to-r from-[#ffde6e] to-[#f8c455]"
href="/alby/auth"
>
<img
src={albyHead}
width="400"
height="400"
className="w-[24px] mr-2"
/>
Log in with Alby Account
</a>
</p>
)}
<p>
<a href="/about" className="text-purple-700 dark:text-purple-400">
How does it work?
</a>
</p>
</div>
);
}
export default Login;

View file

@ -0,0 +1,17 @@
function NotFound() {
return (
<div className="bg-white rounded-md shadow px-4 lg:p-8 dark:bg-surface-02dp">
<h3 className="font-bold text-2xl font-headline mb-4 dark:text-white">
404 Page Not Found
</h3>
<p className="leading-relaxed dark:text-neutral-400 mb-4">
The page you are looking for does not exist.
</p>
<a href="/" className="text-purple-700 dark:text-purple-400">
Return Home
</a>
</div>
);
}
export default NotFound;

View file

@ -0,0 +1,146 @@
import { useEffect, useRef, useState } from "react";
import { useLocation, Navigate } from "react-router-dom";
import { CreateAppResponse } from "../../types";
import { EyeIcon } from "../../components/icons/EyeIcon";
import { CopyIcon } from "../../components/icons/CopyIcon";
import { LogoIcon } from "../../components/icons/LogoIcon";
import QRCode from "../../components/QRCode";
export default function AppCreated() {
const { state } = useLocation();
const createAppResponse = state as CreateAppResponse;
const [copied, setCopied] = useState(false);
const [isPopupVisible, setPopupVisible] = useState(false);
const popupRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
popupRef.current &&
!popupRef.current.contains(event.target as Node)
) {
setPopupVisible(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
if (!createAppResponse) {
return <Navigate to="/apps/new" state={{ from: "/apps/success" }} />;
}
const pairingUri = createAppResponse.pairingUri;
const copyToClipboard = () => {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(pairingUri);
} else {
// Fallback for older browsers
const textArea = document.createElement("textarea");
textArea.value = pairingUri;
textArea.style.position = "absolute";
textArea.style.opacity = "0";
document.body.appendChild(textArea);
textArea.select();
new Promise((res, rej) => {
document.execCommand("copy") ? res(pairingUri) : rej();
textArea.remove();
});
}
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
};
const togglePopup = () => {
setPopupVisible(!isPopupVisible);
};
return (
<div className="w-full max-w-screen-sm mx-auto">
<h2 className="font-bold text-2xl font-headline mb-2 dark:text-white text-center">
🚀 Almost there!
</h2>
<div className="font-medium text-center mb-8 dark:text-white">
Complete the last step of the setup by pasting or scanning your
connection's pairing secret in the desired app to finalise the
connection.
</div>
<div className="flex flex-col">
<a
href={pairingUri}
className="w-full inline-flex bg-purple-700 cursor-pointer duration-150 focus:outline-none hover:bg-purple-900 items-center justify-center px-5 py-4 rounded-md shadow text-white transition mb-2"
>
<LogoIcon className="inline w-6 mr-2" />
<p className="font-medium">Open in supported app</p>
</a>
<div className="text-center text-xs text-gray-600 dark:text-neutral-500">
Only connect with apps you trust!
</div>
<div className="dark:text-white text-sm text-center mt-8 mb-1">
Manually pair app
</div>
<button
id="copy-button"
className={`w-full inline-flex items-center justify-center px-3 py-2 cursor-pointer duration-150 transition border dark:border-white/10 ${
copied
? "bg-green-600 text-white"
: "bg-white dark:bg-surface-02dp text-purple-700 dark:text-neutral-200 hover:bg-gray-50 dark:hover:bg-surface-16dp"
} bg-origin-border rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-purple-700 my-2`}
onClick={copyToClipboard}
>
<CopyIcon className="inline w-6 mr-2" />
<span id="copy-text">
{copied ? "Copied to clipboard!" : "Copy pairing secret"}
</span>
</button>
<button
id="copy-button"
className={`w-full inline-flex items-center justify-center px-3 py-2 cursor-pointer duration-150 transition border dark:border-white/10 bg-white dark:bg-surface-02dp text-purple-700 dark:text-neutral-200 hover:bg-gray-50 dark:hover:bg-surface-16dp bg-origin-border rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-purple-700 mb-2`}
onClick={togglePopup}
>
<EyeIcon className="inline w-6 mr-2" />
<span id="copy-text">QR Code</span>
</button>
{/* ... Remaining JSX conversion for QR code and other elements */}
</div>
<div
className={`fixed inset-0 items-center justify-center ${
isPopupVisible ? "flex" : "hidden"
}`}
>
<div
onClick={togglePopup}
className="fixed inset-0 bg-gray-900 opacity-50"
></div>
<div className="bg-white dark:bg-surface-02dp p-4 lg:px-6 rounded shadow relative">
<h2 className="mb-4 font-semibold text-lg lg:text-xl font-headline dark:text-white">
Scan QR Code in the app to pair
</h2>
<a
href={pairingUri}
target="_blank"
className="block border-4 border-purple-600 rounded-lg p-4 lg:p-6"
>
<QRCode value={pairingUri} size={256} />
</a>
<button
onClick={togglePopup}
className="w-full inline-flex font-semibold items-center justify-center px-3 py-2 cursor-pointer duration-150 transition bg-white text-gray-700 dark:bg-surface-02dp dark:text-neutral-200 dark:border-neutral-800 hover:bg-gray-50 dark:hover:bg-surface-16dp bg-origin-border shadow rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-purple-700 mt-4"
>
Close
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,81 @@
import Loading from "../../components/Loading";
import { useApps } from "../../hooks/useApps";
import { PlusIcon } from "../../components/icons/PlusIcon";
import { useNavigate } from "react-router-dom";
function AppsList() {
const { data: apps } = useApps();
const navigate = useNavigate();
if (!apps) {
return <Loading />;
}
return (
<>
<div className="mb-4 flex justify-between items-center">
<h2 className="font-bold text-2xl font-headline dark:text-white">
Connected apps
</h2>
<a
className="inline-flex bg-purple-700 cursor-pointer dark:text-neutral-200 duration-150 focus-visible:ring-2 focus-visible:ring-offset-2 focus:outline-none font-medium items-center justify-center px-3 md:px-6 py-2 md:py-3 rounded-lg shadow text-white transition {{if not .User}}opacity-50{{else}}hover:bg-purple-900{{end}} text-sm md:text-base"
href="/apps/new"
>
<PlusIcon className="mr-2 text-white w-6 h-6" />
Connect app
</a>
</div>
<div className="rounded-lg border border-gray-200 dark:border-white/10 overflow-hidden">
<table className="table-fixed w-full text-sm text-left">
<thead className="text-xs text-gray-900 uppercase bg-gray-50 dark:bg-surface-08dp dark:text-white rounded-t-lg">
<tr>
<th scope="col" className="px-6 py-3 w-full">
Name
</th>
<th scope="col" className="px-6 py-3 w-40 hidden md:table-cell">
Last used
</th>
<th scope="col" className="px-6 py-3 w-24"></th>
</tr>
</thead>
<tbody className="divide-y dark:divide-white/10">
{!apps.length && (
<tr className="bg-white dark:bg-surface-02dp">
<td
colSpan={3}
className="px-6 py-16 text-center text-gray-500 dark:text-neutral-400"
>
No apps connected yet.
</td>
</tr>
)}
{apps.map((app, index) => (
<tr
key={index}
onClick={() => navigate(`/apps/${app.nostrPubkey}`)}
className="bg-white dark:bg-surface-02dp cursor-pointer hover:bg-purple-50 dark:hover:bg-surface-16dp"
>
<td className="px-6 py-4 text-gray-500 dark:text-white">
{app.name}
</td>
<td className="px-6 py-4 text-gray-500 dark:text-neutral-400 hidden md:table-cell">
{app.lastEventAt
? new Date(app.lastEventAt).toLocaleDateString()
: "-"}
</td>
<td className="px-6 py-4 text-purple-700 dark:text-purple-400 text-right">
<a href={`/apps/${app.nostrPubkey}`}>Details</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
);
}
export default AppsList;

View file

@ -0,0 +1,400 @@
import { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import toast from "react-hot-toast";
import {
BudgetRenewalType,
CreateAppResponse,
IconMap,
NIP_47_GET_BALANCE_METHOD,
NIP_47_GET_INFO_METHOD,
NIP_47_LIST_TRANSACTIONS_METHOD,
NIP_47_LOOKUP_INVOICE_METHOD,
NIP_47_MAKE_INVOICE_METHOD,
NIP_47_PAY_INVOICE_METHOD,
RequestMethodType,
nip47MethodDescriptions,
validBudgetRenewals,
} from "../../types";
import { handleFetchError, validateFetchResponse } from "../../utils/fetch";
import { useCSRF } from "../../hooks/useCSRF";
import { WalletIcon } from "../../components/icons/WalletIcon";
import { LightningIcon } from "../../components/icons/LightningIcon";
import { InvoiceIcon } from "../../components/icons/InvoiceIcon";
import { SearchIcon } from "../../components/icons/SearchIcon";
import { TransactionsIcon } from "../../components/icons/TransactionsIcon";
import { EditIcon } from "../../components/icons/EditIcon";
import { useUser } from "../../hooks/useUser";
const NewApp = () => {
const { data: csrf } = useCSRF();
const { data: user } = useUser();
const navigate = useNavigate();
const location = useLocation();
const queryParams = new URLSearchParams(location.search);
const [edit, setEdit] = useState(false);
const nameParam = (queryParams.get("name") || queryParams.get("c")) ?? "";
const [appName, setAppName] = useState(nameParam);
const pubkey = queryParams.get("pubkey") ?? "";
const returnTo = queryParams.get("return_to") ?? "";
const budgetRenewalParam = queryParams.get(
"budget_renewal"
) as BudgetRenewalType;
const budgetRenewal: BudgetRenewalType = validBudgetRenewals.includes(
budgetRenewalParam
)
? budgetRenewalParam
: "monthly";
const reqMethodsParam = queryParams.get("request_methods");
const [requestMethods, setRequestMethods] = useState(
reqMethodsParam ?? Object.keys(nip47MethodDescriptions).join(" ")
);
const maxAmountParam = queryParams.get("max_amount") ?? "";
const [maxAmount, setMaxAmount] = useState(
parseInt(maxAmountParam || "100000")
);
const parseExpiresParam = (expiresParam: string): string => {
if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/.test(expiresParam)) {
const d = new Date(expiresParam);
const isIso =
d instanceof Date &&
!isNaN(d.getTime()) &&
d.toISOString() === expiresAtParam;
if (isIso) {
return expiresParam;
}
}
if (!isNaN(parseInt(expiresParam))) {
return new Date(parseInt(expiresAtParam as string) * 1000).toISOString();
}
return "";
};
// Only timestamp in seconds or ISO string is expected
const expiresAtParam = parseExpiresParam(queryParams.get("expires_at") ?? "");
const [expiresAt, setExpiresAt] = useState(expiresAtParam ?? "");
const [days, setDays] = useState(0);
const [expireOptions, setExpireOptions] = useState(false);
const today = new Date();
const handleDays = (days: number) => {
setDays(days);
if (!days) {
setExpiresAt("");
return;
}
const expiryDate = new Date(today.getTime() + days * 24 * 60 * 60 * 1000);
setExpiresAt(expiryDate.toISOString());
};
const handleRequestMethodChange = (
event: React.ChangeEvent<HTMLInputElement>
) => {
const rm = event.target.value;
if (requestMethods.includes(rm)) {
// If checked and item is already in the list, remove it
const newMethods = requestMethods
.split(" ")
.filter((reqMethod) => reqMethod !== rm)
.join(" ");
setRequestMethods(newMethods);
} else {
setRequestMethods(`${requestMethods} ${rm}`);
}
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!csrf) {
throw new Error("No CSRF token");
}
const formData = new FormData();
formData.append("name", appName);
formData.append("maxAmount", maxAmount.toString());
formData.append("budgetRenewal", budgetRenewal);
formData.append("expiresAt", expiresAt);
formData.append("requestMethods", requestMethods);
formData.append("pubkey", pubkey);
formData.append("returnTo", returnTo);
try {
const response = await fetch("/api/apps", {
method: "POST",
headers: {
"X-CSRF-Token": csrf,
},
body: formData,
// TODO: send consider sending JSON data
/*body: JSON.stringify({
name: appName,
pubkey: pubkey,
maxAmount: maxAmount.toString(),
budgetRenewal: budgetRenewal,
expiresAt: expiresAt,
requestMethods: requestMethods,
returnTo: returnTo,
}),*/
});
await validateFetchResponse(response);
const createAppResponse: CreateAppResponse = await response.json();
navigate("/apps/created", {
state: createAppResponse,
});
toast.success("App created!");
} catch (error) {
handleFetchError("Failed to create app", error);
}
};
const iconMap: IconMap = {
[NIP_47_GET_BALANCE_METHOD]: WalletIcon,
[NIP_47_GET_INFO_METHOD]: WalletIcon,
[NIP_47_LIST_TRANSACTIONS_METHOD]: TransactionsIcon,
[NIP_47_LOOKUP_INVOICE_METHOD]: SearchIcon,
[NIP_47_MAKE_INVOICE_METHOD]: InvoiceIcon,
[NIP_47_PAY_INVOICE_METHOD]: LightningIcon,
};
const expiryOptions: Record<string, number> = {
"1 week": 7,
"1 month": 30,
"1 year": 365,
Never: 0,
};
const budgetOptions: Record<string, number> = {
"10k": 10_000,
"25k": 25_000,
"50k": 50_000,
"100k": 100_000,
"1M": 100_000_000,
Unlimited: 0,
};
return (
<div>
<h2 className="font-bold text-2xl font-headline mb-4 dark:text-white">
{nameParam ? `Connect to ${appName}` : "Connect a new app"}
</h2>
<form onSubmit={handleSubmit} acceptCharset="UTF-8">
<div className="bg-white dark:bg-surface-02dp rounded-md shadow p-4 md:p-8">
{!nameParam && (
<>
<label
htmlFor="name"
className="block font-medium text-gray-900 dark:text-white"
>
Name
</label>
<input
readOnly={!!nameParam}
type="text"
name="name"
value={appName}
id="name"
onChange={(e) => setAppName(e.target.value)}
required
autoComplete="off"
className="bg-gray-50 border border-gray-300 text-gray-900 focus:ring-purple-700 dark:focus:ring-purple-600 dark:ring-offset-gray-800 focus:ring-2 text-sm rounded-lg block w-full p-2.5 dark:bg-surface-00dp dark:border-gray-700 dark:placeholder-gray-400 dark:text-white"
/>
<p className="mt-1 mb-6 text-xs text-gray-500 dark:text-gray-400">
Name of the app or purpose of the connection
</p>
</>
)}
<div className="flex justify-between items-center mb-2 text-gray-800 dark:text-white">
<p className="text-lg font-medium">Authorize the app to:</p>
{!reqMethodsParam && (
<EditIcon
onClick={() => setEdit(true)}
className="text-gray-800 dark:text-gray-300 cursor-pointer w-6"
/>
)}
</div>
<div className="mb-6">
<ul className="flex flex-col w-full">
{Object.keys(nip47MethodDescriptions).map((rm, index) => {
const RequestMethodIcon = iconMap[rm as RequestMethodType];
return (
<li
key={index}
className={`w-full ${
rm == "pay_invoice" ? "order-last" : ""
} ${!edit && !requestMethods.includes(rm) ? "hidden" : ""}`}
>
<div className="flex items-center mb-2">
{RequestMethodIcon && (
<RequestMethodIcon
className={`text-gray-800 dark:text-gray-300 w-5 mr-3 ${
edit ? "hidden" : ""
}`}
/>
)}
<input
type="checkbox"
id={rm}
value={rm}
checked={requestMethods.includes(rm)}
onChange={handleRequestMethodChange}
className={` ${
!edit ? "hidden" : ""
} w-4 h-4 mr-4 text-purple-700 bg-gray-50 border border-gray-300 rounded focus:ring-purple-700 dark:focus:ring-purple-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-surface-00dp dark:border-gray-700 cursor-pointer`}
/>
<label
htmlFor={rm}
className="text-gray-800 dark:text-gray-300 cursor-pointer"
>
{nip47MethodDescriptions[rm as RequestMethodType]}
</label>
</div>
{rm == "pay_invoice" && (
<div
className={`pt-2 pb-2 pl-5 ml-2.5 border-l-2 border-l-gray-200 dark:border-l-gray-400 ${
!requestMethods.includes(rm)
? edit
? "pointer-events-none opacity-30"
: "hidden"
: ""
}`}
>
{!maxAmountParam ? (
<>
<p className="text-gray-600 dark:text-gray-300 mb-2 text-sm">
Monthly budget
</p>
<div
id="budget-allowance-limits"
className="grid grid-cols-6 grid-rows-2 md:grid-rows-1 md:grid-cols-6 gap-2 text-xs text-gray-800 dark:text-neutral-200"
>
{Object.keys(budgetOptions).map((budget) => {
return (
<>
<div
onClick={() =>
setMaxAmount(budgetOptions[budget])
}
className={`col-span-2 md:col-span-1 cursor-pointer rounded border-2 ${
maxAmount == budgetOptions[budget]
? "border-purple-700 dark:border-purple-300 text-purple-700 bg-purple-100 dark:bg-purple-900"
: "border-gray-200 dark:border-gray-400"
} text-center py-4 dark:text-white`}
>
{budget}
<br />
{budgetOptions[budget]
? "sats"
: "#reckless"}
</div>
</>
);
})}
</div>
</>
) : (
<>
<p className="text-gray-600 dark:text-gray-300 text-sm">
<span className="capitalize">
{budgetRenewal}
</span>{" "}
budget: {maxAmount} sats
</p>
</>
)}
</div>
)}
</li>
);
})}
</ul>
</div>
{!expiresAtParam ? (
<>
<div
onClick={() => setExpireOptions(true)}
className={`${
expireOptions ? "hidden" : ""
} cursor-pointer text-sm font-medium text-purple-700 dark:text-purple-500`}
>
+ Add connection expiry time
</div>
{expireOptions && (
<div className="text-gray-800 dark:text-neutral-200">
<p className="text-lg font-medium mb-2">
Connection expiry time
</p>
<div
id="expiry-days"
className="grid grid-cols-4 gap-2 text-xs"
>
{Object.keys(expiryOptions).map((expiry) => {
return (
<div
onClick={() => handleDays(expiryOptions[expiry])}
className={`cursor-pointer rounded border-2 ${
days == expiryOptions[expiry]
? "border-purple-700 dark:border-purple-300 text-purple-700 bg-purple-100 dark:bg-purple-900"
: "border-gray-200 dark:border-gray-400"
} text-center py-4`}
>
{expiry}
</div>
);
})}
</div>
</div>
)}
</>
) : (
<>
<p className="text-lg font-medium mb-2 text-gray-800 dark:text-neutral-200">
Connection expiry time
</p>
<p className="text-gray-600 dark:text-gray-300 text-sm">
{new Date(parseInt(expiresAtParam) * 1000).toISOString()}
</p>
</>
)}
{user && user.email && (
<p className="mt-8 pt-4 border-t border-gray-300 dark:border-gray-700 text-sm text-gray-500 dark:text-neutral-300 text-center">
You're logged in as{" "}
<span className="font-mono">{user.email}</span>
</p>
)}
</div>
<div className="mt-6 flex flex-col sm:flex-row sm:justify-center">
{!pubkey && (
<a
href="/apps"
className="inline-flex p-4 underline cursor-pointer duration-150 items-center justify-center text-gray-700 dark:text-neutral-300 w-full sm:w-[250px] order-last sm:order-first"
>
Cancel
</a>
)}
<button
type="submit"
className="inline-flex w-full sm:w-[250px] bg-purple-700 cursor-pointer dark:text-neutral-200 duration-150 focus-visible:ring-2 focus-visible:ring-offset-2 focus:outline-none font-medium hover:bg-purple-900 items-center justify-center px-5 py-3 rounded-md shadow text-white transition"
>
{pubkey ? "Connect" : "Next"}
</button>
</div>
</form>
</div>
);
};
export default NewApp;

View file

@ -0,0 +1,142 @@
import Loading from "../../components/Loading";
import { useNavigate, useParams } from "react-router-dom";
import { useInfo } from "../../hooks/useInfo";
import { useApp } from "../../hooks/useApp";
import { handleFetchError, validateFetchResponse } from "../../utils/fetch";
import toast from "../../components/Toast";
import { useCSRF } from "../../hooks/useCSRF";
import { RequestMethodType, nip47MethodDescriptions } from "../../types";
function ShowApp() {
const { data: info } = useInfo();
const { data: csrf } = useCSRF();
const { pubkey } = useParams() as { pubkey: string };
const { data: app } = useApp(pubkey);
const navigate = useNavigate();
if (!app || !info) {
return <Loading />;
}
const handleDelete = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
try {
if (!csrf) {
throw new Error("No CSRF token");
}
const response = await fetch(`/api/apps/${app.nostrPubkey}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrf,
},
});
await validateFetchResponse(response);
navigate("/apps");
toast.success("App disconnected");
} catch (error) {
await handleFetchError("Failed to delete app", error);
}
};
return (
<div>
<h2 className="font-bold text-2xl font-headline dark:text-white">
{app ? app.name : "Fetching app..."}
</h2>
<p className="text-gray-600 dark:text-neutral-400 text-sm">
{app ? app.description : ""}
</p>
<a className="ml-1 mt-1 mb-4 block dark:text-white text-xs" href="/apps">
{"<"} Back to overview
</a>
<div className="bg-white rounded-md shadow p-4 lg:p-8 dark:bg-surface-02dp">
<div className="divide-y divide-gray-200 dark:divide-white/10 dark:bg-surface-02dp">
<div className="pb-4">
<table>
<tr>
<td className="align-top w-32 font-medium dark:text-white">
Public Key
</td>
<td className="text-gray-600 dark:text-neutral-400 break-all">
{app.nostrPubkey}
</td>
</tr>
<tr>
<td className="align-top font-medium dark:text-white">
Last used
</td>
<td className="text-gray-600 dark:text-neutral-400">
{app.lastEventAt
? new Date(app.lastEventAt).toLocaleDateString()
: "never"}
</td>
</tr>
<tr>
<td className="align-top font-medium dark:text-white">
Expires at
</td>
<td className="text-gray-600 dark:text-neutral-400">
{app.expiresAt
? new Date(app.expiresAt).toLocaleDateString()
: "never"}
</td>
</tr>
</table>
</div>
<div className="py-4">
<h3 className="text-xl font-headline dark:text-white">
Permissions
</h3>
<ul className="mt-2 text-gray-600 dark:text-neutral-400">
{app.requestMethods.map((method, index) => (
<li key={index} className="mb-2 relative pl-6">
<span className="absolute left-0 text-green-500"></span>
{nip47MethodDescriptions[method as RequestMethodType]}
</li>
))}
</ul>
{app.maxAmount > 0 && (
<div className="pl-6">
<table className="text-gray-600 dark:text-neutral-400">
<tr>
<td className="font-medium">Budget</td>
<td>
{app.maxAmount} sats ({app.budgetUsage} sats used)
</td>
</tr>
<tr>
<td className="font-medium pr-3">Renews</td>
<td>{app.budgetRenewal}</td>
</tr>
</table>
</div>
)}
</div>
<div className="pt-4">
<h3 className="text-xl font-headline mb-2 dark:text-white">
Danger zone
</h3>
<p className="text-gray-600 dark:text-neutral-400 mb-4">
This will revoke the permission and will no longer allow calls
from this public key.
</p>
</div>
</div>
<form method="post" onSubmit={handleDelete}>
<button
type="submit"
className="inline-flex bg-white border border-red-400 cursor-pointer dark:bg-surface-02dp dark:hover:bg-surface-16dp duration-150 focus-visible:ring-2 focus-visible:ring-offset-2 focus:outline-none font-medium hover:bg-gray-50 items-center justify-center px-5 py-3 rounded-md shadow text-gray-700 dark:text-neutral-300 transition w-full sm:w-[250px] sm:mr-8 mt-8 sm:mt-0 order-last sm:order-first"
>
Disconnect
</button>
</form>
</div>
</div>
);
}
export default ShowApp;

2
frontend/src/swr.ts Normal file
View file

@ -0,0 +1,2 @@
export const swrFetcher = (...args: Parameters<typeof fetch>) =>
fetch(...args).then((res) => res.json());

110
frontend/src/types.ts Normal file
View file

@ -0,0 +1,110 @@
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 type BackendType = "ALBY" | "LND";
export type RequestMethodType =
| "pay_invoice"
| "get_balance"
| "get_info"
| "make_invoice"
| "lookup_invoice"
| "list_transactions";
export type BudgetRenewalType = "daily" | "weekly" | "monthly" | "yearly" | "";
export type IconMap = {
[key in RequestMethodType]: (
props: React.SVGAttributes<SVGElement>
) => JSX.Element;
};
export const validBudgetRenewals: BudgetRenewalType[] = [
"daily",
"weekly",
"monthly",
"yearly",
"",
];
export const nip47MethodDescriptions: Record<RequestMethodType, string> = {
[NIP_47_GET_BALANCE_METHOD]: "Read your balance",
[NIP_47_GET_INFO_METHOD]: "Read your node info",
[NIP_47_LIST_TRANSACTIONS_METHOD]: "Read incoming 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",
};
export interface User {
id: number;
albyIdentifier: string;
accessToken: string;
refreshToken: string;
email: string;
expiry: string;
lightningAddress: string;
apps: string;
createdAt: string;
updatedAt: string;
expiresAt: string;
}
export interface App {
id: number;
userId: number;
user: User;
name: string;
description: string;
nostrPubkey: string;
createdAt: string;
updatedAt: string;
lastEventAt?: string;
expiresAt?: string;
requestMethods: string[];
maxAmount: number;
budgetUsage: number;
budgetRenewal: string;
}
export interface AppPermission {
id: number;
appId: number;
app: App;
requestMethod: RequestMethodType;
maxAmount: number;
budgetRenewal: string;
expiresAt: string;
createdAt: string;
updatedAt: string;
}
export interface NostrEvent {
id: number;
appId: number;
app: App;
nostrId: string;
replyId: string;
content: string;
state: string;
repliedAt: string;
createdAt: string;
updatedAt: string;
}
export interface InfoResponse {
backendType: BackendType;
}
export interface CreateAppResponse {
name: string;
pairingUri: string;
pairingPublicKey: string;
pairingSecretKey: string;
returnTo: string;
}

View file

@ -0,0 +1,18 @@
import toast from "../components/Toast";
export async function validateFetchResponse(response: Response) {
if (!response.ok) {
let reason = "unknown";
try {
reason = await response.text();
} catch (error) {
console.error("Failed to read response text", error);
}
throw new Error("Unexpected response: " + response.status + " " + reason);
}
}
export function handleFetchError(message: string, error: unknown) {
console.error(message, error);
toast.error(message + ": " + error);
}

1
frontend/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -0,0 +1,98 @@
/** @type {import('tailwindcss').Config} */
import defaultTheme from 'tailwindcss/defaultTheme';
import forms from '@tailwindcss/forms';
import aspectRatio from '@tailwindcss/aspect-ratio';
import typography from '@tailwindcss/typography';
// NOTE: copied from:
// https://github.com/getAlby/lightning-browser-extension/blob/1bab35feb58859d0786a9c2807a038395dd87fca/tailwind.config.js#L4-L21
function lighten(color, percent) {
var num = parseInt(color.replace("#", ""), 16),
amt = Math.round(2.55 * percent),
R = (num >> 16) + amt,
B = ((num >> 8) & 0x00ff) + amt,
G = (num & 0x0000ff) + amt;
return (
"#" +
(
0x1000000 +
(R < 255 ? (R < 1 ? 0 : R) : 255) * 0x10000 +
(B < 255 ? (B < 1 ? 0 : B) : 255) * 0x100 +
(G < 255 ? (G < 1 ? 0 : G) : 255)
)
.toString(16)
.slice(1)
);
}
const surfaceColor = "#121212";
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
fontFamily: {
mono: defaultTheme.fontFamily.mono,
sans: ["Inter var", ...defaultTheme.fontFamily.sans],
},
colors: {
"alby-grey": "#272828",
"alby-yellow": "#FFDE6E",
"alby-yellow-light": "#FDF0D5",
bitcoin: "#F7931A",
"cold-grey": "#C5C7C8",
light: "#F4F4F4",
primary: "#F8C455",
"warm-grey": "#D2D2D1",
// Material Design Surface Colors
"surface-00dp": surfaceColor,
"surface-01dp": lighten(surfaceColor, 5),
"surface-02dp": lighten(surfaceColor, 7),
"surface-03dp": lighten(surfaceColor, 8),
"surface-04dp": lighten(surfaceColor, 9),
"surface-06dp": lighten(surfaceColor, 11),
"surface-08dp": lighten(surfaceColor, 12),
"surface-12dp": lighten(surfaceColor, 14),
"surface-16dp": lighten(surfaceColor, 15),
"surface-24dp": lighten(surfaceColor, 16),
},
borderRadius: {
"4xl": "1.25rem",
30: "30px",
},
animation: {
wiggle: "wiggle 0.3s ease-in-out",
},
keyframes: {
wiggle: {
"0%, 100%": { transform: "scale(1.0)" },
"50%": { transform: "scale(1.03)" },
},
},
blur: {
xs: "2px",
},
},
fontFamily: {
headline: [
'"Work Sans"',
'"Inter var"',
"Helvetica",
"Arial",
"sans-serif",
],
// required for Chrome, where e.g. ❤️ in Inter is displayed as a black heart :(
emoji: ["sans-serif"],
},
},
plugins: [
forms,
aspectRatio,
typography,
],
}

25
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View file

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

13
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,13 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": "http://localhost:8080",
"/alby": "http://localhost:8080",
},
},
});

1746
frontend/yarn.lock Normal file

File diff suppressed because it is too large Load diff

View file

@ -208,7 +208,6 @@ type LookupInvoiceResponse struct {
type ErrorResponse struct {
Error bool `json:"error"`
Code int `json:"code"`
Message string `json:"message"`
}

39
models/api/api.go Normal file
View file

@ -0,0 +1,39 @@
package api
import "time"
type App struct {
// ID uint `json:"id"` // ID unused - pubkey is used as ID
Name string `json:"name"`
Description string `json:"description"`
NostrPubkey string `json:"nostrPubkey"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
LastEventAt *time.Time `json:"lastEventAt"`
ExpiresAt *time.Time `json:"expiresAt"`
RequestMethods []string `json:"requestMethods"`
MaxAmount int `json:"maxAmount"`
BudgetUsage int64 `json:"budgetUsage"`
BudgetRenewal string `json:"budgetRenewal"`
}
type ListAppsResponse struct {
Apps []App `json:"apps"`
}
type CreateAppResponse struct {
PairingUri string `json:"pairingUri"`
PairingSecret string `json:"pairingSecretKey"`
Pubkey string `json:"pairingPublicKey"`
Name string `json:"name"`
ReturnTo string `json:"returnTo"`
}
type User struct {
Email string `json:"email"`
}
type InfoResponse struct {
BackendType string `json:"backendType"`
}

910
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,11 +1,6 @@
{
"scripts": {
"css": "tailwindcss -i ./views/application.css -o ./public/css/application.css --watch"
},
"devDependencies": {
"@tailwindcss/aspect-ratio": "^0.4.2",
"@tailwindcss/forms": "^0.5.3",
"@tailwindcss/typography": "^0.5.9",
"tailwindcss": "^3.2.7"
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,43 +0,0 @@
@font-face {
font-family: 'Inter var';
font-weight: 100 900;
font-display: swap;
font-style: normal;
font-named-instance: 'Regular';
src: url(/public/fonts/Inter-roman.var.woff2) format("woff2");
}
@font-face {
font-family: 'Inter var';
font-weight: 100 900;
font-display: swap;
font-style: italic;
font-named-instance: 'Italic';
src: url(/public/fonts/Inter-italic.var.woff2) format("woff2");
}
/* NOTE: source: https://gwfh.mranftl.com/fonts/work-sans?subsets=latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Work Sans';
font-style: normal;
font-weight: 500;
src: url('/public/fonts/work-sans-v18-latin-500.eot'); /* IE9 Compat Modes */
src: url('/public/fonts/work-sans-v18-latin-500.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
url('/public/fonts/work-sans-v18-latin-500.woff2') format('woff2'), /* Super Modern Browsers */
url('/public/fonts/work-sans-v18-latin-500.woff') format('woff'), /* Modern Browsers */
url('/public/fonts/work-sans-v18-latin-500.ttf') format('truetype'), /* Safari, Android, iOS */
url('/public/fonts/work-sans-v18-latin-500.svg#WorkSans') format('svg'); /* Legacy iOS */
}
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Work Sans';
font-style: normal;
font-weight: 600;
src: url('/public/fonts/work-sans-v18-latin-600.eot'); /* IE9 Compat Modes */
src: url('/public/fonts/work-sans-v18-latin-600.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
url('/public/fonts/work-sans-v18-latin-600.woff2') format('woff2'), /* Super Modern Browsers */
url('/public/fonts/work-sans-v18-latin-600.woff') format('woff'), /* Modern Browsers */
url('/public/fonts/work-sans-v18-latin-600.ttf') format('truetype'), /* Safari, Android, iOS */
url('/public/fonts/work-sans-v18-latin-600.svg#WorkSans') format('svg'); /* Legacy iOS */
}

View file

@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.3814 5.35432C18.738 5.56495 18.8564 6.02484 18.6458 6.3815L11.5591 18.3815C11.4426 18.5787 11.2424 18.7119 11.0156 18.7431C10.7887 18.7743 10.56 18.7002 10.3946 18.5418L5.48126 13.8366C5.18211 13.5501 5.17184 13.0753 5.45833 12.7761C5.74482 12.477 6.21958 12.4667 6.51874 12.7532L10.7487 16.804L17.3542 5.61874C17.5648 5.26208 18.0247 5.14369 18.3814 5.35432Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 532 B

View file

@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
fill="currentColor">
<path
d="M3.995 17.207V19.5a.5.5 0 00.5.5h2.298a.5.5 0 00.353-.146l9.448-9.448-3-3-9.452 9.448a.5.5 0 00-.147.353zM14.832 6.167l3 3 1.46-1.46a1 1 0 000-1.414l-1.585-1.586a1 1 0 00-1.414 0l-1.46 1.46z"></path>
</svg>

Before

Width:  |  Height:  |  Size: 327 B

View file

@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
fill="currentColor">
<path fill-rule="evenodd"
d="M7.5 4A1.5 1.5 0 006 5.5v13A1.5 1.5 0 007.5 20h9a1.5 1.5 0 001.5-1.5v-13A1.5 1.5 0 0016.5 4h-9zm6.854 4.354a.5.5 0 00-.708-.708l-4 4a.5.5 0 00.708.708l4-4zM11.5 8.5a1 1 0 11-2 0 1 1 0 012 0zm2 4a1 1 0 100-2 1 1 0 000 2zm-5 2.5a.5.5 0 01.5-.5h6a.5.5 0 010 1H9a.5.5 0 01-.5-.5zm.5 1.5a.5.5 0 000 1h6a.5.5 0 000-1H9z"
clip-rule="evenodd"></path>
</svg>

Before

Width:  |  Height:  |  Size: 494 B

View file

@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
fill="currentColor" data-v-1286450c="">
<path
d="M18.496 10.709l-8.636 8.88c-.24.246-.638-.039-.482-.345l3.074-6.066a.3.3 0 00-.268-.436H5.718a.3.3 0 01-.214-.51l8.01-8.115c.232-.235.618.023.489.328L11.706 9.86a.3.3 0 00.28.417l6.291-.078a.3.3 0 01.22.509z"></path>
</svg>

Before

Width:  |  Height:  |  Size: 363 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1210" height="525" fill="none"><circle r="36.7" fill="#000" transform="matrix(-1 0 0 1 94.1 138.1)"/><path stroke="#000" stroke-width="18.3" d="m88 132.6 68.5 68.5"/><circle cx="393.6" cy="138.1" r="36.7" fill="#000"/><path stroke="#000" stroke-width="18.3" d="m400.4 132.6-68.5 68.5"/><path fill="#FFDF6F" fill-rule="evenodd" d="M104.5 357a48.5 48.5 0 0 1-27.4-52.1c16.6-92.5 85.5-161.9 168-161.9 82.7 0 151.8 69.7 168.1 162.6a48.5 48.5 0 0 1-27.6 52 327.6 327.6 0 0 1-139.9 31.2c-50.5 0-98.4-11.4-141.2-31.8Z" clip-rule="evenodd"/><path fill="#000" d="m413.2 305.6-9 1.6 9-1.6Zm-27.6 52-4-8.3 4 8.3Zm-299.4-51c16-89.5 82-154.4 159-154.4v-18.3c-88.2 0-160 73.8-177.1 169.4l18 3.2Zm159-154.4c77 0 143.2 65.3 159 155l18-3.2c-16.8-96-88.8-170.1-177-170.1v18.3Zm136.5 197.1a318.4 318.4 0 0 1-136 30.3v18.3c51.4 0 100.1-11.5 143.8-32l-7.8-16.6Zm-136 30.3c-49.1 0-95.6-11.1-137.2-30.9l-7.9 16.6c44 21 93.2 32.6 145.1 32.6v-18.3Zm158.5-72.4c3 17.3-6.2 34.5-22.5 42.1l7.8 16.6c23.4-11 37.3-36 32.7-62l-18 3.3Zm-336.1-4a57.6 57.6 0 0 0 32.5 62l7.9-16.5a39.3 39.3 0 0 1-22.3-42.2L68 303.3Z"/><path fill="#000" fill-rule="evenodd" d="M139.8 334c-15.9-6.5-25.3-23.4-19.8-39.7 17-50.2 66.6-86.5 125.1-86.5s108.1 36.3 125.1 86.5c5.6 16.3-3.9 33.2-19.8 39.7a278.6 278.6 0 0 1-105.3 20.5c-37.2 0-72.8-7.3-105.3-20.5Z" clip-rule="evenodd"/><ellipse cx="287.3" cy="286.1" fill="#fff" rx="30.6" ry="24.4"/><ellipse cx="199.8" cy="286.1" fill="#fff" rx="30.6" ry="24.4"/><path fill="#000" d="M623.9 134.4h58.2l81.3 215-57.3 5.8-15.6-46h-75.2l-15 42.8h-58l81.6-217.6Zm50 129L652 204.2l-20.8 59.2h42.5ZM837.5 352h-58.2V129.6l58.2-3.5V352Zm61.4 0h-29.4V129.6l58.2-3.5v69.4c3.6-1.3 7.4-2.2 11.2-2.9a93.2 93.2 0 0 1 52.8 7.4 63.8 63.8 0 0 1 28.2 26.6c7 12 10.5 27.2 10.5 45.7a92 92 0 0 1-10.5 46.1 66 66 0 0 1-28.8 27.8 93.4 93.4 0 0 1-43 9.3 65.3 65.3 0 0 1-39.3-13.4L899 352Zm42.9-115.8a27 27 0 0 0-14.1 4.8v65.2c5.8 3 11.3 4.5 16.6 4.5a22 22 0 0 0 18.3-9.9 46 46 0 0 0 7.7-28.2c0-12-2.5-21-7.4-27.2-4.9-6.1-12-9.2-21.1-9.2ZM1088 352.6l1.3-3.5-55.7-142.7 53.8-15.4 31.7 93.5 29.7-87.7h58l-68.2 173.4c-3.2 8.4-8.4 16-15.7 23-7.3 7.1-15.7 13-25.3 18a128 128 0 0 1-29.7 10.9l-19.6-45.1 16-7c5.8-2.6 11-5.4 15.4-8.4 4.5-3 7.3-6 8.3-9Z"/></svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -1,6 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.2854 12.0002L11.2727 12.0002" stroke="#f87171" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" />
<path d="M16.5101 15.6364L19.9999 12L16.5101 8.36363" stroke="#f87171" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" />
<path d="M13.4545 7V4H4V20H13.4545V17" stroke="#f87171" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" />
</svg>

Before

Width:  |  Height:  |  Size: 496 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white"><path fill-rule="evenodd" d="M12 3.25a.75.75 0 01.75.75v7.25H20a.75.75 0 010 1.5h-7.25V20a.75.75 0 01-1.5 0v-7.25H4a.75.75 0 010-1.5h7.25V4a.75.75 0 01.75-.75z" clip-rule="evenodd"></path></svg>

Before

Width:  |  Height:  |  Size: 267 B

View file

@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
fill="currentColor">
<path fill-rule="evenodd"
d="M10.5 5.5a5 5 0 100 10 5 5 0 000-10zm-6.5 5a6.5 6.5 0 1113 0 6.5 6.5 0 01-13 0z"
clip-rule="evenodd"></path>
<path fill-rule="evenodd"
d="M14.47 14.47a.75.75 0 011.06 0l4 4a.75.75 0 11-1.06 1.06l-4-4a.75.75 0 010-1.06z"
clip-rule="evenodd"></path>
</svg>

Before

Width:  |  Height:  |  Size: 409 B

View file

@ -1,15 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
fill="currentColor">
<circle cx="5.5" cy="7.5" r="1.5"></circle>
<path fill-rule="evenodd"
d="M8 6.5a.5.5 0 01.5-.5h11a.5.5 0 010 1h-11a.5.5 0 01-.5-.5zM8 8.5a.5.5 0 01.5-.5h6a.5.5 0 010 1h-6a.5.5 0 01-.5-.5z"
clip-rule="evenodd"></path>
<circle cx="5.5" cy="12" r="1.5"></circle>
<path fill-rule="evenodd"
d="M8 11a.5.5 0 01.5-.5h8a.5.5 0 010 1h-8A.5.5 0 018 11zM8 13a.5.5 0 01.5-.5h7a.5.5 0 010 1h-7A.5.5 0 018 13z"
clip-rule="evenodd"></path>
<circle cx="5.5" cy="16.5" r="1.5"></circle>
<path fill-rule="evenodd"
d="M8 15.5a.5.5 0 01.5-.5H18a.5.5 0 010 1H8.5a.5.5 0 01-.5-.5zM8 17.5a.5.5 0 01.5-.5h4a.5.5 0 010 1h-4a.5.5 0 01-.5-.5z"
clip-rule="evenodd"></path>
</svg>

Before

Width:  |  Height:  |  Size: 793 B

View file

@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
fill="currentColor">
<path fill-rule="evenodd"
d="M12 8a2 2 0 012-2h4a2 2 0 012 2v8a2 2 0 01-2 2h-4a2 2 0 01-2-2V8zm2-1a1 1 0 00-1 1v8a1 1 0 001 1h4a1 1 0 001-1V8a1 1 0 00-1-1h-4z"
clip-rule="evenodd"></path>
<path fill-rule="evenodd"
d="M5.5 6A1.5 1.5 0 004 7.5v9A1.5 1.5 0 005.5 18h10a1.5 1.5 0 001.5-1.5v-9A1.5 1.5 0 0015.5 6h-10zm2 7.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3z"
clip-rule="evenodd"></path>
</svg>

Before

Width:  |  Height:  |  Size: 513 B

View file

@ -1,82 +0,0 @@
/*
jquery-qrcode v0.14.0 - https://larsjung.de/jquery-qrcode/ */
'use strict'; let G = null; class H { } H.render = function(w, B) { G(w, B) }; self.QrCreator = H;
(function(w) {
function B(t, c, a, e) { var b = {}, h = w(a, c); h.u(t); h.J(); e = e || 0; var r = h.h(), d = h.h() + 2 * e; b.text = t; b.level = c; b.version = a; b.O = d; b.a = function(b, a) { b -= e; a -= e; return 0 > b || b >= r || 0 > a || a >= r ? !1 : h.a(b, a) }; return b } function C(t, c, a, e, b, h, r, d, g, x) { function u(b, a, f, c, d, r, g) { b ? (t.lineTo(a + r, f + g), t.arcTo(a, f, c, d, h)) : t.lineTo(a, f) } r ? t.moveTo(c + h, a) : t.moveTo(c, a); u(d, e, a, e, b, -h, 0); u(g, e, b, c, b, 0, -h); u(x, c, b, c, a, h, 0); u(r, c, a, e, a, 0, h) } function z(t, c, a, e, b, h, r, d, g, x) {
function u(b, a, c, d) {
t.moveTo(b + c, a); t.lineTo(b,
a); t.lineTo(b, a + d); t.arcTo(b, a, b + c, a, h)
} r && u(c, a, h, h); d && u(e, a, -h, h); g && u(e, b, -h, -h); x && u(c, b, h, -h)
} function A(t, c) { var a = c.fill; if ("string" === typeof a) t.fillStyle = a; else { var e = a.type, b = a.colorStops; a = a.position.map((b) => Math.round(b * c.size)); if ("linear-gradient" === e) var h = t.createLinearGradient.apply(t, a); else if ("radial-gradient" === e) h = t.createRadialGradient.apply(t, a); else throw Error("Unsupported fill"); b.forEach(([b, a]) => { h.addColorStop(b, a) }); t.fillStyle = h } } function y(t, c) {
a: {
var a = c.text, e =
c.v, b = c.N, h = c.K, r = c.P; b = Math.max(1, b || 1); for (h = Math.min(40, h || 40); b <= h; b += 1)try { var d = B(a, e, b, r); break a } catch (J) { } d = void 0
} if (!d) return null; a = t.getContext("2d"); c.background && (a.fillStyle = c.background, a.fillRect(c.left, c.top, c.size, c.size)); e = d.O; h = c.size / e; a.beginPath(); for (r = 0; r < e; r += 1)for (b = 0; b < e; b += 1) {
var g = a, x = c.left + b * h, u = c.top + r * h, p = r, q = b, f = d.a, k = x + h, m = u + h, D = p - 1, E = p + 1, n = q - 1, l = q + 1, y = Math.floor(Math.min(.5, Math.max(0, c.R)) * h), v = f(p, q), I = f(D, n), w = f(D, q); D = f(D, l); var F = f(p, l); l = f(E, l); q = f(E,
q); E = f(E, n); p = f(p, n); x = Math.round(x); u = Math.round(u); k = Math.round(k); m = Math.round(m); v ? C(g, x, u, k, m, y, !w && !p, !w && !F, !q && !F, !q && !p) : z(g, x, u, k, m, y, w && p && I, w && F && D, q && F && l, q && p && E)
} A(a, c); a.fill(); return t
} var v = { minVersion: 1, maxVersion: 40, ecLevel: "L", left: 0, top: 0, size: 200, fill: "#000", background: null, text: "no text", radius: .5, quiet: 0 }; G = function(t, c) {
var a = {}; Object.assign(a, v, t); a.N = a.minVersion; a.K = a.maxVersion; a.v = a.ecLevel; a.left = a.left; a.top = a.top; a.size = a.size; a.fill = a.fill; a.background = a.background;
a.text = a.text; a.R = a.radius; a.P = a.quiet; if (c instanceof HTMLCanvasElement) { if (c.width !== a.size || c.height !== a.size) c.width = a.size, c.height = a.size; c.getContext("2d").clearRect(0, 0, c.width, c.height); y(c, a) } else t = document.createElement("canvas"), t.width = a.size, t.height = a.size, a = y(t, a), c.appendChild(a)
}
})(function() {
function w(c) { var a = C.s(c); return { S: function() { return 4 }, b: function() { return a.length }, write: function(c) { for (var b = 0; b < a.length; b += 1)c.put(a[b], 8) } } } function B() {
var c = [], a = 0, e = {
B: function() { return c },
c: function(b) { return 1 == (c[Math.floor(b / 8)] >>> 7 - b % 8 & 1) }, put: function(b, h) { for (var a = 0; a < h; a += 1)e.m(1 == (b >>> h - a - 1 & 1)) }, f: function() { return a }, m: function(b) { var h = Math.floor(a / 8); c.length <= h && c.push(0); b && (c[h] |= 128 >>> a % 8); a += 1 }
}; return e
} function C(c, a) {
function e(b, h) { for (var a = -1; 7 >= a; a += 1)if (!(-1 >= b + a || d <= b + a)) for (var c = -1; 7 >= c; c += 1)-1 >= h + c || d <= h + c || (r[b + a][h + c] = 0 <= a && 6 >= a && (0 == c || 6 == c) || 0 <= c && 6 >= c && (0 == a || 6 == a) || 2 <= a && 4 >= a && 2 <= c && 4 >= c ? !0 : !1) } function b(b, a) {
for (var f = d = 4 * c + 17, k = Array(f), m = 0; m <
f; m += 1) { k[m] = Array(f); for (var p = 0; p < f; p += 1)k[m][p] = null } r = k; e(0, 0); e(d - 7, 0); e(0, d - 7); f = y.G(c); for (k = 0; k < f.length; k += 1)for (m = 0; m < f.length; m += 1) { p = f[k]; var q = f[m]; if (null == r[p][q]) for (var n = -2; 2 >= n; n += 1)for (var l = -2; 2 >= l; l += 1)r[p + n][q + l] = -2 == n || 2 == n || -2 == l || 2 == l || 0 == n && 0 == l } for (f = 8; f < d - 8; f += 1)null == r[f][6] && (r[f][6] = 0 == f % 2); for (f = 8; f < d - 8; f += 1)null == r[6][f] && (r[6][f] = 0 == f % 2); f = y.w(h << 3 | a); for (k = 0; 15 > k; k += 1)m = !b && 1 == (f >> k & 1), r[6 > k ? k : 8 > k ? k + 1 : d - 15 + k][8] = m, r[8][8 > k ? d - k - 1 : 9 > k ? 15 - k : 14 - k] = m; r[d - 8][8] = !b; if (7 <=
c) { f = y.A(c); for (k = 0; 18 > k; k += 1)m = !b && 1 == (f >> k & 1), r[Math.floor(k / 3)][k % 3 + d - 8 - 3] = m; for (k = 0; 18 > k; k += 1)m = !b && 1 == (f >> k & 1), r[k % 3 + d - 8 - 3][Math.floor(k / 3)] = m } if (null == g) {
b = t.I(c, h); f = B(); for (k = 0; k < x.length; k += 1)m = x[k], f.put(4, 4), f.put(m.b(), y.f(4, c)), m.write(f); for (k = m = 0; k < b.length; k += 1)m += b[k].j; if (f.f() > 8 * m) throw Error("code length overflow. (" + f.f() + ">" + 8 * m + ")"); for (f.f() + 4 <= 8 * m && f.put(0, 4); 0 != f.f() % 8;)f.m(!1); for (; !(f.f() >= 8 * m);) { f.put(236, 8); if (f.f() >= 8 * m) break; f.put(17, 8) } var u = 0; m = k = 0; p = Array(b.length);
q = Array(b.length); for (n = 0; n < b.length; n += 1) { var v = b[n].j, w = b[n].o - v; k = Math.max(k, v); m = Math.max(m, w); p[n] = Array(v); for (l = 0; l < p[n].length; l += 1)p[n][l] = 255 & f.B()[l + u]; u += v; l = y.C(w); v = z(p[n], l.b() - 1).l(l); q[n] = Array(l.b() - 1); for (l = 0; l < q[n].length; l += 1)w = l + v.b() - q[n].length, q[n][l] = 0 <= w ? v.c(w) : 0 } for (l = f = 0; l < b.length; l += 1)f += b[l].o; f = Array(f); for (l = u = 0; l < k; l += 1)for (n = 0; n < b.length; n += 1)l < p[n].length && (f[u] = p[n][l], u += 1); for (l = 0; l < m; l += 1)for (n = 0; n < b.length; n += 1)l < q[n].length && (f[u] = q[n][l], u += 1); g = f
} b = g; f =
-1; k = d - 1; m = 7; p = 0; a = y.F(a); for (q = d - 1; 0 < q; q -= 2)for (6 == q && --q; ;) { for (n = 0; 2 > n; n += 1)null == r[k][q - n] && (l = !1, p < b.length && (l = 1 == (b[p] >>> m & 1)), a(k, q - n) && (l = !l), r[k][q - n] = l, --m, -1 == m && (p += 1, m = 7)); k += f; if (0 > k || d <= k) { k -= f; f = -f; break } }
} var h = A[a], r = null, d = 0, g = null, x = [], u = { u: function(b) { b = w(b); x.push(b); g = null }, a: function(b, a) { if (0 > b || d <= b || 0 > a || d <= a) throw Error(b + "," + a); return r[b][a] }, h: function() { return d }, J: function() { for (var a = 0, h = 0, c = 0; 8 > c; c += 1) { b(!0, c); var d = y.D(u); if (0 == c || a > d) a = d, h = c } b(!1, h) } }; return u
}
function z(c, a) {
if ("undefined" == typeof c.length) throw Error(c.length + "/" + a); var e = function() { for (var b = 0; b < c.length && 0 == c[b];)b += 1; for (var r = Array(c.length - b + a), d = 0; d < c.length - b; d += 1)r[d] = c[d + b]; return r }(), b = {
c: function(b) { return e[b] }, b: function() { return e.length }, multiply: function(a) { for (var h = Array(b.b() + a.b() - 1), c = 0; c < b.b(); c += 1)for (var g = 0; g < a.b(); g += 1)h[c + g] ^= v.i(v.g(b.c(c)) + v.g(a.c(g))); return z(h, 0) }, l: function(a) {
if (0 > b.b() - a.b()) return b; for (var c = v.g(b.c(0)) - v.g(a.c(0)), h = Array(b.b()),
g = 0; g < b.b(); g += 1)h[g] = b.c(g); for (g = 0; g < a.b(); g += 1)h[g] ^= v.i(v.g(a.c(g)) + c); return z(h, 0).l(a)
}
}; return b
} C.s = function(c) { for (var a = [], e = 0; e < c.length; e++) { var b = c.charCodeAt(e); 128 > b ? a.push(b) : 2048 > b ? a.push(192 | b >> 6, 128 | b & 63) : 55296 > b || 57344 <= b ? a.push(224 | b >> 12, 128 | b >> 6 & 63, 128 | b & 63) : (e++, b = 65536 + ((b & 1023) << 10 | c.charCodeAt(e) & 1023), a.push(240 | b >> 18, 128 | b >> 12 & 63, 128 | b >> 6 & 63, 128 | b & 63)) } return a }; var A = { L: 1, M: 0, Q: 3, H: 2 }, y = function() {
function c(b) { for (var a = 0; 0 != b;)a += 1, b >>>= 1; return a } var a = [[], [6, 18],
[6, 22], [6, 26], [6, 30], [6, 34], [6, 22, 38], [6, 24, 42], [6, 26, 46], [6, 28, 50], [6, 30, 54], [6, 32, 58], [6, 34, 62], [6, 26, 46, 66], [6, 26, 48, 70], [6, 26, 50, 74], [6, 30, 54, 78], [6, 30, 56, 82], [6, 30, 58, 86], [6, 34, 62, 90], [6, 28, 50, 72, 94], [6, 26, 50, 74, 98], [6, 30, 54, 78, 102], [6, 28, 54, 80, 106], [6, 32, 58, 84, 110], [6, 30, 58, 86, 114], [6, 34, 62, 90, 118], [6, 26, 50, 74, 98, 122], [6, 30, 54, 78, 102, 126], [6, 26, 52, 78, 104, 130], [6, 30, 56, 82, 108, 134], [6, 34, 60, 86, 112, 138], [6, 30, 58, 86, 114, 142], [6, 34, 62, 90, 118, 146], [6, 30, 54, 78, 102, 126, 150], [6, 24, 50, 76, 102, 128, 154],
[6, 28, 54, 80, 106, 132, 158], [6, 32, 58, 84, 110, 136, 162], [6, 26, 54, 82, 110, 138, 166], [6, 30, 58, 86, 114, 142, 170]], e = {
w: function(b) { for (var a = b << 10; 0 <= c(a) - c(1335);)a ^= 1335 << c(a) - c(1335); return (b << 10 | a) ^ 21522 }, A: function(b) { for (var a = b << 12; 0 <= c(a) - c(7973);)a ^= 7973 << c(a) - c(7973); return b << 12 | a }, G: function(b) { return a[b - 1] }, F: function(b) {
switch (b) {
case 0: return function(b, a) { return 0 == (b + a) % 2 }; case 1: return function(b) { return 0 == b % 2 }; case 2: return function(b, a) { return 0 == a % 3 }; case 3: return function(b, a) {
return 0 ==
(b + a) % 3
}; case 4: return function(b, a) { return 0 == (Math.floor(b / 2) + Math.floor(a / 3)) % 2 }; case 5: return function(b, a) { return 0 == b * a % 2 + b * a % 3 }; case 6: return function(b, a) { return 0 == (b * a % 2 + b * a % 3) % 2 }; case 7: return function(b, a) { return 0 == (b * a % 3 + (b + a) % 2) % 2 }; default: throw Error("bad maskPattern:" + b);
}
}, C: function(b) { for (var a = z([1], 0), c = 0; c < b; c += 1)a = a.multiply(z([1, v.i(c)], 0)); return a }, f: function(b, a) { if (4 != b || 1 > a || 40 < a) throw Error("mode: " + b + "; type: " + a); return 10 > a ? 8 : 16 }, D: function(b) {
for (var a = b.h(), c = 0,
d = 0; d < a; d += 1)for (var g = 0; g < a; g += 1) { for (var e = 0, t = b.a(d, g), p = -1; 1 >= p; p += 1)if (!(0 > d + p || a <= d + p)) for (var q = -1; 1 >= q; q += 1)0 > g + q || a <= g + q || (0 != p || 0 != q) && t == b.a(d + p, g + q) && (e += 1); 5 < e && (c += 3 + e - 5) } for (d = 0; d < a - 1; d += 1)for (g = 0; g < a - 1; g += 1)if (e = 0, b.a(d, g) && (e += 1), b.a(d + 1, g) && (e += 1), b.a(d, g + 1) && (e += 1), b.a(d + 1, g + 1) && (e += 1), 0 == e || 4 == e) c += 3; for (d = 0; d < a; d += 1)for (g = 0; g < a - 6; g += 1)b.a(d, g) && !b.a(d, g + 1) && b.a(d, g + 2) && b.a(d, g + 3) && b.a(d, g + 4) && !b.a(d, g + 5) && b.a(d, g + 6) && (c += 40); for (g = 0; g < a; g += 1)for (d = 0; d < a - 6; d += 1)b.a(d, g) && !b.a(d +
1, g) && b.a(d + 2, g) && b.a(d + 3, g) && b.a(d + 4, g) && !b.a(d + 5, g) && b.a(d + 6, g) && (c += 40); for (g = e = 0; g < a; g += 1)for (d = 0; d < a; d += 1)b.a(d, g) && (e += 1); return c += Math.abs(100 * e / a / a - 50) / 5 * 10
}
}; return e
}(), v = function() { for (var c = Array(256), a = Array(256), e = 0; 8 > e; e += 1)c[e] = 1 << e; for (e = 8; 256 > e; e += 1)c[e] = c[e - 4] ^ c[e - 5] ^ c[e - 6] ^ c[e - 8]; for (e = 0; 255 > e; e += 1)a[c[e]] = e; return { g: function(b) { if (1 > b) throw Error("glog(" + b + ")"); return a[b] }, i: function(b) { for (; 0 > b;)b += 255; for (; 256 <= b;)b -= 255; return c[b] } } }(), t = function() {
function c(b, c) {
switch (c) {
case A.L: return a[4 *
(b - 1)]; case A.M: return a[4 * (b - 1) + 1]; case A.Q: return a[4 * (b - 1) + 2]; case A.H: return a[4 * (b - 1) + 3]
}
} var a = [[1, 26, 19], [1, 26, 16], [1, 26, 13], [1, 26, 9], [1, 44, 34], [1, 44, 28], [1, 44, 22], [1, 44, 16], [1, 70, 55], [1, 70, 44], [2, 35, 17], [2, 35, 13], [1, 100, 80], [2, 50, 32], [2, 50, 24], [4, 25, 9], [1, 134, 108], [2, 67, 43], [2, 33, 15, 2, 34, 16], [2, 33, 11, 2, 34, 12], [2, 86, 68], [4, 43, 27], [4, 43, 19], [4, 43, 15], [2, 98, 78], [4, 49, 31], [2, 32, 14, 4, 33, 15], [4, 39, 13, 1, 40, 14], [2, 121, 97], [2, 60, 38, 2, 61, 39], [4, 40, 18, 2, 41, 19], [4, 40, 14, 2, 41, 15], [2, 146, 116], [3, 58, 36,
2, 59, 37], [4, 36, 16, 4, 37, 17], [4, 36, 12, 4, 37, 13], [2, 86, 68, 2, 87, 69], [4, 69, 43, 1, 70, 44], [6, 43, 19, 2, 44, 20], [6, 43, 15, 2, 44, 16], [4, 101, 81], [1, 80, 50, 4, 81, 51], [4, 50, 22, 4, 51, 23], [3, 36, 12, 8, 37, 13], [2, 116, 92, 2, 117, 93], [6, 58, 36, 2, 59, 37], [4, 46, 20, 6, 47, 21], [7, 42, 14, 4, 43, 15], [4, 133, 107], [8, 59, 37, 1, 60, 38], [8, 44, 20, 4, 45, 21], [12, 33, 11, 4, 34, 12], [3, 145, 115, 1, 146, 116], [4, 64, 40, 5, 65, 41], [11, 36, 16, 5, 37, 17], [11, 36, 12, 5, 37, 13], [5, 109, 87, 1, 110, 88], [5, 65, 41, 5, 66, 42], [5, 54, 24, 7, 55, 25], [11, 36, 12, 7, 37, 13], [5, 122, 98, 1, 123, 99], [7, 73,
45, 3, 74, 46], [15, 43, 19, 2, 44, 20], [3, 45, 15, 13, 46, 16], [1, 135, 107, 5, 136, 108], [10, 74, 46, 1, 75, 47], [1, 50, 22, 15, 51, 23], [2, 42, 14, 17, 43, 15], [5, 150, 120, 1, 151, 121], [9, 69, 43, 4, 70, 44], [17, 50, 22, 1, 51, 23], [2, 42, 14, 19, 43, 15], [3, 141, 113, 4, 142, 114], [3, 70, 44, 11, 71, 45], [17, 47, 21, 4, 48, 22], [9, 39, 13, 16, 40, 14], [3, 135, 107, 5, 136, 108], [3, 67, 41, 13, 68, 42], [15, 54, 24, 5, 55, 25], [15, 43, 15, 10, 44, 16], [4, 144, 116, 4, 145, 117], [17, 68, 42], [17, 50, 22, 6, 51, 23], [19, 46, 16, 6, 47, 17], [2, 139, 111, 7, 140, 112], [17, 74, 46], [7, 54, 24, 16, 55, 25], [34, 37, 13], [4,
151, 121, 5, 152, 122], [4, 75, 47, 14, 76, 48], [11, 54, 24, 14, 55, 25], [16, 45, 15, 14, 46, 16], [6, 147, 117, 4, 148, 118], [6, 73, 45, 14, 74, 46], [11, 54, 24, 16, 55, 25], [30, 46, 16, 2, 47, 17], [8, 132, 106, 4, 133, 107], [8, 75, 47, 13, 76, 48], [7, 54, 24, 22, 55, 25], [22, 45, 15, 13, 46, 16], [10, 142, 114, 2, 143, 115], [19, 74, 46, 4, 75, 47], [28, 50, 22, 6, 51, 23], [33, 46, 16, 4, 47, 17], [8, 152, 122, 4, 153, 123], [22, 73, 45, 3, 74, 46], [8, 53, 23, 26, 54, 24], [12, 45, 15, 28, 46, 16], [3, 147, 117, 10, 148, 118], [3, 73, 45, 23, 74, 46], [4, 54, 24, 31, 55, 25], [11, 45, 15, 31, 46, 16], [7, 146, 116, 7, 147, 117],
[21, 73, 45, 7, 74, 46], [1, 53, 23, 37, 54, 24], [19, 45, 15, 26, 46, 16], [5, 145, 115, 10, 146, 116], [19, 75, 47, 10, 76, 48], [15, 54, 24, 25, 55, 25], [23, 45, 15, 25, 46, 16], [13, 145, 115, 3, 146, 116], [2, 74, 46, 29, 75, 47], [42, 54, 24, 1, 55, 25], [23, 45, 15, 28, 46, 16], [17, 145, 115], [10, 74, 46, 23, 75, 47], [10, 54, 24, 35, 55, 25], [19, 45, 15, 35, 46, 16], [17, 145, 115, 1, 146, 116], [14, 74, 46, 21, 75, 47], [29, 54, 24, 19, 55, 25], [11, 45, 15, 46, 46, 16], [13, 145, 115, 6, 146, 116], [14, 74, 46, 23, 75, 47], [44, 54, 24, 7, 55, 25], [59, 46, 16, 1, 47, 17], [12, 151, 121, 7, 152, 122], [12, 75, 47, 26, 76, 48],
[39, 54, 24, 14, 55, 25], [22, 45, 15, 41, 46, 16], [6, 151, 121, 14, 152, 122], [6, 75, 47, 34, 76, 48], [46, 54, 24, 10, 55, 25], [2, 45, 15, 64, 46, 16], [17, 152, 122, 4, 153, 123], [29, 74, 46, 14, 75, 47], [49, 54, 24, 10, 55, 25], [24, 45, 15, 46, 46, 16], [4, 152, 122, 18, 153, 123], [13, 74, 46, 32, 75, 47], [48, 54, 24, 14, 55, 25], [42, 45, 15, 32, 46, 16], [20, 147, 117, 4, 148, 118], [40, 75, 47, 7, 76, 48], [43, 54, 24, 22, 55, 25], [10, 45, 15, 67, 46, 16], [19, 148, 118, 6, 149, 119], [18, 75, 47, 31, 76, 48], [34, 54, 24, 34, 55, 25], [20, 45, 15, 61, 46, 16]], e = {
I: function(b, a) {
var e = c(b, a); if ("undefined" ==
typeof e) throw Error("bad rs block @ typeNumber:" + b + "/errorCorrectLevel:" + a); b = e.length / 3; a = []; for (var d = 0; d < b; d += 1)for (var g = e[3 * d], h = e[3 * d + 1], t = e[3 * d + 2], p = 0; p < g; p += 1) { var q = t, f = {}; f.o = h; f.j = q; a.push(f) } return a
}
}; return e
}(); return C
}());
//# sourceMappingURL=qr-creator.min.js.map

View file

@ -1,12 +0,0 @@
{{define "body"}}
<div
class="bg-white rounded-md shadow px-4 lg:p-8 dark:bg-surface-02dp"
>
<h3 class="font-bold text-2xl font-headline mb-4 dark:text-white">404 Page Not Found</h3>
<p class="leading-relaxed dark:text-neutral-400">
The page you are looking for does not exist.
</p>
</div>
{{end}}

View file

@ -1,133 +0,0 @@
{{define "body"}}
<div
class="bg-white rounded-md shadow p-4 lg:p-8 mb-8 dark:bg-surface-02dp"
>
<h3 class="font-bold text-2xl font-headline mb-4 dark:text-white">tl;dr</h3>
<p class="leading-relaxed dark:text-neutral-400">
Nostr Wallet Connect (NWC) is a way for applications like Nostr clients to access a remote Lightning wallet through a <a href="https://github.com/getAlby/nips/blob/master/47.md" class="dark:text-gray-300 underline">standardized protocol</a>.
For instance, you can link your <a href="https://getalby.com" class="dark:text-gray-300 underline">Alby account</a> with any other application using this feature.
</p>
</div>
<div
class="bg-white rounded-md shadow p-4 lg:p-8 mb-8 dark:bg-surface-02dp"
>
<h3 class="font-bold text-2xl font-headline mb-4 dark:text-white">How to connect your app?</h3>
<ol class="list-decimal list-inside leading-relaxed dark:text-neutral-400">
<li class="mb-2">
<strong class="dark:text-white">Create a new app connection</strong>
<p class="ml-6">
Enable the application by creating a permissioned connection to your wallet. This access
can be revoked at any time.
</p>
</li>
<li class="mb-2">
<strong class="dark:text-white">Connect your app</strong>
<p class="ml-6">
Connect your application by adding the connection secret. Either by
scanning the connect QR code or clicking a link.
</p>
</li>
<li class="mb-2">
<strong class="dark:text-white">Done</strong>
<p class="ml-6">
Zapp and Boost! Your lightning wallet is available where you need it.
<br />
And if you ever want to disable it again, simply disconnect the connection again.
</p>
</li>
</ol>
</div>
<div
class="bg-white rounded-md shadow p-4 lg:p-8 mb-8 dark:bg-surface-02dp"
>
<h3 class="font-bold text-2xl font-headline mb-4 dark:text-white">FAQs</h3>
<h4 class="font-bold text-l font-headline mt-4 dark:text-white">How does it work?</h4>
<p class="leading-relaxed dark:text-neutral-400">
Nostr Wallet Connect uses the existing Nostr infrastructure to allow
applications to communicate with lightning wallets. This makes it easy for
developers to integrate and gives the users full flexibility on choice.
<br />
To learn more about the details have a look at
<a
href="https://github.com/getAlby/nips/blob/7-wallet-connect-patch/47.md"
class="dark:text-gray-300 underline"
>the specs (NIP47)</a
>
</p>
<h4 class="font-bold text-l font-headline mt-6 dark:text-white">Which apps support it?</h4>
<p class="leading-relaxed dark:text-neutral-400">
<a href="https://linktr.ee/amethyst.social" class="dark:text-gray-300 underline">Amethyst</a>
is the first Nostr Client to fully support Nostr Wallet Connect. More are
working on support for it and are following shortly.
</p>
<h4 class="font-bold text-l font-headline mt-6 dark:text-white">Is it open source?</h4>
<p class="leading-relaxed dark:text-neutral-400">
Of course! Nostr Wallet Connect is an open standard and our implementation
is
<a href="https://github.com/getAlby/nostr-wallet-connect/" class="dark:text-gray-300 underline"
>open source on GitHub</a
>
</p>
<h4 class="font-bold text-l font-headline mt-6 dark:text-white">Can I run my own?</h4>
<p class="leading-relaxed dark:text-neutral-400">
Yes, Nostr Wallet Connect is for every wallet and can easily be
self-hosted.<br />
We are looking for contributors to add support for more wallet backends. If
you're interested please contact us
<a href="https://github.com/getAlby/nostr-wallet-connect/" class="dark:text-gray-300 underline"
>on GitHub</a
>
or
<a href="mailto:hello@getalby.com" class="dark:text-gray-300 underline">hello@getalby.com</a>
</p>
</div>
<div
class="bg-white rounded-md shadow p-4 lg:p-8 dark:bg-surface-02dp"
>
<h3 class="font-bold text-2xl font-headline mb-4 dark:text-white">For developers</h3>
<p class="mb-1 leading-relaxed dark:text-neutral-400">
Nostr Wallet Connect is an open protocol enabling applications to interact
with bitcoin lightning wallets. It allows users to connect their existing
wallets to your application allowing you to easily integrate bitcoin
lightning functionality but still focus on the core functionality of your
app.
</p>
<ul class="list-inside list-disc mb-8 dark:text-neutral-400">
<li>
<a
href="https://github.com/getAlby/nips/blob/master/47.md"
class="dark:text-gray-300 underline"
>Read the specs (NIP47)</a
>
</li>
<li>
<a
href="https://github.com/getAlby/alby-js-sdk#nostr-wallet-connect-documentation"
class="dark:text-gray-300 underline"
>Look at the JavaScript SDK</a
>
</li>
</ul>
<p class="sm:text-2xl font-bold font-headline mb-4 text-center dark:text-white">
Happy zapping!
<img
src="/public/images/nostr-bitcoin.png"
alt="Bitcoin on Nostr"
class="w-80 mx-auto"
/>
</p>
</div>
{{end}}

Some files were not shown because too many files have changed in this diff Show more