mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-02-14 12:12:13 +00:00
feat: manage account buttons
This commit is contained in:
parent
d4be5b8c66
commit
af4fcb8f06
12 changed files with 286 additions and 149 deletions
|
@ -280,7 +280,18 @@
|
|||
"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": "Hydra Cloud subscription",
|
||||
"subscription_active_until": "Your Hydra Cloud is active until {{date}}",
|
||||
"subscription_not_active": "You don't have an active Hydra Cloud subscription",
|
||||
"manage_account": "Manage account",
|
||||
"manage_subscription": "Manage subscription",
|
||||
"update_email": "Update email",
|
||||
"update_password": "Update password",
|
||||
"current_email": "Current email:",
|
||||
"no_associated_email": "You don't have an associated email yet"
|
||||
},
|
||||
"notifications": {
|
||||
"download_complete": "Download complete",
|
||||
|
|
|
@ -268,7 +268,18 @@
|
|||
"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": "Assinatura Hydra Cloud",
|
||||
"subscription_active_until": "Seu Hydra Cloud ficará ativo até {{date}}",
|
||||
"subscription_not_active": "Você não possui uma assinatura Hydra Cloud ativa",
|
||||
"manage_account": "Gerenciar conta",
|
||||
"manage_subscription": "Gerenciar assinatura",
|
||||
"update_email": "Atualizar email",
|
||||
"update_password": "Atualizar senha",
|
||||
"current_email": "Email atual:",
|
||||
"no_associated_email": "Você ainda não associou nenhum email a sua conta"
|
||||
},
|
||||
"notifications": {
|
||||
"download_complete": "Download concluído",
|
||||
|
|
|
@ -30,6 +30,7 @@ import "./library/remove-game-from-library";
|
|||
import "./library/select-game-wine-prefix";
|
||||
import "./library/reset-game-achievements";
|
||||
import "./misc/open-checkout";
|
||||
import "./misc/open-manage-account";
|
||||
import "./misc/open-external";
|
||||
import "./misc/show-open-dialog";
|
||||
import "./misc/get-features";
|
||||
|
|
25
src/main/events/misc/open-manage-account.ts
Normal file
25
src/main/events/misc/open-manage-account.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { shell } from "electron";
|
||||
import { registerEvent } from "../register-event";
|
||||
import { HydraApi, logger } from "@main/services";
|
||||
import { ManageAccountPage } from "@types";
|
||||
|
||||
const openManageAccount = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
page: ManageAccountPage
|
||||
) => {
|
||||
try {
|
||||
const { accessToken } = await HydraApi.refreshToken();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
token: accessToken,
|
||||
});
|
||||
|
||||
shell.openExternal(
|
||||
`${import.meta.env.MAIN_VITE_AUTH_URL}/${page}?${params.toString()}`
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error("Failed to open manage account", err);
|
||||
}
|
||||
};
|
||||
|
||||
registerEvent("openManageAccount", openManageAccount);
|
|
@ -215,16 +215,20 @@ export class HydraApi {
|
|||
}
|
||||
}
|
||||
|
||||
public static async refreshToken() {
|
||||
return this.instance
|
||||
.post<{ accessToken: string; expiresIn: number }>(`/auth/refresh`, {
|
||||
refreshToken: this.userAuth.refreshToken,
|
||||
})
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
private static async revalidateAccessTokenIfExpired() {
|
||||
const now = new Date();
|
||||
|
||||
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 } = await this.refreshToken();
|
||||
|
||||
const tokenExpirationTimestamp =
|
||||
now.getTime() +
|
||||
|
|
|
@ -14,6 +14,7 @@ import type {
|
|||
CatalogueSearchPayload,
|
||||
SeedingStatus,
|
||||
GameAchievement,
|
||||
ManageAccountPage,
|
||||
} from "@types";
|
||||
import type { CatalogueCategory } from "@shared";
|
||||
import type { AxiosProgressEvent } from "axios";
|
||||
|
@ -226,6 +227,8 @@ contextBridge.exposeInMainWorld("electron", {
|
|||
isPortableVersion: () => ipcRenderer.invoke("isPortableVersion"),
|
||||
openExternal: (src: string) => ipcRenderer.invoke("openExternal", src),
|
||||
openCheckout: () => ipcRenderer.invoke("openCheckout"),
|
||||
openManageAccount: (page: ManageAccountPage) =>
|
||||
ipcRenderer.invoke("openManageAccount", page),
|
||||
showOpenDialog: (options: Electron.OpenDialogOptions) =>
|
||||
ipcRenderer.invoke("showOpenDialog", options),
|
||||
showItemInFolder: (path: string) =>
|
||||
|
|
2
src/renderer/src/declaration.d.ts
vendored
2
src/renderer/src/declaration.d.ts
vendored
|
@ -29,6 +29,7 @@ import type {
|
|||
UserAchievement,
|
||||
ComparedAchievements,
|
||||
CatalogueSearchPayload,
|
||||
ManageAccountPage,
|
||||
} from "@types";
|
||||
import type { AxiosProgressEvent } from "axios";
|
||||
import type disk from "diskusage";
|
||||
|
@ -187,6 +188,7 @@ declare global {
|
|||
/* Misc */
|
||||
openExternal: (src: string) => Promise<void>;
|
||||
openCheckout: () => Promise<void>;
|
||||
openManageAccount: (page: ManageAccountPage) => Promise<void>;
|
||||
getVersion: () => Promise<string>;
|
||||
isStaging: () => Promise<boolean>;
|
||||
ping: () => string;
|
||||
|
|
217
src/renderer/src/pages/settings/settings-account.tsx
Normal file
217
src/renderer/src/pages/settings/settings-account.tsx
Normal file
|
@ -0,0 +1,217 @@
|
|||
import { Button, SelectField } from "@renderer/components";
|
||||
import { SPACING_UNIT, vars } 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";
|
||||
|
||||
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<FormValues>();
|
||||
|
||||
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 (
|
||||
<form className={styles.form} onSubmit={handleSubmit(onSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="profileVisibility"
|
||||
render={({ field }) => {
|
||||
const handleChange = (
|
||||
event: React.ChangeEvent<HTMLSelectElement>
|
||||
) => {
|
||||
field.onChange(event);
|
||||
handleSubmit(onSubmit)();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SelectField
|
||||
label={t("profile_visibility")}
|
||||
value={field.value}
|
||||
onChange={handleChange}
|
||||
options={visibilityOptions.map((visiblity) => ({
|
||||
key: visiblity.value,
|
||||
value: visiblity.value,
|
||||
label: visiblity.label,
|
||||
}))}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
|
||||
<small>{t("profile_visibility_description")}</small>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<h3 style={{ marginTop: `${SPACING_UNIT * 2}px` }}>
|
||||
{t("manage_account")}
|
||||
</h3>
|
||||
|
||||
{userDetails?.email ? (
|
||||
<div>
|
||||
<h4>{t("current_email")}</h4>
|
||||
<p>{userDetails.email}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p>{t("no_associated_email")}</p>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "start",
|
||||
alignItems: "center",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() => window.electron.openManageAccount("update-email")}
|
||||
>
|
||||
{t("update_email")}
|
||||
<MailIcon />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() => window.electron.openManageAccount("update-password")}
|
||||
>
|
||||
{t("update_password")}
|
||||
<KeyIcon />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginTop: `${SPACING_UNIT * 2}px` }}>
|
||||
{t("subscription")}
|
||||
</h3>
|
||||
{userDetails?.subscription?.expiresAt ? (
|
||||
<p>
|
||||
{t("subscription_active_until", {
|
||||
date: formatDate(userDetails?.subscription?.expiresAt),
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<p>{t("subscription_not_active")}</p>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "start",
|
||||
alignItems: "center",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
<Button theme="outline" onClick={() => window.electron.openCheckout()}>
|
||||
{t("manage_subscription")}
|
||||
<CloudIcon />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginTop: `${SPACING_UNIT * 2}px` }}>
|
||||
{t("blocked_users")}
|
||||
</h3>
|
||||
|
||||
<ul className={styles.blockedUsersList}>
|
||||
{blockedUsers.length > 0 ? (
|
||||
blockedUsers.map((user) => {
|
||||
return (
|
||||
<li key={user.id} className={styles.blockedUser}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={user.profileImageUrl!}
|
||||
alt={user.displayName}
|
||||
className={styles.blockedUserAvatar}
|
||||
/>
|
||||
<span>{user.displayName}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.unblockButton}
|
||||
onClick={() => handleUnblockClick(user.id)}
|
||||
disabled={isUnblocking}
|
||||
>
|
||||
<XCircleFillIcon />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<small>{t("no_users_blocked")}</small>
|
||||
)}
|
||||
</ul>
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -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<FormValues>();
|
||||
|
||||
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 (
|
||||
<form className={styles.form} onSubmit={handleSubmit(onSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="profileVisibility"
|
||||
render={({ field }) => {
|
||||
const handleChange = (
|
||||
event: React.ChangeEvent<HTMLSelectElement>
|
||||
) => {
|
||||
field.onChange(event);
|
||||
handleSubmit(onSubmit)();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SelectField
|
||||
label={t("profile_visibility")}
|
||||
value={field.value}
|
||||
onChange={handleChange}
|
||||
options={visibilityOptions.map((visiblity) => ({
|
||||
key: visiblity.value,
|
||||
value: visiblity.value,
|
||||
label: visiblity.label,
|
||||
}))}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
|
||||
<small>{t("profile_visibility_description")}</small>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<h3 style={{ marginTop: `${SPACING_UNIT * 2}px` }}>
|
||||
{t("blocked_users")}
|
||||
</h3>
|
||||
|
||||
<ul className={styles.blockedUsersList}>
|
||||
{blockedUsers.map((user) => {
|
||||
return (
|
||||
<li key={user.id} className={styles.blockedUser}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={user.profileImageUrl!}
|
||||
alt={user.displayName}
|
||||
className={styles.blockedUserAvatar}
|
||||
/>
|
||||
<span>{user.displayName}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.unblockButton}
|
||||
onClick={() => handleUnblockClick(user.id)}
|
||||
disabled={isUnblocking}
|
||||
>
|
||||
<XCircleFillIcon />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -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 <SettingsRealDebrid />;
|
||||
}
|
||||
|
||||
return <SettingsPrivacy />;
|
||||
return <SettingsAccount />;
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
@ -416,6 +416,8 @@ export interface CatalogueSearchPayload {
|
|||
developers: string[];
|
||||
}
|
||||
|
||||
export type ManageAccountPage = "update-email" | "update-password";
|
||||
|
||||
export * from "./steam.types";
|
||||
export * from "./real-debrid.types";
|
||||
export * from "./ludusavi.types";
|
||||
|
|
Loading…
Reference in a new issue