Skip to content

Commit

Permalink
feat: unlink alby account (#347)
Browse files Browse the repository at this point in the history
* feat: unlink alby account

* chore: address unlink account feedback
  • Loading branch information
rolznz authored Jul 30, 2024
1 parent 4d50a98 commit ffb6188
Show file tree
Hide file tree
Showing 9 changed files with 199 additions and 14 deletions.
66 changes: 61 additions & 5 deletions alby/alby_oauth_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ const (
userIdentifierKey = "AlbyUserIdentifier"
)

const ALBY_ACCOUNT_APP_NAME = "getalby.com"

func NewAlbyOAuthService(db *gorm.DB, cfg config.Config, keys keys.Keys, eventPublisher events.EventPublisher) *albyOAuthService {
conf := &oauth2.Config{
ClientID: cfg.GetEnv().AlbyClientId,
Expand Down Expand Up @@ -395,11 +397,23 @@ func (svc *albyOAuthService) GetAuthUrl() string {
return svc.oauthConf.AuthCodeURL("unused")
}

func (svc *albyOAuthService) LinkAccount(ctx context.Context, lnClient lnclient.LNClient, budget uint64, renewal string) error {
appName := "getalby.com"
func (svc *albyOAuthService) UnlinkAccount(ctx context.Context) error {
err := svc.destroyAlbyAccountNWCNode(ctx)
if err != nil {
logger.Logger.WithError(err).Error("Failed to destroy Alby Account NWC node")
}
svc.deleteAlbyAccountApps()

// delete any existing getalby.com connections to ensure user only sees the new one
svc.db.Where("name = ?", appName).Delete(&db.App{})
svc.cfg.SetUpdate(userIdentifierKey, "", "")
svc.cfg.SetUpdate(accessTokenKey, "", "")
svc.cfg.SetUpdate(accessTokenExpiryKey, "", "")
svc.cfg.SetUpdate(refreshTokenKey, "", "")

return nil
}

func (svc *albyOAuthService) LinkAccount(ctx context.Context, lnClient lnclient.LNClient, budget uint64, renewal string) error {
svc.deleteAlbyAccountApps()

connectionPubkey, err := svc.createAlbyAccountNWCNode(ctx)
if err != nil {
Expand All @@ -418,7 +432,7 @@ func (svc *albyOAuthService) LinkAccount(ctx context.Context, lnClient lnclient.
}

app, _, err := db.NewDBService(svc.db, svc.eventPublisher).CreateApp(
appName,
ALBY_ACCOUNT_APP_NAME,
connectionPubkey,
budget,
renewal,
Expand Down Expand Up @@ -719,6 +733,40 @@ func (svc *albyOAuthService) createAlbyAccountNWCNode(ctx context.Context) (stri
return responsePayload.Pubkey, nil
}

func (svc *albyOAuthService) destroyAlbyAccountNWCNode(ctx context.Context) error {
token, err := svc.fetchUserToken(ctx)
if err != nil {
logger.Logger.WithError(err).Error("Failed to fetch user token")
}

client := svc.oauthConf.Client(ctx, token)

req, err := http.NewRequest("DELETE", fmt.Sprintf("%s/internal/nwcs", svc.cfg.GetEnv().AlbyAPIURL), nil)
if err != nil {
logger.Logger.WithError(err).Error("Error creating request /internal/nwcs")
return err
}

setDefaultRequestHeaders(req)

resp, err := client.Do(req)
if err != nil {
logger.Logger.WithError(err).Error("Failed to send request to /internal/nwcs")
return err
}

if resp.StatusCode >= 300 {
logger.Logger.WithFields(logrus.Fields{
"status": resp.StatusCode,
}).Error("Request to /internal/nwcs returned non-success status")
return errors.New("request to /internal/nwcs returned non-success status")
}

logger.Logger.Info("Removed alby account nwc node successfully")

return nil
}

func (svc *albyOAuthService) activateAlbyAccountNWCNode(ctx context.Context) error {
token, err := svc.fetchUserToken(ctx)
if err != nil {
Expand Down Expand Up @@ -1054,3 +1102,11 @@ func setDefaultRequestHeaders(req *http.Request) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "AlbyHub/"+version.Tag)
}

func (svc *albyOAuthService) deleteAlbyAccountApps() {
// delete any existing getalby.com connections so when re-linking the user only has one
err := svc.db.Where("name = ?", ALBY_ACCOUNT_APP_NAME).Delete(&db.App{}).Error
if err != nil {
logger.Logger.WithError(err).Error("Failed to delete Alby Account apps")
}
}
1 change: 1 addition & 0 deletions alby/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type AlbyOAuthService interface {
GetMe(ctx context.Context) (*AlbyMe, error)
SendPayment(ctx context.Context, invoice string) error
DrainSharedWallet(ctx context.Context, lnClient lnclient.LNClient) error
UnlinkAccount(ctx context.Context) error
RequestAutoChannel(ctx context.Context, lnClient lnclient.LNClient, isPublic bool) (*AutoChannelResponse, error)
}

Expand Down
11 changes: 7 additions & 4 deletions frontend/src/components/AuthCodeForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,26 @@ import { handleRequestError } from "src/utils/handleRequestError";
import { openLink } from "src/utils/openLink";
import { request } from "src/utils/request"; // build the project for this to appear

function AuthCodeForm() {
type AuthCodeFormProps = {
url: string;
};

function AuthCodeForm({ url }: AuthCodeFormProps) {
const [authCode, setAuthCode] = useState("");
const navigate = useNavigate();
const { data: csrf } = useCSRF();
const { data: info } = useInfo();
const { mutate: refetchInfo } = useInfo();

const [hasRequestedCode, setRequestedCode] = React.useState(false);
const [isLoading, setLoading] = React.useState(false);

async function requestAuthCode() {
setRequestedCode((hasRequestedCode) => {
if (!info) {
if (!url) {
return false;
}
if (!hasRequestedCode) {
openLink(info.albyAuthUrl);
openLink(url);
}
return true;
});
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/layouts/SettingsLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export default function SettingsLayout() {
{hasNodeBackup && (
<MenuItem to="/settings/node-backup">Migrate Node</MenuItem>
)}
<MenuItem to="/settings/alby-account">Alby Account</MenuItem>
<MenuItem to="/debug-tools">
Debug Tools
<ExternalLink className="w-4 h-4 ml-2" />
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import BuyBitcoin from "src/screens/onchain/BuyBitcoin";
import DepositBitcoin from "src/screens/onchain/DepositBitcoin";
import ConnectPeer from "src/screens/peers/ConnectPeer";
import Peers from "src/screens/peers/Peers";
import { AlbyAccount } from "src/screens/settings/AlbyAccount";
import { ChangeUnlockPassword } from "src/screens/settings/ChangeUnlockPassword";
import DebugTools from "src/screens/settings/DebugTools";
import Settings from "src/screens/settings/Settings";
Expand Down Expand Up @@ -129,6 +130,10 @@ const routes = [
path: "node-backup",
element: <BackupNode />,
},
{
path: "alby-account",
element: <AlbyAccount />,
},
],
},
],
Expand Down
19 changes: 15 additions & 4 deletions frontend/src/screens/alby/AlbyAuthRedirect.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,31 @@
import React from "react";
import { useLocation } from "react-router-dom";
import AuthCodeForm from "src/components/AuthCodeForm";

import Loading from "src/components/Loading";
import { useInfo } from "src/hooks/useInfo";

export default function AlbyAuthRedirect() {
const { data: info } = useInfo();
const location = useLocation();
const queryParams = new URLSearchParams(location.search);
const forceLogin = !!queryParams.get("force_login");
const url = info?.albyAuthUrl
? `${info.albyAuthUrl}${forceLogin ? "&force_login=true" : ""}`
: undefined;

React.useEffect(() => {
if (!info) {
if (!info || !url) {
return;
}
if (info.oauthRedirect) {
window.location.href = info.albyAuthUrl;
window.location.href = url;
}
}, [info]);
}, [info, url]);

return !info || info.oauthRedirect ? <Loading /> : <AuthCodeForm />;
return !info || info.oauthRedirect || !url ? (
<Loading />
) : (
<AuthCodeForm url={url} />
);
}
87 changes: 87 additions & 0 deletions frontend/src/screens/settings/AlbyAccount.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { ExitIcon } from "@radix-ui/react-icons";
import { ExternalLinkIcon } from "lucide-react";
import { useNavigate } from "react-router-dom";

import ExternalLink from "src/components/ExternalLink";
import SettingsHeader from "src/components/SettingsHeader";
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "src/components/ui/card";
import { useToast } from "src/components/ui/use-toast";
import { useCSRF } from "src/hooks/useCSRF";
import { request } from "src/utils/request";

export function AlbyAccount() {
const { data: csrf } = useCSRF();
const { toast } = useToast();
const navigate = useNavigate();

const unlink = async () => {
if (
!confirm(
"Are you sure you want to change the Alby Account for your hub? Your Alby Account will be disconnected from your hub and you'll need to login with a new Alby Account to access your hub."
)
) {
return;
}

try {
if (!csrf) {
throw new Error("No CSRF token");
}
await request("/api/alby/unlink-account", {
method: "POST",
headers: {
"X-CSRF-Token": csrf,
"Content-Type": "application/json",
},
});
navigate("/alby/auth?force_login=true");
toast({
title: "Alby Account Unlinked",
description: "Please login with another Alby Account",
});
} catch (error) {
toast({
title: "Unlink account failed",
description: (error as Error).message,
variant: "destructive",
});
}
};

return (
<>
<SettingsHeader
title="Alby Account"
description="Manage your Alby Account"
/>
<ExternalLink
to="https://getalby.com/settings"
className="w-full flex flex-row items-center gap-2"
>
<Card className="w-full">
<CardHeader>
<CardTitle>Your Alby Account</CardTitle>
<CardDescription className="flex gap-2 items-center">
<ExternalLinkIcon className="w-4 h-4" /> Manage your Alby Account
Settings
</CardDescription>
</CardHeader>
</Card>
</ExternalLink>
<Card className="w-full cursor-pointer" onClick={unlink}>
<CardHeader>
<CardTitle>Change Alby Account</CardTitle>
<CardDescription className="flex gap-2 items-center">
<ExitIcon className="w-4 h-4" /> Link your Hub to a different Alby
Account
</CardDescription>
</CardHeader>
</Card>
</>
);
}
17 changes: 16 additions & 1 deletion http/alby_http_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ func (albyHttpSvc *AlbyHttpService) RegisterSharedRoutes(e *echo.Echo, authMiddl
e.POST("/api/alby/drain", albyHttpSvc.albyDrainHandler, authMiddleware)
e.POST("/api/alby/link-account", albyHttpSvc.albyLinkAccountHandler, authMiddleware)
e.POST("/api/alby/auto-channel", albyHttpSvc.autoChannelHandler, authMiddleware)
e.POST("/api/alby/unlink-account", albyHttpSvc.unlinkHandler, authMiddleware)
}

func (albyHttpSvc *AlbyHttpService) autoChannelHandler(c echo.Context) error {
Expand All @@ -49,13 +50,27 @@ func (albyHttpSvc *AlbyHttpService) autoChannelHandler(c echo.Context) error {

if err != nil {
return c.JSON(http.StatusInternalServerError, ErrorResponse{
Message: fmt.Sprintf("Failed to request wrapped invoice: %s", err.Error()),
Message: fmt.Sprintf("Failed to request auto channel: %s", err.Error()),
})
}

return c.JSON(http.StatusOK, autoChannelResponseResponse)
}

func (albyHttpSvc *AlbyHttpService) unlinkHandler(c echo.Context) error {
ctx := c.Request().Context()

err := albyHttpSvc.albyOAuthSvc.UnlinkAccount(ctx)

if err != nil {
return c.JSON(http.StatusInternalServerError, ErrorResponse{
Message: fmt.Sprintf("Failed to request wrapped invoice: %s", err.Error()),
})
}

return c.NoContent(http.StatusNoContent)
}

func (albyHttpSvc *AlbyHttpService) albyCallbackHandler(c echo.Context) error {
code := c.QueryParam("code")

Expand Down
6 changes: 6 additions & 0 deletions wails/wails_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,12 @@ func (app *WailsApp) WailsRequestRouter(route string, method string, body string
return WailsRequestRouterResponse{Body: nil, Error: err.Error()}
}
return WailsRequestRouterResponse{Body: nil, Error: ""}
case "/api/alby/unlink-account":
err := app.svc.GetAlbyOAuthSvc().UnlinkAccount(ctx)
if err != nil {
return WailsRequestRouterResponse{Body: nil, Error: err.Error()}
}
return WailsRequestRouterResponse{Body: nil, Error: ""}
case "/api/alby/pay":
payRequest := &alby.AlbyPayRequest{}
err := json.Unmarshal([]byte(body), payRequest)
Expand Down

0 comments on commit ffb6188

Please sign in to comment.