mirror of
https://github.com/sussy-code/smov.git
synced 2026-05-18 15:41:42 +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"
|
"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": {
|
"navigation": {
|
||||||
"banner": {
|
"banner": {
|
||||||
"offline": "Check your internet connection, silly goose! 🦢"
|
"offline": "Check your internet connection, silly goose! 🦢"
|
||||||
|
|
@ -573,7 +590,11 @@
|
||||||
"server": {
|
"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>",
|
"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",
|
"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": {
|
"setup": {
|
||||||
"doSetup": "Do setup",
|
"doSetup": "Do setup",
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,7 @@ export function verifyValidMnemonic(mnemonic: string) {
|
||||||
return validateMnemonic(mnemonic, wordlist);
|
return validateMnemonic(mnemonic, wordlist);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function keysFromMnemonic(mnemonic: string): Promise<Keys> {
|
export async function keysFromSeed(seed: Uint8Array): Promise<Keys> {
|
||||||
const seed = await seedFromMnemonic(mnemonic);
|
|
||||||
|
|
||||||
const { privateKey, publicKey } = forge.pki.ed25519.generateKeyPair({
|
const { privateKey, publicKey } = forge.pki.ed25519.generateKeyPair({
|
||||||
seed,
|
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 {
|
export function genMnemonic(): string {
|
||||||
return generateMnemonic(wordlist);
|
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 { AuthInputBox } from "@/components/text-inputs/AuthInputBox";
|
||||||
import { Divider } from "@/components/utils/Divider";
|
import { Divider } from "@/components/utils/Divider";
|
||||||
import { Heading1 } from "@/components/utils/Text";
|
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 {
|
interface ProxyEditProps {
|
||||||
proxyUrls: string[] | null;
|
proxyUrls: string[] | null;
|
||||||
|
|
@ -116,6 +117,7 @@ function ProxyEdit({ proxyUrls, setProxyUrls }: ProxyEditProps) {
|
||||||
|
|
||||||
function BackendEdit({ backendUrl, setBackendUrl }: BackendEditProps) {
|
function BackendEdit({ backendUrl, setBackendUrl }: BackendEditProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const user = useAuthStore();
|
||||||
return (
|
return (
|
||||||
<SettingsCard>
|
<SettingsCard>
|
||||||
<div className="flex justify-between items-center gap-4">
|
<div className="flex justify-between items-center gap-4">
|
||||||
|
|
@ -130,6 +132,18 @@ function BackendEdit({ backendUrl, setBackendUrl }: BackendEditProps) {
|
||||||
</MwLink>
|
</MwLink>
|
||||||
</Trans>
|
</Trans>
|
||||||
</p>
|
</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>
|
||||||
<div>
|
<div>
|
||||||
<Toggle
|
<Toggle
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import { NotFoundPage } from "@/pages/errors/NotFoundPage";
|
||||||
import { HomePage } from "@/pages/HomePage";
|
import { HomePage } from "@/pages/HomePage";
|
||||||
import { JipPage } from "@/pages/Jip";
|
import { JipPage } from "@/pages/Jip";
|
||||||
import { LoginPage } from "@/pages/Login";
|
import { LoginPage } from "@/pages/Login";
|
||||||
|
import { MigrationDirectPage } from "@/pages/migration/MigrationDirect";
|
||||||
import { OnboardingPage } from "@/pages/onboarding/Onboarding";
|
import { OnboardingPage } from "@/pages/onboarding/Onboarding";
|
||||||
import { OnboardingExtensionPage } from "@/pages/onboarding/OnboardingExtension";
|
import { OnboardingExtensionPage } from "@/pages/onboarding/OnboardingExtension";
|
||||||
import { OnboardingProxyPage } from "@/pages/onboarding/OnboardingProxy";
|
import { OnboardingProxyPage } from "@/pages/onboarding/OnboardingProxy";
|
||||||
|
|
@ -146,6 +147,9 @@ function App() {
|
||||||
element={<OnboardingExtensionPage />}
|
element={<OnboardingExtensionPage />}
|
||||||
/>
|
/>
|
||||||
<Route path="/onboarding/proxy" element={<OnboardingProxyPage />} />
|
<Route path="/onboarding/proxy" element={<OnboardingProxyPage />} />
|
||||||
|
|
||||||
|
<Route path="/migration" element={<MigrationDirectPage />} />
|
||||||
|
|
||||||
{shouldHaveDmcaPage() ? (
|
{shouldHaveDmcaPage() ? (
|
||||||
<Route path="/dmca" element={<DmcaPage />} />
|
<Route path="/dmca" element={<DmcaPage />} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue