diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 4e3dcb37..1baf4614 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 2a80084f..9e1021fc 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/main/events/auth/open-auth-window.ts b/src/main/events/auth/open-auth-window.ts index e93a5a42..0f5ec371 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/hosters/datanodes.ts b/src/main/services/hosters/datanodes.ts index d77e7d51..ae144418 100644 --- a/src/main/services/hosters/datanodes.ts +++ b/src/main/services/hosters/datanodes.ts @@ -33,7 +33,8 @@ export class DatanodesApi { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", }, - maxRedirects: 0, validateStatus: (status: number) => status === 302 || status < 400, + maxRedirects: 0, + validateStatus: (status: number) => status === 302 || status < 400, } ); diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index 63dd9b16..16bbc21f 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -215,38 +215,42 @@ export class HydraApi { } } + public static async refreshToken() { + const { accessToken, expiresIn } = await this.instance + .post<{ accessToken: string; expiresIn: number }>(`/auth/refresh`, { + refreshToken: this.userAuth.refreshToken, + }) + .then((response) => response.data); + + const tokenExpirationTimestamp = + Date.now() + + this.secondsToMilliseconds(expiresIn) - + this.EXPIRATION_OFFSET_IN_MS; + + this.userAuth.authToken = accessToken; + this.userAuth.expirationTimestamp = tokenExpirationTimestamp; + + logger.log( + "Token refreshed. New expiration:", + this.userAuth.expirationTimestamp + ); + + userAuthRepository.upsert( + { + id: 1, + accessToken, + tokenExpirationTimestamp, + }, + ["id"] + ); + + return { accessToken, expiresIn }; + } + private static async revalidateAccessTokenIfExpired() { - const now = new Date(); - - if (this.userAuth.expirationTimestamp < now.getTime()) { + if (this.userAuth.expirationTimestamp < Date.now()) { try { - const response = await this.instance.post(`/auth/refresh`, { - refreshToken: this.userAuth.refreshToken, - }); - - const { accessToken, expiresIn } = response.data; - - const tokenExpirationTimestamp = - now.getTime() + - this.secondsToMilliseconds(expiresIn) - - this.EXPIRATION_OFFSET_IN_MS; - - this.userAuth.authToken = accessToken; - this.userAuth.expirationTimestamp = tokenExpirationTimestamp; - - logger.log( - "Token refreshed. New expiration:", - this.userAuth.expirationTimestamp - ); - - userAuthRepository.upsert( - { - id: 1, - accessToken, - tokenExpirationTimestamp, - }, - ["id"] - ); + await this.refreshToken(); } catch (err) { this.handleUnauthorizedError(err); } @@ -261,7 +265,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 a7cfcee2..f7e82f07 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"; @@ -17,6 +17,7 @@ import { gameRepository, userPreferencesRepository } from "@main/repository"; import { IsNull, Not } from "typeorm"; import { HydraApi } from "./hydra-api"; import UserAgent from "user-agents"; +import { AuthPage } from "@shared"; export class WindowManager { public static mainWindow: Electron.BrowserWindow | null = null; @@ -142,7 +143,7 @@ export class WindowManager { }); } - public static openAuthWindow() { + public static openAuthWindow(page: AuthPage, searchParams: URLSearchParams) { if (this.mainWindow) { const authWindow = new BrowserWindow({ width: 600, @@ -164,12 +165,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", () => { @@ -181,6 +178,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 316397d2..5f9bc02c 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", { @@ -291,13 +291,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 49e56ab7..3897ac54 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 88f3297f..31c99df5 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, Game, @@ -208,9 +208,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 12495231..bd138e81 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/settings/settings-privacy.css.ts b/src/renderer/src/pages/settings/settings-account.css.ts similarity index 80% rename from src/renderer/src/pages/settings/settings-privacy.css.ts rename to src/renderer/src/pages/settings/settings-account.css.ts index 2aec8cd0..8fbb3845 100644 --- a/src/renderer/src/pages/settings/settings-privacy.css.ts +++ b/src/renderer/src/pages/settings/settings-account.css.ts @@ -5,14 +5,7 @@ import { SPACING_UNIT, vars } from "../../theme.css"; export const form = style({ display: "flex", flexDirection: "column", - gap: `${SPACING_UNIT}px`, -}); - -export const blockedUserAvatar = style({ - width: "32px", - height: "32px", - borderRadius: "4px", - filter: "grayscale(100%)", + gap: `${SPACING_UNIT * 3}px`, }); export const blockedUser = style({ @@ -43,5 +36,4 @@ export const blockedUsersList = style({ flexDirection: "column", alignItems: "flex-start", gap: `${SPACING_UNIT}px`, - marginTop: `${SPACING_UNIT}px`, }); diff --git a/src/renderer/src/pages/settings/settings-account.tsx b/src/renderer/src/pages/settings/settings-account.tsx new file mode 100644 index 00000000..d43e5b2d --- /dev/null +++ b/src/renderer/src/pages/settings/settings-account.tsx @@ -0,0 +1,291 @@ +import { Avatar, Button, 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-account.css"; +import { useDate, useToast, useUserDetails } from "@renderer/hooks"; +import { useCallback, useContext, useEffect, useState } from "react"; +import { + CloudIcon, + KeyIcon, + MailIcon, + XCircleFillIcon, +} from "@primer/octicons-react"; +import { settingsContext } from "@renderer/context"; +import { AuthPage } from "@shared"; + +interface FormValues { + profileVisibility: "PUBLIC" | "FRIENDS" | "PRIVATE"; +} + +export function SettingsAccount() { + const { t } = useTranslation("settings"); + + const [isUnblocking, setIsUnblocking] = useState(false); + + const { showSuccessToast } = useToast(); + + const { blockedUsers, fetchBlockedUsers } = useContext(settingsContext); + + const { formatDate } = useDate(); + + const { + control, + formState: { isSubmitting }, + setValue, + handleSubmit, + } = useForm(); + + 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 b93d1d07..00000000 --- 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 dffdfbae..5fba6c5d 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 6b332d40..f2bcc793 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", +}