mirror of
https://github.com/sussy-code/smov.git
synced 2026-01-11 20:10:16 +00:00
init
This commit is contained in:
parent
3e0ef278c9
commit
e433e8070e
6 changed files with 335 additions and 5 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
125
src/hooks/auth/useMigration.ts
Normal file
125
src/hooks/auth/useMigration.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
162
src/pages/migration/MigrationDirect.tsx
Normal file
162
src/pages/migration/MigrationDirect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue