diff --git a/docs/README.pt-BR.md b/docs/README.pt-BR.md index f9ba9d66f..ca9428544 100644 --- a/docs/README.pt-BR.md +++ b/docs/README.pt-BR.md @@ -125,6 +125,10 @@ cd hydra yarn ``` +### Instale OpenSSL 1.1 + +[OpenSSL 1.1](https://slproweb.com/download/Win64OpenSSL-1_1_1w.exe) é exigido pelo libtorrent em ambientes Windows. + ### Instale Python 3.9 Certifique-se de ter o Python 3.9 instalado em sua máquina. Você pode baixá-lo e instalá-lo em [python.org](https://www.python.org/downloads/release/python-3913/). diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 4e3dcb37c..1baf46141 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -280,7 +280,23 @@ "launch_minimized": "Launch Hydra minimized", "disable_nsfw_alert": "Disable NSFW alert", "seed_after_download_complete": "Seed after download complete", - "show_hidden_achievement_description": "Show hidden achievements description before unlocking them" + "show_hidden_achievement_description": "Show hidden achievements description before unlocking them", + "account": "Account", + "no_users_blocked": "You have no blocked users", + "subscription_active_until": "Your Hydra Cloud is active until {{date}}", + "manage_subscription": "Manage subscription", + "update_email": "Update email", + "update_password": "Update password", + "current_email": "Current email:", + "no_email_account": "You have not set an email yet", + "account_data_updated_successfully": "Account data updated successfully", + "renew_subscription": "Renew Hydra Cloud", + "subscription_expired_at": "Your subscription expired at {{date}}", + "no_subscription": "Enjoy Hydra in the best possible way", + "become_subscriber": "Be Hydra Cloud", + "subscription_renew_cancelled": "Automatic renewal is disabled", + "subscription_renews_on": "Your subscription renews on {{date}}", + "bill_sent_until": "Your next bill will be sent until this day" }, "notifications": { "download_complete": "Download complete", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 2a80084f3..9e1021fca 100644 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -268,7 +268,23 @@ "launch_minimized": "Iniciar o Hydra minimizado", "disable_nsfw_alert": "Desativar alerta de conteúdo inapropriado", "seed_after_download_complete": "Semear após a conclusão do download", - "show_hidden_achievement_description": "Mostrar descrição de conquistas ocultas antes de debloqueá-las" + "show_hidden_achievement_description": "Mostrar descrição de conquistas ocultas antes de debloqueá-las", + "account": "Conta", + "no_users_blocked": "Você não bloqueou nenhum usuário", + "subscription_active_until": "Sua assinatura Hydra Cloud ficará ativa até {{date}}", + "manage_subscription": "Gerenciar assinatura", + "update_email": "Atualizar email", + "update_password": "Atualizar senha", + "current_email": "Email atual:", + "no_email_account": "Você ainda não adicionou um email a sua conta", + "account_data_updated_successfully": "Dados da conta atualizados com sucesso", + "renew_subscription": "Renovar Hydra Cloud", + "subscription_expired_at": "Sua assinatura expirou em {{date}}", + "no_subscription": "Aproveite o Hydra da melhor forma possível", + "become_subscriber": "Seja Hydra Cloud", + "subscription_renew_cancelled": "A renovação automática está desativada", + "subscription_renews_on": "Sua assinatura renova dia {{date}}", + "bill_sent_until": "Sua próxima cobrança será enviada até esse dia" }, "notifications": { "download_complete": "Download concluído", @@ -397,7 +413,7 @@ "new_achievements_unlocked": "{{achievementCount}} novas conquistas de {{gameCount}} jogos", "achievement_progress": "{{unlockedCount}}/{{totalCount}} conquistas", "achievements_unlocked_for_game": "Desbloqueadas {{achievementCount}} novas conquistas em {{gameTitle}}", - "hidden_achievement_tooltip": "Está é uma conquista oculta", + "hidden_achievement_tooltip": "Esta é uma conquista oculta", "achievement_earn_points": "Ganhe {{points}} pontos com essa conquista", "earned_points": "Pontos ganhos:", "available_points": "Pontos disponíveis:", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 92008a5e0..f96ef4957 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -278,7 +278,23 @@ "source_already_exists": "Этот источник уже добавлен", "user_unblocked": "Пользователь разблокирован", "seed_after_download_complete": "Раздавать после завершения загрузки", - "show_hidden_achievement_description": "Показывать описание скрытых достижений перед их получением" + "show_hidden_achievement_description": "Показывать описание скрытых достижений перед их получением", + "account": "Аккаунт", + "no_users_blocked": "У вас нет заблокированных пользователей", + "subscription_active_until": "Ваша подписка на Hydra Cloud активна до {{date}}", + "manage_subscription": "Управлять подпиской", + "update_email": "Обновить электронную почту", + "update_password": "Обновить пароль", + "current_email": "Текущий email:", + "no_email_account": "Вы еще не установили электронную почту", + "account_data_updated_successfully": "Данные учетной записи успешно обновлены", + "renew_subscription": "Обновить подписку Hydra Cloud", + "subscription_expired_at": "Срок действия вашей подписки истек в {{date}}", + "no_subscription": "Наслаждайтесь Hydra по максимуму", + "become_subscriber": "Станьте обладателем Hydra Cloud", + "subscription_renew_cancelled": "Автоматическое продление отключено", + "subscription_renews_on": "Ваша подписка продлевается на {{date}}", + "bill_sent_until": "Ваш следующий счет будет отправлен до этого дня" }, "notifications": { "download_complete": "Загрузка завершена", diff --git a/src/main/events/auth/open-auth-window.ts b/src/main/events/auth/open-auth-window.ts index e93a5a42e..0f5ec3718 100644 --- a/src/main/events/auth/open-auth-window.ts +++ b/src/main/events/auth/open-auth-window.ts @@ -1,7 +1,24 @@ +import i18next from "i18next"; import { registerEvent } from "../register-event"; -import { WindowManager } from "@main/services"; +import { HydraApi, WindowManager } from "@main/services"; +import { AuthPage } from "@shared"; -const openAuthWindow = async (_event: Electron.IpcMainInvokeEvent) => - WindowManager.openAuthWindow(); +const openAuthWindow = async ( + _event: Electron.IpcMainInvokeEvent, + page: AuthPage +) => { + const searchParams = new URLSearchParams({ + lng: i18next.language, + }); + + if ([AuthPage.UpdateEmail, AuthPage.UpdatePassword].includes(page)) { + const { accessToken } = await HydraApi.refreshToken().catch(() => { + return { accessToken: "" }; + }); + searchParams.set("token", accessToken); + } + + WindowManager.openAuthWindow(page, searchParams); +}; registerEvent("openAuthWindow", openAuthWindow); diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index 5f7a5034b..4d5623a02 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -221,43 +221,47 @@ export class HydraApi { } } - private static async revalidateAccessTokenIfExpired() { - const now = new Date(); + public static async refreshToken() { + const response = await this.instance.post(`/auth/refresh`, { + refreshToken: this.userAuth.refreshToken, + }); - if (this.userAuth.expirationTimestamp < now.getTime()) { - try { - const response = await this.instance.post(`/auth/refresh`, { - refreshToken: this.userAuth.refreshToken, - }); + const { accessToken, expiresIn } = response.data; - const { accessToken, expiresIn } = response.data; + const tokenExpirationTimestamp = + Date.now() + + this.secondsToMilliseconds(expiresIn) - + this.EXPIRATION_OFFSET_IN_MS; - const tokenExpirationTimestamp = - now.getTime() + - this.secondsToMilliseconds(expiresIn) - - this.EXPIRATION_OFFSET_IN_MS; + this.userAuth.authToken = accessToken; + this.userAuth.expirationTimestamp = tokenExpirationTimestamp; - this.userAuth.authToken = accessToken; - this.userAuth.expirationTimestamp = tokenExpirationTimestamp; + logger.log( + "Token refreshed. New expiration:", + this.userAuth.expirationTimestamp + ); - logger.log( - "Token refreshed. New expiration:", - this.userAuth.expirationTimestamp + await db + .get(levelKeys.auth, { valueEncoding: "json" }) + .then((auth) => { + return db.put( + levelKeys.auth, + { + ...auth, + accessToken: Crypto.encrypt(accessToken), + tokenExpirationTimestamp, + }, + { valueEncoding: "json" } ); + }); - await db - .get(levelKeys.auth, { valueEncoding: "json" }) - .then((auth) => { - return db.put( - levelKeys.auth, - { - ...auth, - accessToken: Crypto.encrypt(accessToken), - tokenExpirationTimestamp, - }, - { valueEncoding: "json" } - ); - }); + return { accessToken, expiresIn }; + } + + private static async revalidateAccessTokenIfExpired() { + if (this.userAuth.expirationTimestamp < Date.now()) { + try { + await this.refreshToken(); } catch (err) { this.handleUnauthorizedError(err); } @@ -272,7 +276,7 @@ export class HydraApi { }; } - private static handleUnauthorizedError = (err) => { + private static readonly handleUnauthorizedError = (err) => { if (err instanceof AxiosError && err.response?.status === 401) { logger.error( "401 - Current credentials:", diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index df8b08a31..cf9089b71 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -9,7 +9,7 @@ import { shell, } from "electron"; import { is } from "@electron-toolkit/utils"; -import i18next, { t } from "i18next"; +import { t } from "i18next"; import path from "node:path"; import icon from "@resources/icon.png?asset"; import trayIcon from "@resources/tray-icon.png?asset"; @@ -18,6 +18,7 @@ import UserAgent from "user-agents"; import { db, gamesSublevel, levelKeys } from "@main/level"; import { slice, sortBy } from "lodash-es"; import type { UserPreferences } from "@types"; +import { AuthPage } from "@shared"; export class WindowManager { public static mainWindow: Electron.BrowserWindow | null = null; @@ -146,7 +147,7 @@ export class WindowManager { }); } - public static openAuthWindow() { + public static openAuthWindow(page: AuthPage, searchParams: URLSearchParams) { if (this.mainWindow) { const authWindow = new BrowserWindow({ width: 600, @@ -168,12 +169,8 @@ export class WindowManager { if (!app.isPackaged) authWindow.webContents.openDevTools(); - const searchParams = new URLSearchParams({ - lng: i18next.language, - }); - authWindow.loadURL( - `${import.meta.env.MAIN_VITE_AUTH_URL}/?${searchParams.toString()}` + `${import.meta.env.MAIN_VITE_AUTH_URL}${page}?${searchParams.toString()}` ); authWindow.once("ready-to-show", () => { @@ -185,6 +182,13 @@ export class WindowManager { authWindow.close(); HydraApi.handleExternalAuth(url); + return; + } + + if (url.startsWith("hydralauncher://update-account")) { + authWindow.close(); + + WindowManager.mainWindow?.webContents.send("on-account-updated"); } }); } diff --git a/src/preload/index.ts b/src/preload/index.ts index 3c1e9e83b..588becdc6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -15,7 +15,7 @@ import type { SeedingStatus, GameAchievement, } from "@types"; -import type { CatalogueCategory } from "@shared"; +import type { AuthPage, CatalogueCategory } from "@shared"; import type { AxiosProgressEvent } from "axios"; contextBridge.exposeInMainWorld("electron", { @@ -312,13 +312,19 @@ contextBridge.exposeInMainWorld("electron", { /* Auth */ signOut: () => ipcRenderer.invoke("signOut"), - openAuthWindow: () => ipcRenderer.invoke("openAuthWindow"), + openAuthWindow: (page: AuthPage) => + ipcRenderer.invoke("openAuthWindow", page), getSessionHash: () => ipcRenderer.invoke("getSessionHash"), onSignIn: (cb: () => void) => { const listener = (_event: Electron.IpcRendererEvent) => cb(); ipcRenderer.on("on-signin", listener); return () => ipcRenderer.removeListener("on-signin", listener); }, + onAccountUpdated: (cb: () => void) => { + const listener = (_event: Electron.IpcRendererEvent) => cb(); + ipcRenderer.on("on-account-updated", listener); + return () => ipcRenderer.removeListener("on-account-updated", listener); + }, onSignOut: (cb: () => void) => { const listener = (_event: Electron.IpcRendererEvent) => cb(); ipcRenderer.on("on-signout", listener); diff --git a/src/renderer/src/components/sidebar/sidebar-profile.tsx b/src/renderer/src/components/sidebar/sidebar-profile.tsx index 49e56ab78..3897ac545 100644 --- a/src/renderer/src/components/sidebar/sidebar-profile.tsx +++ b/src/renderer/src/components/sidebar/sidebar-profile.tsx @@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next"; import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal"; import SteamLogo from "@renderer/assets/steam-logo.svg?react"; import { Avatar } from "../avatar/avatar"; +import { AuthPage } from "@shared"; const LONG_POLLING_INTERVAL = 120_000; @@ -26,11 +27,11 @@ export function SidebarProfile() { const handleProfileClick = () => { if (userDetails === null) { - window.electron.openAuthWindow(); + window.electron.openAuthWindow(AuthPage.SignIn); return; } - navigate(`/profile/${userDetails!.id}`); + navigate(`/profile/${userDetails.id}`); }; useEffect(() => { diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index c1672fd27..046398d44 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -1,4 +1,4 @@ -import type { CatalogueCategory } from "@shared"; +import type { AuthPage, CatalogueCategory } from "@shared"; import type { AppUpdaterEvent, GameShop, @@ -216,9 +216,10 @@ declare global { /* Auth */ signOut: () => Promise; - openAuthWindow: () => Promise; + openAuthWindow: (page: AuthPage) => Promise; getSessionHash: () => Promise; onSignIn: (cb: () => void) => () => Electron.IpcRenderer; + onAccountUpdated: (cb: () => void) => () => Electron.IpcRenderer; onSignOut: (cb: () => void) => () => Electron.IpcRenderer; /* User */ diff --git a/src/renderer/src/pages/game-details/game-details-content.tsx b/src/renderer/src/pages/game-details/game-details-content.tsx index 12495231e..bd138e81c 100644 --- a/src/renderer/src/pages/game-details/game-details-content.tsx +++ b/src/renderer/src/pages/game-details/game-details-content.tsx @@ -10,7 +10,7 @@ import { Sidebar } from "./sidebar/sidebar"; import * as styles from "./game-details.css"; import { useTranslation } from "react-i18next"; import { cloudSyncContext, gameDetailsContext } from "@renderer/context"; -import { steamUrlBuilder } from "@shared"; +import { AuthPage, steamUrlBuilder } from "@shared"; import cloudIconAnimated from "@renderer/assets/icons/cloud-animated.gif"; import { useUserDetails } from "@renderer/hooks"; @@ -69,7 +69,7 @@ export function GameDetailsContent() { }); const backgroundColor = output - ? (new Color(output).darken(0.7).toString() as string) + ? new Color(output).darken(0.7).toString() : ""; setGameColor(backgroundColor); @@ -101,7 +101,7 @@ export function GameDetailsContent() { const handleCloudSaveButtonClick = () => { if (!userDetails) { - window.electron.openAuthWindow(); + window.electron.openAuthWindow(AuthPage.SignIn); return; } diff --git a/src/renderer/src/pages/game-details/sidebar-section/sidebar-section.tsx b/src/renderer/src/pages/game-details/sidebar-section/sidebar-section.tsx index da9d078f0..e24f677be 100644 --- a/src/renderer/src/pages/game-details/sidebar-section/sidebar-section.tsx +++ b/src/renderer/src/pages/game-details/sidebar-section/sidebar-section.tsx @@ -1,5 +1,5 @@ import { ChevronDownIcon } from "@primer/octicons-react"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import * as styles from "./sidebar-section.css"; @@ -11,6 +11,15 @@ export interface SidebarSectionProps { export function SidebarSection({ title, children }: SidebarSectionProps) { const content = useRef(null); const [isOpen, setIsOpen] = useState(true); + const [height, setHeight] = useState(0); + + useEffect(() => { + if (content.current && content.current.scrollHeight !== height) { + setHeight(isOpen ? content.current.scrollHeight : 0); + } else if (!isOpen) { + setHeight(0); + } + }, [isOpen, children, height]); return (
@@ -26,7 +35,7 @@ export function SidebarSection({ title, children }: SidebarSectionProps) {
(); + + const { + userDetails, + hasActiveSubscription, + patchUser, + fetchUserDetails, + updateUserDetails, + unblockUser, + } = useUserDetails(); + + useEffect(() => { + if (userDetails?.profileVisibility) { + setValue("profileVisibility", userDetails.profileVisibility); + } + }, [userDetails, setValue]); + + useEffect(() => { + const unsubscribe = window.electron.onAccountUpdated(() => { + fetchUserDetails().then((response) => { + if (response) { + updateUserDetails(response); + } + }); + showSuccessToast(t("account_data_updated_successfully")); + }); + + return () => { + unsubscribe(); + }; + }, [fetchUserDetails, updateUserDetails]); + + const visibilityOptions = [ + { value: "PUBLIC", label: t("public") }, + { value: "FRIENDS", label: t("friends_only") }, + { value: "PRIVATE", label: t("private") }, + ]; + + const onSubmit = async (values: FormValues) => { + await patchUser(values); + showSuccessToast(t("changes_saved")); + }; + + const handleUnblockClick = useCallback( + (id: string) => { + setIsUnblocking(true); + + unblockUser(id) + .then(() => { + fetchBlockedUsers(); + showSuccessToast(t("user_unblocked")); + }) + .finally(() => { + setIsUnblocking(false); + }); + }, + [unblockUser, fetchBlockedUsers, t, showSuccessToast] + ); + + const getHydraCloudSectionContent = () => { + const hasSubscribedBefore = Boolean(userDetails?.subscription?.expiresAt); + const isRenewalActive = userDetails?.subscription?.status === "active"; + + if (!hasSubscribedBefore) { + return { + description: {t("no_subscription")}, + callToAction: t("become_subscriber"), + }; + } + + if (hasActiveSubscription) { + return { + description: isRenewalActive ? ( + <> + + {t("subscription_renews_on", { + date: formatDate(userDetails.subscription!.expiresAt!), + })} + + {t("bill_sent_until")} + + ) : ( + <> + {t("subscription_renew_cancelled")} + + {t("subscription_active_until", { + date: formatDate(userDetails!.subscription!.expiresAt!), + })} + + + ), + callToAction: t("manage_subscription"), + }; + } + + return { + description: ( + + {t("subscription_expired_at", { + date: formatDate(userDetails!.subscription!.expiresAt!), + })} + + ), + callToAction: t("renew_subscription"), + }; + }; + + if (!userDetails) return null; + + return ( +
+ { + const handleChange = ( + event: React.ChangeEvent + ) => { + field.onChange(event); + handleSubmit(onSubmit)(); + }; + + return ( +
+ ({ + key: visiblity.value, + value: visiblity.value, + label: visiblity.label, + }))} + disabled={isSubmitting} + /> + + {t("profile_visibility_description")} +
+ ); + }} + /> + +
+

{t("current_email")}

+

{userDetails?.email ?? t("no_email_account")}

+ +
+ + + +
+
+ +
+

Hydra Cloud

+
+ {getHydraCloudSectionContent().description} +
+ + +
+ +
+

{t("blocked_users")}

+ + {blockedUsers.length > 0 ? ( +
    + {blockedUsers.map((user) => { + return ( +
  • +
    + + {user.displayName} +
    + + +
  • + ); + })} +
+ ) : ( + {t("no_users_blocked")} + )} +
+ + ); +} diff --git a/src/renderer/src/pages/settings/settings-privacy.tsx b/src/renderer/src/pages/settings/settings-privacy.tsx deleted file mode 100644 index b93d1d07d..000000000 --- a/src/renderer/src/pages/settings/settings-privacy.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { SelectField } from "@renderer/components"; -import { SPACING_UNIT } from "@renderer/theme.css"; -import { Controller, useForm } from "react-hook-form"; -import { useTranslation } from "react-i18next"; - -import * as styles from "./settings-privacy.css"; -import { useToast, useUserDetails } from "@renderer/hooks"; -import { useCallback, useContext, useEffect, useState } from "react"; -import { XCircleFillIcon } from "@primer/octicons-react"; -import { settingsContext } from "@renderer/context"; - -interface FormValues { - profileVisibility: "PUBLIC" | "FRIENDS" | "PRIVATE"; -} - -export function SettingsPrivacy() { - const { t } = useTranslation("settings"); - - const [isUnblocking, setIsUnblocking] = useState(false); - - const { showSuccessToast } = useToast(); - - const { blockedUsers, fetchBlockedUsers } = useContext(settingsContext); - - const { - control, - formState: { isSubmitting }, - setValue, - handleSubmit, - } = useForm(); - - const { patchUser, userDetails } = useUserDetails(); - - const { unblockUser } = useUserDetails(); - - useEffect(() => { - if (userDetails?.profileVisibility) { - setValue("profileVisibility", userDetails.profileVisibility); - } - }, [userDetails, setValue]); - - const visibilityOptions = [ - { value: "PUBLIC", label: t("public") }, - { value: "FRIENDS", label: t("friends_only") }, - { value: "PRIVATE", label: t("private") }, - ]; - - const onSubmit = async (values: FormValues) => { - await patchUser(values); - showSuccessToast(t("changes_saved")); - }; - - const handleUnblockClick = useCallback( - (id: string) => { - setIsUnblocking(true); - - unblockUser(id) - .then(() => { - fetchBlockedUsers(); - showSuccessToast(t("user_unblocked")); - }) - .finally(() => { - setIsUnblocking(false); - }); - }, - [unblockUser, fetchBlockedUsers, t, showSuccessToast] - ); - - return ( -
- { - const handleChange = ( - event: React.ChangeEvent - ) => { - field.onChange(event); - handleSubmit(onSubmit)(); - }; - - return ( - <> - ({ - key: visiblity.value, - value: visiblity.value, - label: visiblity.label, - }))} - disabled={isSubmitting} - /> - - {t("profile_visibility_description")} - - ); - }} - /> - -

- {t("blocked_users")} -

- -
    - {blockedUsers.map((user) => { - return ( -
  • -
    - {user.displayName} - {user.displayName} -
    - - -
  • - ); - })} -
- - ); -} diff --git a/src/renderer/src/pages/settings/settings.tsx b/src/renderer/src/pages/settings/settings.tsx index dffdfbaeb..5fba6c5df 100644 --- a/src/renderer/src/pages/settings/settings.tsx +++ b/src/renderer/src/pages/settings/settings.tsx @@ -11,7 +11,7 @@ import { SettingsContextConsumer, SettingsContextProvider, } from "@renderer/context"; -import { SettingsPrivacy } from "./settings-privacy"; +import { SettingsAccount } from "./settings-account"; import { useUserDetails } from "@renderer/hooks"; import { useMemo } from "react"; @@ -28,7 +28,7 @@ export default function Settings() { "Real-Debrid", ]; - if (userDetails) return [...categories, t("privacy")]; + if (userDetails) return [...categories, t("account")]; return categories; }, [userDetails, t]); @@ -53,7 +53,7 @@ export default function Settings() { return ; } - return ; + return ; }; return ( diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 6b332d40a..f2bcc7939 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -42,3 +42,9 @@ export enum Cracker { rle = "RLE", razor1911 = "RAZOR1911", } + +export enum AuthPage { + SignIn = "/", + UpdateEmail = "/update-email", + UpdatePassword = "/update-password", +}