This commit is contained in:
Ivan Evans 2025-01-05 13:32:34 -07:00
parent 3e0ef278c9
commit e433e8070e
6 changed files with 335 additions and 5 deletions

View file

@ -197,6 +197,23 @@
"show": "Show"
}
},
"migration": {
"title": "Migrate Account Data",
"description": "Enter the destination backend URL to migrate your current account data to a new backend. This keeps your passphrase the same!",
"backendLabel": "Destination Backend URL",
"recaptchaLabel": "ReCaptcha Key (Optional)",
"toggleLable": "Needs ReCaptcha?",
"status": {
"error": "Failed to migrate your data. 😿",
"success": "Your data has been migrated successfully! 🎉"
},
"button": {
"migrate": "Migrate",
"processing": "Processing...",
"home": "Go home",
"login": "Continue to login"
}
},
"navigation": {
"banner": {
"offline": "Check your internet connection, silly goose! 🦢"
@ -573,7 +590,11 @@
"server": {
"description": "If you would like to connect to a custom backend to store your data, enable this and provide the URL. <0>Instructions.</0>",
"label": "Custom server",
"urlLabel": "Custom server URL"
"urlLabel": "Custom server URL",
"migration": {
"description": "<0>Migrate my data</0> to a new server. ",
"link": "Migrate my data"
}
},
"setup": {
"doSetup": "Do setup",

View file

@ -21,9 +21,7 @@ export function verifyValidMnemonic(mnemonic: string) {
return validateMnemonic(mnemonic, wordlist);
}
export async function keysFromMnemonic(mnemonic: string): Promise<Keys> {
const seed = await seedFromMnemonic(mnemonic);
export async function keysFromSeed(seed: Uint8Array): Promise<Keys> {
const { privateKey, publicKey } = forge.pki.ed25519.generateKeyPair({
seed,
});
@ -35,6 +33,12 @@ export async function keysFromMnemonic(mnemonic: string): Promise<Keys> {
};
}
export async function keysFromMnemonic(mnemonic: string): Promise<Keys> {
const seed = await seedFromMnemonic(mnemonic);
return keysFromSeed(seed);
}
export function genMnemonic(): string {
return generateMnemonic(wordlist);
}

View file

@ -0,0 +1,125 @@
import { useCallback } from "react";
import { SessionResponse } from "@/backend/accounts/auth";
import { bookmarkMediaToInput } from "@/backend/accounts/bookmarks";
import {
base64ToBuffer,
bytesToBase64,
bytesToBase64Url,
encryptData,
keysFromMnemonic,
keysFromSeed,
signChallenge,
} from "@/backend/accounts/crypto";
import { importBookmarks, importProgress } from "@/backend/accounts/import";
import { getLoginChallengeToken, loginAccount } from "@/backend/accounts/login";
import { progressMediaItemToInputs } from "@/backend/accounts/progress";
import {
getRegisterChallengeToken,
registerAccount,
} from "@/backend/accounts/register";
import { removeSession } from "@/backend/accounts/sessions";
import { getSettings } from "@/backend/accounts/settings";
import {
UserResponse,
getBookmarks,
getProgress,
getUser,
} from "@/backend/accounts/user";
import { useAuthData } from "@/hooks/auth/useAuthData";
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
import { AccountWithToken, useAuthStore } from "@/stores/auth";
import { BookmarkMediaItem, useBookmarkStore } from "@/stores/bookmarks";
import { ProgressMediaItem, useProgressStore } from "@/stores/progress";
export interface RegistrationData {
recaptchaToken?: string;
mnemonic: string;
userData: {
device: string;
profile: {
colorA: string;
colorB: string;
icon: string;
};
};
}
export interface LoginData {
mnemonic: string;
userData: {
device: string;
};
}
export function useMigration() {
const currentAccount = useAuthStore((s) => s.account);
const progress = useProgressStore((s) => s.items);
const bookmarks = useBookmarkStore((s) => s.bookmarks);
const { login: userDataLogin } = useAuthData();
const importData = async (
backendUrl: string,
account: AccountWithToken,
progressItems: Record<string, ProgressMediaItem>,
bookmarkItems: Record<string, BookmarkMediaItem>,
) => {
if (
Object.keys(progressItems).length === 0 &&
Object.keys(bookmarkItems).length === 0
) {
return;
}
const progressInputs = Object.entries(progressItems).flatMap(
([tmdbId, item]) => progressMediaItemToInputs(tmdbId, item),
);
const bookmarkInputs = Object.entries(bookmarkItems).map(([tmdbId, item]) =>
bookmarkMediaToInput(tmdbId, item),
);
await Promise.all([
importProgress(backendUrl, account, progressInputs),
importBookmarks(backendUrl, account, bookmarkInputs),
]);
};
const migrate = useCallback(
async (backendUrl: string, recaptchaToken?: string) => {
if (!currentAccount) return;
const { challenge } = await getRegisterChallengeToken(
backendUrl,
recaptchaToken || undefined, // Pass undefined if token is not provided
);
const keys = await keysFromSeed(base64ToBuffer(currentAccount.seed));
const signature = await signChallenge(keys, challenge);
const registerResult = await registerAccount(backendUrl, {
challenge: {
code: challenge,
signature,
},
publicKey: bytesToBase64Url(keys.publicKey),
device: await encryptData(currentAccount.deviceName, keys.seed),
profile: currentAccount.profile,
});
const account = await userDataLogin(
registerResult,
registerResult.user,
registerResult.session,
bytesToBase64(keys.seed),
);
await importData(backendUrl, account, progress, bookmarks);
return account;
},
[currentAccount, userDataLogin, bookmarks, progress],
);
return {
migrate,
};
}

View file

@ -0,0 +1,162 @@
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/buttons/Button";
import { Toggle } from "@/components/buttons/Toggle";
import { SettingsCard } from "@/components/layout/SettingsCard";
import { CenterContainer } from "@/components/layout/ThinContainer";
import { AuthInputBox } from "@/components/text-inputs/AuthInputBox";
import { Divider } from "@/components/utils/Divider";
import { Heading2, Paragraph } from "@/components/utils/Text";
import { useMigration } from "@/hooks/auth/useMigration";
import { MinimalPageLayout } from "@/pages/layouts/MinimalPageLayout";
import { PageTitle } from "@/pages/parts/util/PageTitle";
import { useAuthStore } from "@/stores/auth";
export function MigrationDirectPage() {
const { t } = useTranslation();
const user = useAuthStore();
const navigate = useNavigate();
const { migrate } = useMigration();
const [backendUrl, setBackendUrl] = useState("");
const [recaptchaToken, setRecaptchaToken] = useState<string | undefined>();
const [status, setStatus] = useState<
"idle" | "success" | "error" | "processing"
>("idle");
const [needscaptcha, setNeedscaptcha] = useState(false);
const handleMigration = useCallback(async () => {
if (!backendUrl) {
// eslint-disable-next-line no-alert
alert("Please provide a Backend URL.");
return;
}
try {
setStatus("processing");
const account = await migrate(backendUrl, recaptchaToken);
if (account) {
setStatus("success");
} else {
setStatus("error");
}
} catch (error) {
console.error("Error during migration:", error);
setStatus("error");
}
}, [backendUrl, recaptchaToken, migrate]);
const handleToggleChange = () => {
setNeedscaptcha(!needscaptcha);
};
return (
<MinimalPageLayout>
<PageTitle subpage k="global.pages.migration" />
<CenterContainer>
{user.account ? (
<div>
<Heading2 className="!text-4xl"> {t("migration.title")}</Heading2>
<div className="space-y-6 max-w-3xl mx-auto">
<Paragraph className="text-lg max-w-md">
{t("migration.description")}
</Paragraph>
<SettingsCard>
<div className="flex justify-between items-center">
<p className="font-bold text-white">
{t("migration.backendLabel")}
</p>
</div>
{backendUrl !== null && (
<>
<Divider marginClass="my-6 px-8 box-content -mx-8" />
<AuthInputBox
placeholder="https://"
value={backendUrl ?? ""}
onChange={setBackendUrl}
/>
</>
)}
</SettingsCard>
<div className="flex items-center gap-4">
<Toggle enabled={needscaptcha} onClick={handleToggleChange} />
<p
className={`flex-1 font-bold ${
needscaptcha ? "text-white" : "text-type-secondary"
}`}
>
{t("migration.toggleLable")}
</p>
</div>
{needscaptcha && (
<SettingsCard>
<div className="flex justify-between items-center">
<p className="font-bold text-white">
{t("migration.recaptchaLabel")}
</p>
</div>
{recaptchaToken !== null && (
<>
<Divider marginClass="my-6 px-8 box-content -mx-8" />
<AuthInputBox
value={recaptchaToken ?? ""}
onChange={(val) => setRecaptchaToken(val)}
/>
</>
)}
</SettingsCard>
)}
<div className="text-center">
{status !== "success" && (
<Button theme="purple" onClick={handleMigration}>
{status === "processing"
? t("migration.button.processing")
: t("migration.button.migrate")}
</Button>
)}
{status === "success" && (
<div>
<Button
theme="purple"
className="mt-4"
onClick={() => navigate("/login")}
>
{t("migration.button.login")}
</Button>
<p className="text-green-600 mt-4">
{t("migration.status.success")}
</p>
</div>
)}
{status === "error" && (
<p className="text-red-600 mt-4">
{t("migration.status.error")}
</p>
)}
</div>
</div>
</div>
) : (
<div className="flex flex-col items-center text-center mb-8">
<Paragraph className="max-w-[320px] text-md">
You must be logged in to migrate your data! Please go back and
login to continue.
</Paragraph>
<Button
theme="purple"
className="mt-4"
onClick={() => navigate("/")}
>
{t("migration.button.home")}
</Button>
</div>
)}
</CenterContainer>
</MinimalPageLayout>
);
}

View file

@ -9,7 +9,8 @@ import { MwLink } from "@/components/text/Link";
import { AuthInputBox } from "@/components/text-inputs/AuthInputBox";
import { Divider } from "@/components/utils/Divider";
import { Heading1 } from "@/components/utils/Text";
// import { SetupPart } from "@/pages/parts/settings/SetupPart";
import { SetupPart } from "@/pages/parts/settings/SetupPart";
import { useAuthStore } from "@/stores/auth";
interface ProxyEditProps {
proxyUrls: string[] | null;
@ -116,6 +117,7 @@ function ProxyEdit({ proxyUrls, setProxyUrls }: ProxyEditProps) {
function BackendEdit({ backendUrl, setBackendUrl }: BackendEditProps) {
const { t } = useTranslation();
const user = useAuthStore();
return (
<SettingsCard>
<div className="flex justify-between items-center gap-4">
@ -130,6 +132,18 @@ function BackendEdit({ backendUrl, setBackendUrl }: BackendEditProps) {
</MwLink>
</Trans>
</p>
{user.account && (
<div>
<br />
<p className="max-w-[20rem] font-medium">
<Trans i18nKey="settings.connections.server.migration.description">
<MwLink to="/migration">
{t("settings.connections.server.migration.link")}
</MwLink>
</Trans>
</p>
</div>
)}
</div>
<div>
<Toggle

View file

@ -22,6 +22,7 @@ import { NotFoundPage } from "@/pages/errors/NotFoundPage";
import { HomePage } from "@/pages/HomePage";
import { JipPage } from "@/pages/Jip";
import { LoginPage } from "@/pages/Login";
import { MigrationDirectPage } from "@/pages/migration/MigrationDirect";
import { OnboardingPage } from "@/pages/onboarding/Onboarding";
import { OnboardingExtensionPage } from "@/pages/onboarding/OnboardingExtension";
import { OnboardingProxyPage } from "@/pages/onboarding/OnboardingProxy";
@ -146,6 +147,9 @@ function App() {
element={<OnboardingExtensionPage />}
/>
<Route path="/onboarding/proxy" element={<OnboardingProxyPage />} />
<Route path="/migration" element={<MigrationDirectPage />} />
{shouldHaveDmcaPage() ? (
<Route path="/dmca" element={<DmcaPage />} />
) : null}