diff --git a/apps/deploy-web/src/components/deployments/CreateCertificateButton/CreateCertificateButton.spec.tsx b/apps/deploy-web/src/components/deployments/CreateCertificateButton/CreateCertificateButton.spec.tsx index ea32aa5ae..c2ce05242 100644 --- a/apps/deploy-web/src/components/deployments/CreateCertificateButton/CreateCertificateButton.spec.tsx +++ b/apps/deploy-web/src/components/deployments/CreateCertificateButton/CreateCertificateButton.spec.tsx @@ -1,4 +1,5 @@ import type { LocalCert } from "@src/context/CertificateProvider"; +import type { LocalToken } from "@src/hooks/useJwt"; import type { Props as CreateCertificateButtonProps } from "./CreateCertificateButton"; import { CreateCertificateButton, DEPENDENCIES as CREATE_CERTIFICATE_BUTTON_DEPENDENCIES } from "./CreateCertificateButton"; @@ -11,6 +12,12 @@ describe(CreateCertificateButton.name, () => { expect(screen.queryByRole("alert")).toHaveTextContent(/You need to create a certificate to view deployment details./); }); + it("renders create token button", () => { + setup({ isJwtEnabled: true }); + expect(screen.getByRole("button", { name: /create token/i })).toBeInTheDocument(); + expect(screen.queryByRole("alert")).toHaveTextContent(/You need to create a token to view deployment details./); + }); + it("calls createCertificate when clicked", async () => { const createCertificate = jest.fn(async () => {}); setup({ createCertificate }); @@ -21,6 +28,16 @@ describe(CreateCertificateButton.name, () => { expect(createCertificate).toHaveBeenCalled(); }); + it("calls createToken when clicked", async () => { + const createToken = jest.fn(async () => {}); + setup({ createToken, isJwtEnabled: true }); + + const button = screen.getByRole("button", { name: /create token/i }); + fireEvent.click(button); + + expect(createToken).toHaveBeenCalled(); + }); + it("displays warning text if certificate is expired", () => { setup({ localCert: { @@ -33,16 +50,43 @@ describe(CreateCertificateButton.name, () => { expect(screen.queryByRole("alert")).toHaveTextContent(/Your certificate has expired. Please create a new one./); }); + it("displays warning text if token is expired", () => { + setup({ + localToken: { + token: "expired", + address: "akash1234567890" + }, + isLocalTokenExpired: true, + isJwtEnabled: true + }); + expect(screen.queryByRole("alert")).toHaveTextContent(/Your token has expired. Please create a new one./); + }); + function setup( input?: CreateCertificateButtonProps & { createCertificate?: () => Promise; + createToken?: () => Promise; isCreatingCert?: boolean; isLocalCertExpired?: boolean; + isLocalTokenExpired?: boolean; localCert?: LocalCert; + localToken?: LocalToken; isBlockchainDown?: boolean; + isJwtEnabled?: boolean; } ) { - const { createCertificate, isCreatingCert, isLocalCertExpired, localCert, isBlockchainDown, ...props } = input ?? {}; + const { + createCertificate, + createToken, + isCreatingCert, + isLocalCertExpired, + isLocalTokenExpired, + localCert, + localToken, + isBlockchainDown, + isJwtEnabled, + ...props + } = input ?? {}; return render( { isSettingsInit: true, refreshNodeStatuses: jest.fn(), isRefreshingNodeStatus: false + }), + useJwt: () => ({ + isCreatingToken: false, + createToken: createToken ?? jest.fn(() => Promise.resolve()), + isLocalTokenExpired: isLocalTokenExpired ?? false, + localToken: localToken ?? null, + setLocalToken: jest.fn() + }), + useFlag: jest.fn().mockImplementation(flag => { + if (flag === "jwt_instead_of_cert") { + return isJwtEnabled ?? false; + } + + return false; }) }} /> diff --git a/apps/deploy-web/src/components/deployments/CreateCertificateButton/CreateCertificateButton.tsx b/apps/deploy-web/src/components/deployments/CreateCertificateButton/CreateCertificateButton.tsx index 3d0d44849..d7351e68b 100644 --- a/apps/deploy-web/src/components/deployments/CreateCertificateButton/CreateCertificateButton.tsx +++ b/apps/deploy-web/src/components/deployments/CreateCertificateButton/CreateCertificateButton.tsx @@ -6,13 +6,17 @@ import { cn } from "@akashnetwork/ui/utils"; import { useCertificate } from "@src/context/CertificateProvider"; import { useSettings } from "@src/context/SettingsProvider"; +import { useFlag } from "@src/hooks/useFlag"; +import { useJwt } from "@src/hooks/useJwt"; export const DEPENDENCIES = { Alert, Button, Spinner, useCertificate, - useSettings + useJwt, + useSettings, + useFlag }; export interface Props extends Omit { @@ -22,33 +26,62 @@ export interface Props extends Omit { } export const CreateCertificateButton: FC = ({ afterCreate, containerClassName, dependencies: d = DEPENDENCIES, ...buttonProps }) => { + const isJwtEnabled = d.useFlag("jwt_instead_of_cert"); const { settings } = d.useSettings(); const { isCreatingCert, createCertificate, isLocalCertExpired, localCert } = d.useCertificate(); + const { isCreatingToken, createToken, isLocalTokenExpired, localToken } = d.useJwt(); const _createCertificate = useCallback(async () => { await createCertificate(); afterCreate?.(); }, [createCertificate, afterCreate]); + const _createToken = useCallback(async () => { + await createToken(); + afterCreate?.(); + }, [createToken, afterCreate]); const warningText = useMemo(() => { + if (isJwtEnabled) { + if (isLocalTokenExpired) return "Your token has expired. Please create a new one."; + if (!localToken) return "You need to create a token to view deployment details."; + } + if (isLocalCertExpired) return "Your certificate has expired. Please create a new one."; if (!localCert) return "You need to create a certificate to view deployment details."; + return undefined; - }, [isLocalCertExpired, isCreatingCert, localCert]); - const buttonText = useMemo(() => (isLocalCertExpired ? "Regenerate Certificate" : "Create Certificate"), [isLocalCertExpired]); + }, [isJwtEnabled, isLocalCertExpired, isLocalTokenExpired, localCert, localToken]); + const buttonText = useMemo(() => { + if (isJwtEnabled) { + return isLocalTokenExpired ? "Regenerate Token" : "Create Token"; + } + + return isLocalCertExpired ? "Regenerate Certificate" : "Create Certificate"; + }, [isJwtEnabled, isLocalCertExpired, isLocalTokenExpired]); return (
{warningText} - - {isCreatingCert ? : buttonText} - + {isJwtEnabled ? ( + + {isCreatingToken ? : buttonText} + + ) : ( + + {isCreatingCert ? : buttonText} + + )}
); }; diff --git a/apps/deploy-web/src/context/SettingsProvider/SettingsProviderContext.tsx b/apps/deploy-web/src/context/SettingsProvider/SettingsProviderContext.tsx index 5f33341e1..d70c64c9b 100644 --- a/apps/deploy-web/src/context/SettingsProvider/SettingsProviderContext.tsx +++ b/apps/deploy-web/src/context/SettingsProvider/SettingsProviderContext.tsx @@ -29,7 +29,7 @@ export type Settings = { isBlockchainDown: boolean; }; -type ContextType = { +export type ContextType = { settings: Settings; setSettings: React.Dispatch>; isLoadingSettings: boolean; diff --git a/apps/deploy-web/src/hooks/useJwt.spec.tsx b/apps/deploy-web/src/hooks/useJwt.spec.tsx new file mode 100644 index 000000000..826645213 --- /dev/null +++ b/apps/deploy-web/src/hooks/useJwt.spec.tsx @@ -0,0 +1,369 @@ +import type { ChainContext } from "@cosmos-kit/core"; +import { mock } from "jest-mock-extended"; + +import type { AppDIContainer } from "@src/context/ServicesProvider"; +import type { ContextType as SettingsProviderContextType } from "@src/context/SettingsProvider/SettingsProviderContext"; +import type { ContextType as WalletProviderContextType } from "@src/context/WalletProvider/WalletProvider"; +import type { LocalWallet } from "@src/utils/walletUtils"; +import { useJwt } from "./useJwt"; + +import { act, renderHook, waitFor } from "@testing-library/react"; + +describe(useJwt.name, () => { + it("should return initial state when no address is provided", () => { + const { result } = setup({ + address: "", + isSettingsInit: true, + wallets: [] + }); + + expect(result.current.localToken).toBeNull(); + expect(result.current.isLocalTokenExpired).toBe(false); + expect(result.current.isCreatingToken).toBe(false); + expect(typeof result.current.createToken).toBe("function"); + expect(typeof result.current.setLocalToken).toBe("function"); + }); + + it("should return initial state when settings are not initialized", () => { + const { result } = setup({ + address: "akash123", + isSettingsInit: false, + wallets: [] + }); + + expect(result.current.localToken).toBeNull(); + expect(result.current.isLocalTokenExpired).toBe(false); + expect(result.current.isCreatingToken).toBe(false); + }); + + it("should load local token when address exists and settings are initialized", async () => { + const mockToken = "mock-jwt-token"; + const address = "akash123"; + const wallets = [{ address, token: mockToken }] as LocalWallet[]; + + const { result } = setup({ + address, + isSettingsInit: true, + wallets, + mockDecodedToken: { + exp: Math.floor(Date.now() / 1000) + 3600, // Not expired + iat: Math.floor(Date.now() / 1000), + iss: "https://example.com", + version: "v1" + } + }); + + await waitFor(() => { + expect(result.current.localToken).toEqual({ + token: mockToken, + address + }); + }); + + expect(result.current.isLocalTokenExpired).toBe(false); + }); + + it("should not load token when no matching wallet is found", async () => { + const address = "akash123"; + const wallets = [{ address: "different-address", token: "some-token" }] as LocalWallet[]; + + const { result } = setup({ + address, + isSettingsInit: true, + wallets + }); + + await waitFor(() => { + expect(result.current.localToken).toBeNull(); + }); + + expect(result.current.isLocalTokenExpired).toBe(false); + }); + + it("should handle wallet without token", async () => { + const address = "akash123"; + const wallets = [{ address, token: undefined }] as LocalWallet[]; + + const { result } = setup({ + address, + isSettingsInit: true, + wallets + }); + + await waitFor(() => { + expect(result.current.localToken).toBeNull(); + }); + }); + + it("should return null for localToken when token is expired", async () => { + const mockToken = "mock-jwt-token"; + const address = "akash123"; + const wallets = [{ address, token: mockToken }] as LocalWallet[]; + + const { result } = setup({ + address, + isSettingsInit: true, + wallets, + mockDecodedToken: { + exp: Math.floor(Date.now() / 1000) - 3600, // Expired + iat: Math.floor(Date.now() / 1000) - 7200, + iss: "https://example.com", + version: "v1" + } + }); + + await waitFor(() => { + expect(result.current.localToken).toBeNull(); + }); + + expect(result.current.isLocalTokenExpired).toBe(true); + }); + + it("should return token when not expired", async () => { + const mockToken = "mock-jwt-token"; + const address = "akash123"; + const wallets = [{ address, token: mockToken }] as LocalWallet[]; + + const { result } = setup({ + address, + isSettingsInit: true, + wallets, + mockDecodedToken: { + exp: Math.floor(Date.now() / 1000) + 3600, // Not expired + iat: Math.floor(Date.now() / 1000), + iss: "https://example.com", + version: "v1" + } + }); + + await waitFor(() => { + expect(result.current.localToken).toEqual({ + token: mockToken, + address + }); + }); + + expect(result.current.isLocalTokenExpired).toBe(false); + }); + + it("should create token successfully", async () => { + const address = "akash123"; + const mockToken = "new-jwt-token"; + + const { result, mocks } = setup({ + address, + isSettingsInit: true, + wallets: [], + mockCreatedToken: mockToken + }); + + await act(async () => { + await result.current.createToken(); + }); + + expect(mocks.useSelectedChain.getAccount).toHaveBeenCalled(); + expect(mocks.signAndBroadcastTx).toHaveBeenCalled(); + expect(mocks.updateWallet).toHaveBeenCalledWith(address, expect.any(Function)); + expect(mocks.analyticsService.track).toHaveBeenCalledWith("create_jwt", { + category: "certificates", + label: "Created jwt" + }); + expect(result.current.isCreatingToken).toBe(false); + }); + + it("should handle token creation error", async () => { + const address = "akash123"; + const error = new Error("Token creation failed"); + + const { result } = setup({ + address, + isSettingsInit: true, + wallets: [], + mockCreateTokenError: error + }); + + await expect(async () => { + await act(async () => { + await result.current.createToken(); + }); + }).rejects.toThrow("Token creation failed"); + + expect(result.current.isCreatingToken).toBe(false); + }); + + it("should handle transaction broadcast failure", async () => { + const address = "akash123"; + const mockToken = "new-jwt-token"; + + const { result, mocks } = setup({ + address, + isSettingsInit: true, + wallets: [], + mockCreatedToken: mockToken, + mockSignAndBroadcastTxResponse: false + }); + + await act(async () => { + await result.current.createToken(); + }); + + expect(mocks.useSelectedChain.getAccount).toHaveBeenCalled(); + expect(mocks.signAndBroadcastTx).toHaveBeenCalled(); + expect(mocks.updateWallet).not.toHaveBeenCalled(); + expect(mocks.analyticsService.track).not.toHaveBeenCalled(); + expect(result.current.isCreatingToken).toBe(false); + }); + + it("should clear local token when setLocalToken is called with null", async () => { + const { result } = setup({ + address: "akash123", + isSettingsInit: true, + wallets: [] + }); + + await act(async () => { + result.current.setLocalToken(null); + }); + + expect(result.current.localToken).toBeNull(); + }); + + it("should handle multiple wallets and find correct one", async () => { + const address = "akash123"; + const mockToken = "correct-token"; + const wallets = [ + { address: "akash111", token: "wrong-token-1" }, + { address: "akash222", token: "wrong-token-2" }, + { address, token: mockToken }, + { address: "akash333", token: "wrong-token-3" } + ] as LocalWallet[]; + + const { result } = setup({ + address, + isSettingsInit: true, + wallets, + mockDecodedToken: { + exp: Math.floor(Date.now() / 1000) + 3600, + iat: Math.floor(Date.now() / 1000), + iss: "https://example.com", + version: "v1" + } + }); + + await waitFor(() => { + expect(result.current.localToken).toEqual({ + token: mockToken, + address + }); + }); + }); + + it("should handle JWT token decoding error gracefully", async () => { + const mockToken = "invalid-jwt-token"; + const address = "akash123"; + const wallets = [{ address, token: mockToken }] as LocalWallet[]; + + const { result } = setup({ + address, + isSettingsInit: true, + wallets, + mockDecodeTokenError: new Error("Invalid JWT token") + }); + + await waitFor(() => { + expect(result.current.localToken).toBeNull(); + }); + + expect(result.current.isLocalTokenExpired).toBe(false); + }); + + function setup(input: { + address?: string; + isSettingsInit?: boolean; + wallets?: Array; + mockDecodedToken?: any; + mockCreatedToken?: string; + mockCreateTokenError?: Error; + mockSignAndBroadcastTxResponse?: any; + mockDecodeTokenError?: Error; + }) { + class MockJwtToken { + createToken() { + if (input.mockCreateTokenError) { + throw input.mockCreateTokenError; + } + + return input.mockCreatedToken ?? null; + } + + decodeToken() { + if (input.mockDecodeTokenError) { + throw input.mockDecodeTokenError; + } + + return input.mockDecodedToken ?? null; + } + } + + const mockUseSelectedChain = mock({ + getAccount: jest.fn().mockResolvedValue({ pubkey: "mock-pubkey" }), + signArbitrary: jest.fn().mockResolvedValue("mock-signature") + }); + + const mockAnalyticsService = mock(); + const mockUseServices = mock({ + analyticsService: mockAnalyticsService + }); + + const mockSignAndBroadcastTx = jest.fn().mockResolvedValue(input.mockSignAndBroadcastTxResponse ?? { success: true }); + const mockUseWallet = mock({ + address: input.address ?? "", + signAndBroadcastTx: mockSignAndBroadcastTx + }); + + const mockUseSettings = mock({ + settings: { + apiEndpoint: "https://api.example.com", + rpcEndpoint: "https://rpc.example.com", + isCustomNode: false, + nodes: [], + selectedNode: null, + customNode: null, + isBlockchainDown: false + } + }); + + const mockGetStorageWallets = jest.fn().mockReturnValue(input.wallets ?? []); + + const mockUpdateWallet = jest.fn().mockImplementation((address, updater) => { + const wallet = { address, token: null }; + return updater(wallet); + }); + + const { result, rerender } = renderHook(() => + useJwt({ + dependencies: { + useSelectedChain: () => mockUseSelectedChain, + useServices: () => mockUseServices, + useWallet: () => mockUseWallet, + useSettings: () => mockUseSettings, + getStorageWallets: mockGetStorageWallets, + updateWallet: mockUpdateWallet, + JwtToken: MockJwtToken as any + } + }) + ); + + return { + result, + rerender, + mocks: { + useSelectedChain: mockUseSelectedChain, + signAndBroadcastTx: mockSignAndBroadcastTx, + analyticsService: mockAnalyticsService, + getStorageWallets: mockGetStorageWallets, + updateWallet: mockUpdateWallet + } + }; + } +}); diff --git a/apps/deploy-web/src/hooks/useJwt.ts b/apps/deploy-web/src/hooks/useJwt.ts new file mode 100644 index 000000000..b92d0b174 --- /dev/null +++ b/apps/deploy-web/src/hooks/useJwt.ts @@ -0,0 +1,122 @@ +"use client"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { JwtToken, type JwtTokenPayload } from "@akashnetwork/jwt"; + +import { useSelectedChain } from "@src/context/CustomChainProvider"; +import { useServices } from "@src/context/ServicesProvider"; +import { useSettings } from "@src/context/SettingsProvider"; +import { useWallet } from "@src/context/WalletProvider"; +import { getStorageWallets, updateWallet } from "@src/utils/walletUtils"; + +export type LocalToken = { + token: string; + address: string; +}; + +const DEPENDENCIES = { + useSelectedChain, + useServices, + useWallet, + useSettings, + getStorageWallets, + updateWallet, + JwtToken +}; + +export const useJwt = ({ dependencies: d = DEPENDENCIES } = {}) => { + const { getAccount, signArbitrary } = d.useSelectedChain(); + const { analyticsService } = d.useServices(); + + const [isCreatingToken, setIsCreatingToken] = useState(false); + const [localToken, setLocalToken] = useState(null); + const { address } = d.useWallet(); + const { isSettingsInit } = d.useSettings(); + + useEffect(() => { + if (!isSettingsInit) return; + + setLocalToken(null); + + if (address) { + loadLocalToken(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [address, isSettingsInit]); + + const parsedLocalToken = useMemo(() => { + if (!localToken) return null; + + try { + const jwtToken = new d.JwtToken({} as any); + return jwtToken.decodeToken(localToken.token); + } catch (error) { + return null; + } + }, [d.JwtToken, localToken]); + + const loadLocalToken = useCallback(async () => { + const wallets = d.getStorageWallets(); + wallets.find(wallet => { + const token: LocalToken | null = wallet.token ? { token: wallet.token, address: wallet.address } : null; + + if (wallet.address === address) { + setLocalToken(token); + return true; + } + }); + }, [address, d]); + + const createToken = useCallback(async () => { + setIsCreatingToken(true); + + const { pubkey } = await getAccount(); + + const jwtToken = new d.JwtToken({ + signArbitrary, + address, + pubkey + }); + + const token = await jwtToken.createToken({ + version: "v1", + iss: address, + exp: Math.floor(Date.now() / 1000) + 3600, + iat: Math.floor(Date.now() / 1000) + }); + + try { + d.updateWallet(address, wallet => { + return { + ...wallet, + token + }; + }); + loadLocalToken(); + + analyticsService.track("create_jwt", { + category: "certificates", + label: "Created jwt" + }); + } finally { + setIsCreatingToken(false); + } + }, [getAccount, signArbitrary, address, d, loadLocalToken, analyticsService]); + + return useMemo(() => { + return { + get localToken() { + return !parsedLocalToken || isExpired(parsedLocalToken) ? null : localToken; + }, + get isLocalTokenExpired() { + return !!parsedLocalToken && isExpired(parsedLocalToken); + }, + setLocalToken, + createToken, + isCreatingToken + }; + }, [parsedLocalToken, localToken, setLocalToken, createToken, isCreatingToken]); +}; + +function isExpired(parsedLocalToken: JwtTokenPayload) { + return parsedLocalToken.exp < Date.now() / 1000; +} diff --git a/apps/deploy-web/src/services/analytics/analytics.service.ts b/apps/deploy-web/src/services/analytics/analytics.service.ts index 9ae21c6ca..7f8159495 100644 --- a/apps/deploy-web/src/services/analytics/analytics.service.ts +++ b/apps/deploy-web/src/services/analytics/analytics.service.ts @@ -37,6 +37,7 @@ export type AnalyticsEvent = | "create_certificate" | "regenerate_certificate" | "export_certificate" + | "create_jwt" | "deployment_deposit" | "close_deployment" | "use_depositor" diff --git a/apps/deploy-web/src/types/feature-flags.ts b/apps/deploy-web/src/types/feature-flags.ts index 1aca5dcb0..366269c75 100644 --- a/apps/deploy-web/src/types/feature-flags.ts +++ b/apps/deploy-web/src/types/feature-flags.ts @@ -6,4 +6,5 @@ export type FeatureFlag = | "billing_usage" | "custodial_auto_topup" | "ui_sdl_log_collector_enabled" - | "maintenance_banner"; + | "maintenance_banner" + | "jwt_instead_of_cert"; diff --git a/apps/deploy-web/src/utils/walletUtils.ts b/apps/deploy-web/src/utils/walletUtils.ts index f55f5be78..8226680a1 100644 --- a/apps/deploy-web/src/utils/walletUtils.ts +++ b/apps/deploy-web/src/utils/walletUtils.ts @@ -8,6 +8,7 @@ interface BaseLocalWallet { address: string; cert?: string; certKey?: string; + token?: string; selected: boolean; }