Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: unlink alby account #347

Merged
merged 4 commits into from
Jul 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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, "", "")
rolznz marked this conversation as resolved.
Show resolved Hide resolved
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(
rolznz marked this conversation as resolved.
Show resolved Hide resolved
"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
Loading