diff --git a/.github/assets/addnewaccount.png b/.github/assets/addnewaccount.png new file mode 100644 index 0000000..2f39d9b Binary files /dev/null and b/.github/assets/addnewaccount.png differ diff --git a/.github/assets/confirmations.png b/.github/assets/confirmations.png new file mode 100644 index 0000000..d92a15a Binary files /dev/null and b/.github/assets/confirmations.png differ diff --git a/.github/assets/selectaccount.png b/.github/assets/selectaccount.png new file mode 100644 index 0000000..b097a09 Binary files /dev/null and b/.github/assets/selectaccount.png differ diff --git a/.github/assets/tradeoffers.png b/.github/assets/tradeoffers.png new file mode 100644 index 0000000..57522fa Binary files /dev/null and b/.github/assets/tradeoffers.png differ diff --git a/README.md b/README.md index f1abc4e..6b0a36c 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,61 @@ # Thunder Authenticator -A Steam Desktop authenticator heavily inspired by [Vapor-Authenticator](https://github.com/HilliamT/Vapor-Authenticator). I like the connection Vapors name has to Steam, but since my name is Zeus, Thunder fits me better! +Thunder is a 3rd party desktop authenticator and account manager for Steam. You can use it to generate Steam Guard codes, confirm trades/market transactions, and use the steam website within the app logged in as your selected account without needing to use a mobile phone. -Mostly just wanted to see if I could make a more up to date version of it myself, with some features I thought were lacking there. Vapor unfortunately has breaking bugs and seems abandoned :( +### Table of Contents +- [Features](#features) +- [Download](#download) +- [Showcase images](#showcase-images) +- [Development quickstart](#development-quickstart) -## Development +## First, a word of caution: -### Install Dependencies +> [!WARNING] +> IF YOU HAVE A MOBILE PHONE, USE THE OFFICIAL STEAM MOBILE AUTHENTICATOR INSTEAD. The whole purpose of 2FA is to protect your account, and using a desktop authenticator is inherently less secure than using a mobile one. Only use this if you don't have a phone or have a good reason not to use the official app! -``` -$ cd thunder-authenticator -$ yarn install -``` +> [!CAUTION] +> IF you lost your config file or forgot your password, go [here](https://store.steampowered.com/twofactor/manage) and click "Remove authenticator" then enter the revocation code that you saved when you first set up the authenticator. This will remove the authenticator from your account, allowing you to set it up again. + +Now onto the good stuff + +## Features +- Password login, used to encrypt your account data locally +- Generate Steam Guard codes for any number of accounts +- Confirm trades and market transactions +- View the Steam website within the app logged in as your selected account +- Easily import existing accounts from Steam Desktop Authenticator +- Export account information for use in for example trading bots + +### Planned features +- Adding tags or notes to accounts for easier organization + +Have other feature ideas? Let me know by opening an [issue](https://github.com/ZeusJunior/thunder/issues/new)! -### Use it +## Download +You can download the latest release for your system from the [releases page](https://github.com/ZeusJunior/thunder/releases/latest). + +## Showcase images +Selecting an account to use, if there are 5 or more a search box will appear +![Select account](./.github/assets/selectaccount.png) + +Adding a new account +![Add account](./.github/assets/addnewaccount.png) + +Viewing your confirmations +![Confirmations](./.github/assets/confirmations.png) + +Using the Steam website within the app +![Steam website](./.github/assets/tradeoffers.png) + +## Development quickstart ``` -# development mode +$ cd thunder +$ yarn install + +# start dev server $ yarn dev -# production build +# or build for production $ yarn build ``` diff --git a/main/background.ts b/main/background.ts index b4c1d20..e3e770c 100644 --- a/main/background.ts +++ b/main/background.ts @@ -1,10 +1,10 @@ import path from 'path'; -import { app, ipcMain, shell, dialog } from 'electron'; +import { app, ipcMain, shell, dialog, IpcMainEvent } from 'electron'; import serve from 'electron-serve'; import { createWindow, getCurrentAccount, getDebugInfo, configFileExists, getAllAccounts, setCurrentAccount, addAccount, accountExists } from './helpers'; import SteamCommunity from 'steamcommunity'; import { addAuthenticator, finalizeAuthenticator, getAuthCode, loginAgain, refreshProfile } from './helpers/steam'; -import { createEncryptedStore, initializeStore } from './store'; +import { createEncryptedStore, initializeStore, verifyPassword } from './store'; import { Account, Confirmation, IpcHandlers, MaFileData } from './types'; import { readFile } from 'fs/promises'; import { getConfirmationKey, time } from 'steam-totp'; @@ -123,27 +123,12 @@ app.on('window-all-closed', () => { ipcMain.on('message', async (event, arg) => { event.reply('message', `${arg} World!`); }); -ipcMain.on( - 'open-new-window', - async (event, { url, external }: { url: string; external: boolean }) => { - if (external) { - await shell.openExternal(url); - return; - } - - const newWindow = createWindow('external', { - width: 1200, - height: 800, - webPreferences: { - preload: path.join(__dirname, 'preload.js'), - }, - }); - - await newWindow.loadURL(url); - } -); +ipcMain.on('open-browser-github', async () => { + await shell.openExternal('https://github.com/ZeusJunior/thunder'); + return; +}); -ipcMain.on('open-steam-window', async (event, { url }: { url: string }) => { +function openSteamWindow(event: IpcMainEvent, url: string) { const account = getCurrentAccount(false); if (!account) { throw new Error('No current account set'); @@ -157,7 +142,16 @@ ipcMain.on('open-steam-window', async (event, { url }: { url: string }) => { return; } - const proceed = async () => { + const proceed = async (cookiesRefreshed = false) => { + // Set cookies again if they were refreshed + if (cookiesRefreshed) { + const account = getCurrentAccount(false); + if (!account) { + throw new Error('No current account set'); + } + community.setCookies(account.cookies || []); + } + const steamWindow = createWindow('steam', { width: 1200, height: 800, @@ -189,7 +183,7 @@ ipcMain.on('open-steam-window', async (event, { url }: { url: string }) => { refreshToken: account.refreshToken, }) .then(() => { - return proceed(); + return proceed(true); }) .catch(() => { event.reply('login-required'); @@ -197,6 +191,13 @@ ipcMain.on('open-steam-window', async (event, { url }: { url: string }) => { return; }); +} +ipcMain.on('open-steam-community', async (event) => { + openSteamWindow(event, 'https://steamcommunity.com/'); +}); + +ipcMain.on('open-steam-tradeoffers', async (event) => { + openSteamWindow(event, 'https://steamcommunity.com/my/tradeoffers'); }); handleIpc('debug-info', async () => { @@ -216,6 +217,11 @@ handleIpc('config-initialize', async (event, password) => { return initializeStore(password); }); +ipcMain.on('show-app-data-directory', async () => { + const appDataPath = app.getPath('userData'); + shell.showItemInFolder(path.join(appDataPath, 'config.json')); +}); + // Account handlers handleIpc('get-all-accounts', async () => { return getAllAccounts(); @@ -263,6 +269,27 @@ handleIpc('get-auth-code', async () => { return getAuthCode(account.sharedSecret); }); +handleIpc('export-account-secrets', async (event, password: string) => { + try { + const passwordValid = verifyPassword(password); + if (!passwordValid) { + return { error: 'Invalid password' }; + } + + const account = getCurrentAccount(false); + if (!account) { + return { error: 'No current account set' }; + } + + return { + sharedSecret: account.sharedSecret, + identitySecret: account.identitySecret, + }; + } catch { + return { error: 'Invalid password' }; + } +}); + handleIpc('show-mafile-dialog', async () => { const result = await dialog.showOpenDialog({ title: 'Select maFile', @@ -353,7 +380,16 @@ handleIpc('get-confirmations', async (event) => { return resolve([]); } - const proceed = async () => { + const proceed = async (cookiesRefreshed = false) => { + // Set cookies again if they were refreshed + if (cookiesRefreshed) { + const account = getCurrentAccount(false); + if (!account) { + throw new Error('No current account set'); + } + community.setCookies(account.cookies || []); + } + community.getConfirmations(time(), getConfirmationKey(account.identitySecret, time(), 'conf'), async (err, confirmations) => { if (err) { return reject(err); @@ -377,7 +413,7 @@ handleIpc('get-confirmations', async (event) => { refreshToken: account.refreshToken, }) .then(() => { - return proceed(); + return proceed(true); }) .catch(() => { event.sender.send('login-required'); diff --git a/main/helpers/create-window.ts b/main/helpers/create-window.ts index 4ee2595..ce8d09b 100644 --- a/main/helpers/create-window.ts +++ b/main/helpers/create-window.ts @@ -72,6 +72,7 @@ export const createWindow = ( state = ensureVisibleOnSomeDisplay(restore()); const win = new BrowserWindow({ + autoHideMenuBar: true, ...state, ...options, webPreferences: { diff --git a/main/helpers/steam.ts b/main/helpers/steam.ts index 39392d8..30752c3 100644 --- a/main/helpers/steam.ts +++ b/main/helpers/steam.ts @@ -11,22 +11,39 @@ export function loginAgain(details: SteamUser.LogOnDetailsNamePass | SteamUser.L let loggedOn = false; let cookies: string[] = []; let newRefreshToken = ''; + let hasResolved = false; const user = new SteamUser({ renewRefreshTokens: true }); user.logOn(details); const saveAndResolve = () => { + if (hasResolved) return; + hasResolved = true; + const steamId = user.steamID!.getSteamID64(); updateAccount(steamId, { cookies, - refreshToken: newRefreshToken, + ...(newRefreshToken ? { refreshToken: newRefreshToken } : {}), }); return resolve(); }; + const checkReadyAndSetTimeout = () => { + if (loggedOn && cookies.length > 0) { + // Wait up to 1 extra second for refreshToken, then proceed anyway + // It doesn't always fire or possibly after loggedOn and webSession events fire. + // Do still want to try and save the new one as the old one is expired if we get it. + setTimeout(() => { + if (!hasResolved) { + console.log('Proceeding without new refresh token after timeout'); + saveAndResolve(); + } + }, 1000); + } + }; + user.on('error', (err) => { - // TODO: Figure out specific EResult for invalid/expired refresh token? - // TODO: Handle this error better in the UI + // TODO: Handle any errors here better in the UI console.error('Error re-authenticating:', err); return reject(new Error(err.message)); }); @@ -34,19 +51,17 @@ export function loginAgain(details: SteamUser.LogOnDetailsNamePass | SteamUser.L user.on('loggedOn', () => { console.log('Re-authenticated successfully for', user.steamID!.getSteamID64()); loggedOn = true; - if (cookies.length > 0 && newRefreshToken) { - return saveAndResolve(); - } + checkReadyAndSetTimeout(); }); user.on('webSession', (_sessionID, webSession) => { + console.log('Obtained new web session for', user.steamID!.getSteamID64()); cookies = webSession; - if (loggedOn && newRefreshToken) { - return saveAndResolve(); - } + checkReadyAndSetTimeout(); }); user.on('refreshToken', (token) => { + console.log('Obtained new refresh token for', user.steamID!.getSteamID64()); newRefreshToken = token; if (loggedOn && cookies.length > 0) { return saveAndResolve(); diff --git a/main/preload.ts b/main/preload.ts index cbcef22..4aadb8f 100644 --- a/main/preload.ts +++ b/main/preload.ts @@ -17,11 +17,21 @@ const handler = { create: (password: string) => invoke('config-create', password), initialize: (password: string) => invoke('config-initialize', password), }, - openWindow: (url: string, external: boolean) => { - ipcRenderer.send('open-new-window', { url, external }); + showAppDataDirectory: () => { + ipcRenderer.send('show-app-data-directory'); }, - openSteamWindow: (url: string) => { - ipcRenderer.send('open-steam-window', { url }); + openBrowser: { + github: () => { + ipcRenderer.send('open-browser-github'); + } + }, + openSteamWindow: { + community: () => { + ipcRenderer.send('open-steam-community'); + }, + tradeOffers: () => { + ipcRenderer.send('open-steam-tradeoffers'); + }, }, addAuthenticator: ( @@ -43,6 +53,9 @@ const handler = { getAuthCode: () => { return invoke('get-auth-code'); }, + exportAccountSecrets: (password: string) => { + return invoke('export-account-secrets', password); + }, showMaFileDialog: () => { return invoke('show-mafile-dialog'); diff --git a/main/store.ts b/main/store.ts index e53f241..ae70b58 100644 --- a/main/store.ts +++ b/main/store.ts @@ -56,4 +56,30 @@ export function createEncryptedStore(password: string) { */ export function getStore() { return store; +} + +/** + * Verify password is correct for the encrypted store + * @param password The encryption password + * @returns boolean indicating if the password is correct + */ +export function verifyPassword(password: string) { + try { + const tempStore = new Store({ + name: 'config', + encryptionKey: password, + }); + + // Verify the store is accessible by trying to read from it + const initialized = tempStore.get('initialized'); + if (!initialized) { + // Corrupted config + return false; + } + + return true; + } catch (error) { + console.error('Error verifying password:', error); + return false; + } } \ No newline at end of file diff --git a/main/types.ts b/main/types.ts index 434666d..a3ec2c7 100644 --- a/main/types.ts +++ b/main/types.ts @@ -97,6 +97,7 @@ export interface IpcHandlers { 'finalize-authenticator': (steamId: string, activationCode: string) => Promise; 'login-again': (password: string) => Promise; 'get-auth-code': () => Promise; + 'export-account-secrets': (password: string) => Promise<{ identitySecret: string; sharedSecret: string } | { error: string }>; 'show-mafile-dialog': () => Promise; 'import-mafile': (filePath: string) => Promise; 'get-confirmations': () => Promise; diff --git a/renderer/components/AccountSelector/AccountList.tsx b/renderer/components/AccountSelector/AccountList.tsx index 9863d62..76939a8 100644 --- a/renderer/components/AccountSelector/AccountList.tsx +++ b/renderer/components/AccountSelector/AccountList.tsx @@ -2,6 +2,7 @@ import Image from 'next/image'; import { useEffect, useState } from 'react'; import { useAccount } from '../../context/AccountContext'; import ReloadIcon from '../Icons/Reload'; +import { ErrorMessage } from '../ErrorMessage'; export default function AccountList({ onSelect }: { onSelect: (accountId: string) => void }) { const { accounts, isLoading, loadAccounts } = useAccount(); @@ -74,11 +75,7 @@ export default function AccountList({ onSelect }: { onSelect: (accountId: string )} - {error && ( -
- {error} -
- )} + {error && ()} {/* Accounts List */}
diff --git a/renderer/components/AccountSelector/AccountSelector.tsx b/renderer/components/AccountSelector/AccountSelector.tsx index 9437765..7b0bd99 100644 --- a/renderer/components/AccountSelector/AccountSelector.tsx +++ b/renderer/components/AccountSelector/AccountSelector.tsx @@ -5,6 +5,8 @@ import AccountList from './AccountList'; import NewAuthenticator from './Forms/NewAuthenticator'; import ImportSDA from './Forms/ImportSDA'; import ImportOptions from './ImportOptions'; +import { PageContainer } from '../Layout/PageContainer'; +import { ErrorMessage } from '../ErrorMessage'; interface AccountSelectorProps { onAccountSelected?: () => void; @@ -52,9 +54,9 @@ export default function AccountSelector({ onAccountSelected = () => { } }: Accou return ( <> - {isFirstAccount ? 'Add your first account - Thunder' : 'Select Account - Thunder'} + {isFirstAccount ? 'Add your first account' : 'Select account'} - Thunder -
+

@@ -67,11 +69,7 @@ export default function AccountSelector({ onAccountSelected = () => { } }: Accou

- {error && ( -
- {error} -
- )} + {error && ()} {!addAccountMode ? (
@@ -97,7 +95,7 @@ export default function AccountSelector({ onAccountSelected = () => { } }: Accou
)}
-
+ ); } diff --git a/renderer/components/AccountSelector/Forms/ImportSDA.tsx b/renderer/components/AccountSelector/Forms/ImportSDA.tsx index 857965d..c0a1582 100644 --- a/renderer/components/AccountSelector/Forms/ImportSDA.tsx +++ b/renderer/components/AccountSelector/Forms/ImportSDA.tsx @@ -1,4 +1,7 @@ import { useState } from 'react'; +import { ErrorMessage } from '../../ErrorMessage'; +import SecondaryButton from '../../Form/SecondaryButton'; +import PrimaryButton from '../../Form/PrimaryButton'; interface NewAuthenticatorProps { onSuccess: (accountId: string) => void; @@ -57,11 +60,7 @@ export default function ImportSDA({ onSuccess, onCancel }: NewAuthenticatorProps

- {error && ( -
-

{error}

-
- )} + {error && ()}
@@ -69,37 +68,25 @@ export default function ImportSDA({ onSuccess, onCancel }: NewAuthenticatorProps Select maFile
- - {selectedFile && ( - - {selectedFile.split(/[\\/]/).pop()} - - )} + text={selectedFile ? selectedFile.split(/[\\/]/).pop() as string : 'Browse files'} + />
- - + text='Cancel' + />
diff --git a/renderer/components/AccountSelector/Forms/NewAuthenticator.tsx b/renderer/components/AccountSelector/Forms/NewAuthenticator.tsx index bd54b52..eb09d72 100644 --- a/renderer/components/AccountSelector/Forms/NewAuthenticator.tsx +++ b/renderer/components/AccountSelector/Forms/NewAuthenticator.tsx @@ -1,4 +1,7 @@ import { useState } from 'react'; +import { ErrorMessage } from '../../ErrorMessage'; +import SecondaryButton from '../../Form/SecondaryButton'; +import PrimaryButton from '../../Form/PrimaryButton'; interface NewAuthenticatorProps { onSuccess: (accountId: string) => void; @@ -47,11 +50,7 @@ export default function NewAuthenticator({ onSuccess, onCancel }: NewAuthenticat ) } - {error && ( -
- {error} -
- )} + {error && ()} {getStepContent()} @@ -167,24 +166,16 @@ function LoginStep({ onCancel, setError, setSteamId, setRecoveryCode, nextStep } )}
- - + text='Cancel' + />
); @@ -237,28 +228,21 @@ function FinalizeStep({ onCancel, setError, nextStep, steamId }: FinalizeStepPro

- Node:If you have a phone number linked to your account, then you'll be sent an SMS with an activation code. Otherwise, you'll receive the activation code by email. + Note: If you have a phone number linked to your account, then you'll be sent an SMS with an activation code. Otherwise, you'll receive the activation code by email.

- - + text='Cancel' + />
); @@ -296,13 +280,11 @@ function CheckRecoveryCodeStep({ setError, recoveryCode, nextStep }: CheckRecove

{recoveryCode}

- + text='I have saved the recovery code' + /> ); } @@ -325,14 +307,10 @@ function CheckRecoveryCodeStep({ setError, recoveryCode, nextStep }: CheckRecove /> -
- -
+ ); } @@ -348,13 +326,11 @@ function CongratulationsStep({ onContinue }: CongratulationsStepProps) {

A Steam Guard authenticator has been successfully added to your account!

- + text='Continue' + /> ); } diff --git a/renderer/components/AccountSelector/ImportOptions.tsx b/renderer/components/AccountSelector/ImportOptions.tsx index 4678b00..a199f46 100644 --- a/renderer/components/AccountSelector/ImportOptions.tsx +++ b/renderer/components/AccountSelector/ImportOptions.tsx @@ -1,3 +1,5 @@ +import PrimaryButton from '../Form/PrimaryButton'; +import SecondaryButton from '../Form/SecondaryButton'; import CloudDownloadIcon from '../Icons/CloudDownload'; import PlusIcon from '../Icons/Plus'; @@ -11,25 +13,21 @@ export default function ImportOptions({ onSelect, isFirstAccount }: ImportOption return (
- + icon={} + text="New Authenticator" + /> - + text='Import from SDA maFile' + icon={} + />
); @@ -40,22 +38,18 @@ export default function ImportOptions({ onSelect, isFirstAccount }: ImportOption

Add another account:

- - + text="Import from SDA" + />
diff --git a/renderer/components/ErrorMessage.tsx b/renderer/components/ErrorMessage.tsx new file mode 100644 index 0000000..0a5e7cb --- /dev/null +++ b/renderer/components/ErrorMessage.tsx @@ -0,0 +1,10 @@ +export const ErrorMessage = ({ title, message }: { title?: string; message: string }) => { + return ( +
+ {title &&

{title}

} +

+ {message} +

+
+ ); +}; \ No newline at end of file diff --git a/renderer/components/Form/PrimaryButton.tsx b/renderer/components/Form/PrimaryButton.tsx new file mode 100644 index 0000000..e246dd6 --- /dev/null +++ b/renderer/components/Form/PrimaryButton.tsx @@ -0,0 +1,44 @@ +import SmallLoadingSpinner from '../Icons/SmallLoadingSpinner'; + +interface ButtonProps { + onClick?: () => void; + text: string; + loadingText?: string; + icon?: React.ReactNode; + disabled?: boolean; + isLoading?: boolean; + type?: 'submit' | 'button'; +} + +export default function PrimaryButton({ + onClick, + text, + loadingText, + icon, + disabled, + isLoading, + type = 'submit', +}: ButtonProps) { + return ( + + ); +} \ No newline at end of file diff --git a/renderer/components/Form/SecondaryButton.tsx b/renderer/components/Form/SecondaryButton.tsx new file mode 100644 index 0000000..00a66b8 --- /dev/null +++ b/renderer/components/Form/SecondaryButton.tsx @@ -0,0 +1,26 @@ +interface ButtonProps { + onClick?: () => void; + text: string; + icon?: React.ReactNode; +} + +export default function SecondaryButton({ + onClick, + text, + icon, +}: ButtonProps) { + return ( + + ); +} \ No newline at end of file diff --git a/renderer/components/Icons/Settings.tsx b/renderer/components/Icons/Settings.tsx new file mode 100644 index 0000000..d0090fd --- /dev/null +++ b/renderer/components/Icons/Settings.tsx @@ -0,0 +1,8 @@ +export default function SettingsIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/renderer/components/Icons/SmallLoadingSpinner.tsx b/renderer/components/Icons/SmallLoadingSpinner.tsx new file mode 100644 index 0000000..1661a07 --- /dev/null +++ b/renderer/components/Icons/SmallLoadingSpinner.tsx @@ -0,0 +1,8 @@ +export default function SmallLoadingSpinner() { + return ( + + + + + ); +} \ No newline at end of file diff --git a/renderer/components/Layout/PageContainer.tsx b/renderer/components/Layout/PageContainer.tsx new file mode 100644 index 0000000..eb1b157 --- /dev/null +++ b/renderer/components/Layout/PageContainer.tsx @@ -0,0 +1,7 @@ +export const PageContainer = ({ children }: { children: React.ReactNode }) => { + return ( +
+ {children} +
+ ); +}; \ No newline at end of file diff --git a/renderer/components/Layout/PageHeader.tsx b/renderer/components/Layout/PageHeader.tsx new file mode 100644 index 0000000..f73ea29 --- /dev/null +++ b/renderer/components/Layout/PageHeader.tsx @@ -0,0 +1,10 @@ +export const PageHeader = ({ title, children }: { title: string; children?: React.ReactNode }) => { + return ( +
+

+ {title} +

+ {children && children} +
+ ); +}; \ No newline at end of file diff --git a/renderer/components/PasswordAuth.tsx b/renderer/components/PasswordAuth.tsx index 12bcd95..a096ffe 100644 --- a/renderer/components/PasswordAuth.tsx +++ b/renderer/components/PasswordAuth.tsx @@ -1,5 +1,7 @@ import Head from 'next/head'; import React, { useState } from 'react'; +import { ErrorMessage } from './ErrorMessage'; +import PrimaryButton from './Form/PrimaryButton'; interface PasswordAuthProps { isFirstTime: boolean @@ -52,13 +54,13 @@ export default function PasswordAuth({ isFirstTime, onAuthenticated }: PasswordA return ( <> - {isFirstTime ? 'Set Up Password' : 'Enter Password'} - Thunder + {isFirstTime ? 'Set Up Password' : 'Enter password'} - Thunder

- {isFirstTime ? 'Set Up Password' : 'Enter Password'} + {isFirstTime ? 'Set Up Password' : 'Enter password'}

{isFirstTime @@ -106,31 +108,14 @@ export default function PasswordAuth({ isFirstTime, onAuthenticated }: PasswordA )}

- {error && ( -
- {error} -
- )} + {error && ()} -
- -
+
diff --git a/renderer/components/Sidebar.tsx b/renderer/components/Sidebar.tsx index e669979..0284b29 100644 --- a/renderer/components/Sidebar.tsx +++ b/renderer/components/Sidebar.tsx @@ -8,6 +8,9 @@ import ArrowLRIcon from './Icons/ArrowsLR'; import Popup from './Popup/Popup'; import ExternalIcon from './Icons/External'; import DocumentCheckIcon from './Icons/DocumentCheck'; +import SettingsIcon from './Icons/Settings'; +import { ErrorMessage } from './ErrorMessage'; +import PrimaryButton from './Form/PrimaryButton'; export default function Sidebar() { const { currentAccount } = useAccount(); @@ -16,10 +19,6 @@ export default function Sidebar() { const [popupError, setPopupError] = useState(''); const [isLoggingIn, setIsLoggingIn] = useState(false); - const handleOpenSteam = (url: string) => { - window.electron.openSteamWindow(url); - }; - useEffect(() => { // Perhaps our session expired, listen for event window.electron.events.onLoginRequired(() => { @@ -86,7 +85,7 @@ export default function Sidebar() {
  • handleOpenSteam('https://steamcommunity.com')} + onClick={() => window.electron.openSteamWindow.community()} className="flex items-center px-3 py-2 rounded-lg hover:bg-gray-800 transition-colors duration-200" > @@ -96,7 +95,7 @@ export default function Sidebar() {
  • handleOpenSteam('https://steamcommunity.com/my/tradeoffers')} + onClick={() => window.electron.openSteamWindow.tradeOffers()} className="flex items-center px-3 py-2 rounded-lg hover:bg-gray-800 transition-colors duration-200" > @@ -107,12 +106,12 @@ export default function Sidebar() { {process.env.NODE_ENV === 'development' && ( -
    +
    - Debug Info + Debug info
    )} @@ -147,10 +146,17 @@ export default function Sidebar() { )} {/* Bottom Icons */} -
    -
    +
    +
    + + + - + +
    + +
    diff --git a/renderer/components/WarningMessage.tsx b/renderer/components/WarningMessage.tsx new file mode 100644 index 0000000..162b337 --- /dev/null +++ b/renderer/components/WarningMessage.tsx @@ -0,0 +1,18 @@ + +export const WarningMessage = ({ title, message }: { title?: string; message: string }) => { + return ( +
    +
    + + + +
    + {title &&

    {title}

    } +

    + {message} +

    +
    +
    +
    + ); +}; \ No newline at end of file diff --git a/renderer/next.config.js b/renderer/next.config.js index 70e88c8..eefa555 100644 --- a/renderer/next.config.js +++ b/renderer/next.config.js @@ -1,3 +1,5 @@ +const { version } = require("../package.json"); + /** @type {import('next').NextConfig} */ module.exports = { output: "export", @@ -6,6 +8,9 @@ module.exports = { images: { unoptimized: true, }, + env: { + VERSION: version, + }, webpack: (config) => { return config; }, diff --git a/renderer/pages/confirmations.tsx b/renderer/pages/confirmations.tsx index 0792de4..e39f38d 100644 --- a/renderer/pages/confirmations.tsx +++ b/renderer/pages/confirmations.tsx @@ -3,6 +3,9 @@ import Head from 'next/head'; import Image from 'next/image'; import { Confirmation } from '../../main/types'; import Popup from '../components/Popup/Popup'; +import { PageContainer } from '../components/Layout/PageContainer'; +import { PageHeader } from '../components/Layout/PageHeader'; +import { ErrorMessage } from '../components/ErrorMessage'; interface LoadingConfirmations extends Confirmation { isLoading?: boolean; @@ -77,11 +80,8 @@ export default function ConfirmationsPage() { Confirmations - Thunder -
    -
    -

    - Confirmations -

    + + {!isLoading && confirmations.length > 0 && ( )} -
    + - {/* TODO: Make error alert reusable */} - {error && ( -
    - {error} -
    - )} + {error && ()} {isLoading && (
    @@ -192,14 +187,14 @@ export default function ConfirmationsPage() {
    )} -
    + ); } diff --git a/renderer/pages/debug.tsx b/renderer/pages/debug.tsx index 7ab0dca..ff78620 100644 --- a/renderer/pages/debug.tsx +++ b/renderer/pages/debug.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react'; import Head from 'next/head'; import { DebugInfo } from '../../main/types'; +import { PageContainer } from '../components/Layout/PageContainer'; export default function DebugPage() { const [debugInfo, setDebugInfo] = useState(null); @@ -18,11 +19,11 @@ export default function DebugPage() { Debug! - Thunder -
    +
               {JSON.stringify(debugInfo, null, 2)}
             
    -
    + ); } diff --git a/renderer/pages/index.tsx b/renderer/pages/index.tsx index f60df06..0de8a9f 100644 --- a/renderer/pages/index.tsx +++ b/renderer/pages/index.tsx @@ -3,6 +3,9 @@ import Head from 'next/head'; import { useAccount } from '../context/AccountContext'; import ClockIcon from '../components/Icons/Clock'; import CopyIcon from '../components/Icons/Copy'; +import { PageContainer } from '../components/Layout/PageContainer'; +import { PageHeader } from '../components/Layout/PageHeader'; +import PrimaryButton from '../components/Form/PrimaryButton'; export default function HomePage() { const { currentAccount, isLoading, seconds, authCode } = useAccount(); @@ -28,10 +31,8 @@ export default function HomePage() { Home - Thunder -
    -

    - Welcome to Thunder Authenticator - {currentAccount.personaName} -

    + +
    @@ -62,13 +63,11 @@ export default function HomePage() {
    - + icon={} + text="Copy code" + /> ) : (
    @@ -79,7 +78,7 @@ export default function HomePage() {
    -
    + ); } diff --git a/renderer/pages/settings.tsx b/renderer/pages/settings.tsx new file mode 100644 index 0000000..e07d336 --- /dev/null +++ b/renderer/pages/settings.tsx @@ -0,0 +1,156 @@ +import React, { useState } from 'react'; +import Head from 'next/head'; +import { PageContainer } from '../components/Layout/PageContainer'; +import { PageHeader } from '../components/Layout/PageHeader'; +import Popup from '../components/Popup/Popup'; +import PrimaryButton from '../components/Form/PrimaryButton'; +import CopyIcon from '../components/Icons/Copy'; +import { ErrorMessage } from '../components/ErrorMessage'; +import { WarningMessage } from '../components/WarningMessage'; + +export default function SettingsPage() { + const [showExportModal, setShowExportModal] = useState(false); + const [showPasswordModal, setShowPasswordModal] = useState(false); + const [exportedData, setExportedData] = useState(null); + const [password, setPassword] = useState(''); + const [passwordError, setPasswordError] = useState(''); + + const SettingSection = ({ title, children }: { title: string; children: React.ReactNode }) => ( +
    +

    {title}

    + {children} +
    + ); + + const handleExportSecrets = async () => { + setPasswordError(''); + setShowPasswordModal(true); + }; + + const handlePasswordSubmit = async () => { + if (!password.trim()) { + setPasswordError('Password is required'); + return; + } + + setPasswordError(''); + + try { + const secrets = await window.electron.exportAccountSecrets(password); + if ('error' in secrets) { + throw new Error(secrets.error); + } + + setExportedData(JSON.stringify(secrets, null, 2)); + setShowPasswordModal(false); + setShowExportModal(true); + setPassword(''); + } catch (error) { + console.error('Failed to export secrets:', error); + setPasswordError(error instanceof Error ? error.message : 'Invalid password or export failed'); + } + }; + + const copyToClipboard = () => { + if (exportedData) { + navigator.clipboard.writeText(exportedData); + alert('Secrets copied to clipboard!'); + } + }; + + return ( + <> + + Settings - Thunder + + + + +
    + + + +

    + Export your Steam authentication secrets. This includes your identity secret and shared secret. +

    + +
    + +
    +
    + + +
    +
    Version: {process.env.VERSION}
    + +
    +
    +
    +
    + + {showExportModal && ( + setShowExportModal(false)}> + + +
    + +