authentication register and login

Co-authored-by: William Oldham <github@binaryoverload.co.uk>
This commit is contained in:
mrjvs 2023-11-05 17:56:56 +01:00
parent 791923e78c
commit 743ecc7869
22 changed files with 565 additions and 148 deletions

View file

@ -1,5 +1,7 @@
import { ofetch } from "ofetch";
import { UserResponse } from "@/backend/accounts/user";
export interface SessionResponse {
id: string;
userId: string;
@ -8,26 +10,12 @@ export interface SessionResponse {
device: string;
userAgent: string;
}
export interface UserResponse {
id: string;
namespace: string;
name: string;
roles: string[];
createdAt: string;
profile: {
colorA: string;
colorB: string;
icon: string;
};
}
export interface LoginResponse {
session: SessionResponse;
token: string;
}
function getAuthHeaders(token: string): Record<string, string> {
export function getAuthHeaders(token: string): Record<string, string> {
return {
authorization: `Bearer ${token}`,
};
@ -48,16 +36,6 @@ export async function accountLogin(
});
}
export async function getUser(
url: string,
token: string
): Promise<UserResponse> {
return ofetch<UserResponse>("/user/@me", {
headers: getAuthHeaders(token),
baseURL: url,
});
}
export async function removeSession(
url: string,
token: string,

View file

@ -1,6 +1,6 @@
import { pbkdf2Async } from "@noble/hashes/pbkdf2";
import { sha256 } from "@noble/hashes/sha256";
import { generateMnemonic } from "@scure/bip39";
import { generateMnemonic, validateMnemonic } from "@scure/bip39";
import { wordlist } from "@scure/bip39/wordlists/english";
import forge from "node-forge";
@ -11,7 +11,11 @@ async function seedFromMnemonic(mnemonic: string) {
});
}
export async function keysFromMenmonic(mnemonic: string) {
export function verifyValidMnemonic(mnemonic: string) {
return validateMnemonic(mnemonic, wordlist);
}
export async function keysFromMnemonic(mnemonic: string) {
const seed = await seedFromMnemonic(mnemonic);
const { privateKey, publicKey } = forge.pki.ed25519.generateKeyPair({
@ -45,3 +49,12 @@ export function bytesToBase64Url(bytes: Uint8Array): string {
.replace(/\+/g, "-")
.replace(/=+$/, "");
}
export async function signChallenge(mnemonic: string, challengeCode: string) {
const keys = await keysFromMnemonic(mnemonic);
const signature = await signCode(challengeCode, keys.privateKey);
return {
publicKey: bytesToBase64Url(keys.publicKey),
signature: bytesToBase64Url(signature),
};
}

View file

@ -0,0 +1,48 @@
import { ofetch } from "ofetch";
import { SessionResponse } from "@/backend/accounts/auth";
export interface ChallengeTokenResponse {
challenge: string;
}
export async function getLoginChallengeToken(
url: string,
publicKey: string
): Promise<ChallengeTokenResponse> {
return ofetch<ChallengeTokenResponse>("/auth/login/start", {
method: "POST",
body: {
publicKey,
},
baseURL: url,
});
}
export interface LoginResponse {
session: SessionResponse;
token: string;
}
export interface LoginInput {
publicKey: string;
challenge: {
code: string;
signature: string;
};
device: string;
}
export async function loginAccount(
url: string,
data: LoginInput
): Promise<LoginResponse> {
return ofetch<LoginResponse>("/auth/login/complete", {
method: "POST",
body: {
namespace: "movie-web",
...data,
},
baseURL: url,
});
}

View file

@ -1,11 +1,7 @@
import { ofetch } from "ofetch";
import { SessionResponse, UserResponse } from "@/backend/accounts/auth";
import {
bytesToBase64Url,
keysFromMenmonic as keysFromMnemonic,
signCode,
} from "@/backend/accounts/crypto";
import { SessionResponse } from "@/backend/accounts/auth";
import { UserResponse } from "@/backend/accounts/user";
export interface ChallengeTokenResponse {
challenge: string;
@ -57,12 +53,3 @@ export async function registerAccount(
baseURL: url,
});
}
export async function signChallenge(mnemonic: string, challengeCode: string) {
const keys = await keysFromMnemonic(mnemonic);
const signature = await signCode(challengeCode, keys.privateKey);
return {
publicKey: bytesToBase64Url(keys.publicKey),
signature: bytesToBase64Url(signature),
};
}

View file

@ -0,0 +1,128 @@
import { ofetch } from "ofetch";
import { getAuthHeaders } from "@/backend/accounts/auth";
import { AccountWithToken } from "@/stores/auth";
import { BookmarkMediaItem } from "@/stores/bookmarks";
import { ProgressMediaItem } from "@/stores/progress";
export interface UserResponse {
id: string;
namespace: string;
name: string;
roles: string[];
createdAt: string;
profile: {
colorA: string;
colorB: string;
icon: string;
};
}
export interface BookmarkResponse {
tmdbId: string;
meta: {
title: string;
year: number;
poster?: string;
type: "show" | "movie";
};
updatedAt: string;
}
export interface ProgressResponse {
tmdbId: string;
seasonId?: string;
episodeId?: string;
meta: {
title: string;
year: number;
poster?: string;
type: "show" | "movie";
};
duration: number;
watched: number;
updatedAt: string;
}
export function bookmarkResponsesToEntries(responses: BookmarkResponse[]) {
const entries = responses.map((bookmark) => {
const item: BookmarkMediaItem = {
...bookmark.meta,
updatedAt: new Date(bookmark.updatedAt).getTime(),
};
return [bookmark.tmdbId, item] as const;
});
return Object.fromEntries(entries);
}
export function progressResponsesToEntries(responses: ProgressResponse[]) {
const items: Record<string, ProgressMediaItem> = {};
responses.forEach((v) => {
if (!items[v.tmdbId]) {
items[v.tmdbId] = {
title: v.meta.title,
poster: v.meta.poster,
type: v.meta.type,
updatedAt: new Date(v.updatedAt).getTime(),
episodes: {},
seasons: {},
year: v.meta.year,
};
}
const item = items[v.tmdbId];
if (item.type === "movie") {
item.progress = {
duration: v.duration,
watched: v.watched,
};
}
if (item.type === "show" && v.seasonId && v.episodeId) {
item.seasons[v.seasonId] = {
id: v.seasonId,
number: 0, // TODO missing
title: "", // TODO missing
};
item.episodes[v.episodeId] = {
id: v.seasonId,
number: 0, // TODO missing
title: "", // TODO missing
progress: {
duration: v.duration,
watched: v.watched,
},
seasonId: v.seasonId,
updatedAt: new Date(v.updatedAt).getTime(),
};
}
});
return items;
}
export async function getUser(
url: string,
token: string
): Promise<UserResponse> {
return ofetch<UserResponse>("/users/@me", {
headers: getAuthHeaders(token),
baseURL: url,
});
}
export async function getBookmarks(url: string, account: AccountWithToken) {
return ofetch<BookmarkResponse[]>(`/users/${account.userId}/bookmarks`, {
headers: getAuthHeaders(account.token),
baseURL: url,
});
}
export async function getProgress(url: string, account: AccountWithToken) {
return ofetch<ProgressResponse[]>(`/users/${account.userId}/progress`, {
headers: getAuthHeaders(account.token),
baseURL: url,
});
}

32
src/components/Avatar.tsx Normal file
View file

@ -0,0 +1,32 @@
import { Icon, Icons } from "@/components/Icon";
import { AccountProfile } from "@/pages/parts/auth/AccountCreatePart";
import { useAuthStore } from "@/stores/auth";
export interface AvatarProps {
profile: AccountProfile["profile"];
}
const possibleIcons = ["bookmark"] as const;
const avatarIconMap: Record<(typeof possibleIcons)[number], Icons> = {
bookmark: Icons.BOOKMARK,
};
export function Avatar(props: AvatarProps) {
const icon = (avatarIconMap as any)[props.profile.icon] ?? Icons.X;
return (
<div
className="h-[2em] w-[2em] rounded-full overflow-hidden flex items-center justify-center text-white"
style={{
background: `linear-gradient(to bottom right, ${props.profile.colorA}, ${props.profile.colorB})`,
}}
>
<Icon icon={icon} />
</div>
);
}
export function UserAvatar() {
const auth = useAuthStore();
if (!auth.account) return null;
return <Avatar profile={auth.account.profile} />;
}

View file

@ -1,10 +1,11 @@
import classNames from "classnames";
import { Link } from "react-router-dom";
import { UserAvatar } from "@/components/Avatar";
import { IconPatch } from "@/components/buttons/IconPatch";
import { Icons } from "@/components/Icon";
import { Lightbar } from "@/components/utils/Lightbar";
import { useAuth } from "@/hooks/useAuth";
import { useAuth } from "@/hooks/auth/useAuth";
import { BlurEllipsis } from "@/pages/layouts/SubPageLayout";
import { conf } from "@/setup/config";
import { useBannerSize } from "@/stores/banner";
@ -59,8 +60,8 @@ export function Navigation(props: NavigationProps) {
>
<div className="absolute -bottom-24 h-24 w-full bg-gradient-to-b from-background-main to-transparent" />
</div>
<div className="pointer-events-auto px-7 py-5 relative flex flex-1 items-center space-x-3">
<div className="flex items-center flex-1">
<div className="pointer-events-auto px-7 py-5 relative flex flex-1 items-center">
<div className="flex items-center flex-1 space-x-3">
<Link className="block" to="/">
<BrandPill clickable />
</Link>
@ -81,9 +82,7 @@ export function Navigation(props: NavigationProps) {
<IconPatch icon={Icons.GITHUB} clickable downsized />
</a>
</div>
<div>
<p>User: {JSON.stringify(loggedIn)}</p>
</div>
<div>{loggedIn ? <UserAvatar /> : <p>Not logged in</p>}</div>
</div>
</div>
</div>

126
src/hooks/auth/useAuth.ts Normal file
View file

@ -0,0 +1,126 @@
import { useCallback } from "react";
import { removeSession } from "@/backend/accounts/auth";
import {
bytesToBase64Url,
keysFromMnemonic,
signChallenge,
} from "@/backend/accounts/crypto";
import { getLoginChallengeToken, loginAccount } from "@/backend/accounts/login";
import {
getRegisterChallengeToken,
registerAccount,
} from "@/backend/accounts/register";
import { getBookmarks, getProgress, getUser } from "@/backend/accounts/user";
import { useAuthData } from "@/hooks/auth/useAuthData";
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
import { useAuthStore } from "@/stores/auth";
export interface RegistrationData {
mnemonic: string;
userData: {
device: string;
profile: {
colorA: string;
colorB: string;
icon: string;
};
};
}
export interface LoginData {
mnemonic: string;
userData: {
device: string;
};
}
export function useAuth() {
const currentAccount = useAuthStore((s) => s.account);
const profile = useAuthStore((s) => s.account?.profile);
const loggedIn = !!useAuthStore((s) => s.account);
const backendUrl = useBackendUrl();
const {
logout: userDataLogout,
login: userDataLogin,
syncData,
} = useAuthData();
const login = useCallback(
async (loginData: LoginData) => {
const keys = await keysFromMnemonic(loginData.mnemonic);
const { challenge } = await getLoginChallengeToken(
backendUrl,
bytesToBase64Url(keys.publicKey)
);
const signResult = await signChallenge(loginData.mnemonic, challenge);
const loginResult = await loginAccount(backendUrl, {
challenge: {
code: challenge,
signature: signResult.signature,
},
publicKey: signResult.publicKey,
device: loginData.userData.device,
});
const user = await getUser(backendUrl, loginResult.token);
await userDataLogin(loginResult, user);
},
[userDataLogin, backendUrl]
);
const logout = useCallback(async () => {
if (!currentAccount) return;
try {
await removeSession(
backendUrl,
currentAccount.token,
currentAccount.sessionId
);
} catch {
// we dont care about failing to delete session
}
userDataLogout();
}, [userDataLogout, backendUrl, currentAccount]);
const register = useCallback(
async (registerData: RegistrationData) => {
const { challenge } = await getRegisterChallengeToken(backendUrl);
const signResult = await signChallenge(registerData.mnemonic, challenge);
const registerResult = await registerAccount(backendUrl, {
challenge: {
code: challenge,
signature: signResult.signature,
},
publicKey: signResult.publicKey,
device: registerData.userData.device,
profile: registerData.userData.profile,
});
await userDataLogin(registerResult, registerResult.user);
},
[backendUrl, userDataLogin]
);
const restore = useCallback(async () => {
if (!currentAccount) {
return;
}
// TODO if fail to get user, log them out
const user = await getUser(backendUrl, currentAccount.token);
const bookmarks = await getBookmarks(backendUrl, currentAccount);
const progress = await getProgress(backendUrl, currentAccount);
syncData(user, progress, bookmarks);
}, [backendUrl, currentAccount, syncData]);
return {
loggedIn,
profile,
login,
logout,
register,
restore,
};
}

View file

@ -0,0 +1,63 @@
import { useCallback } from "react";
import { LoginResponse } from "@/backend/accounts/auth";
import {
BookmarkResponse,
ProgressResponse,
UserResponse,
bookmarkResponsesToEntries,
progressResponsesToEntries,
} from "@/backend/accounts/user";
import { useAuthStore } from "@/stores/auth";
import { useBookmarkStore } from "@/stores/bookmarks";
import { useProgressStore } from "@/stores/progress";
export function useAuthData() {
const loggedIn = !!useAuthStore((s) => s.account);
const setAccount = useAuthStore((s) => s.setAccount);
const removeAccount = useAuthStore((s) => s.removeAccount);
const clearBookmarks = useBookmarkStore((s) => s.clear);
const clearProgress = useProgressStore((s) => s.clear);
const replaceBookmarks = useBookmarkStore((s) => s.replaceBookmarks);
const replaceItems = useProgressStore((s) => s.replaceItems);
const login = useCallback(
async (account: LoginResponse, user: UserResponse) => {
setAccount({
token: account.token,
userId: user.id,
sessionId: account.session.id,
profile: user.profile,
});
},
[setAccount]
);
const logout = useCallback(async () => {
removeAccount();
clearBookmarks();
clearProgress();
// TODO clear settings
}, [removeAccount, clearBookmarks, clearProgress]);
const syncData = useCallback(
async (
_user: UserResponse,
progress: ProgressResponse[],
bookmarks: BookmarkResponse[]
) => {
// TODO sync user settings
replaceBookmarks(bookmarkResponsesToEntries(bookmarks));
replaceItems(progressResponsesToEntries(progress));
},
[replaceBookmarks, replaceItems]
);
return {
loggedIn,
login,
logout,
syncData,
};
}

View file

@ -0,0 +1,16 @@
import { useAsync, useInterval } from "react-use";
import { useAuth } from "@/hooks/auth/useAuth";
const AUTH_CHECK_INTERVAL = 12 * 60 * 60 * 1000;
export function useAuthRestore() {
const { restore } = useAuth();
useInterval(() => {
restore();
}, AUTH_CHECK_INTERVAL);
const result = useAsync(() => restore(), [restore]);
return result;
}

View file

@ -0,0 +1,7 @@
import { conf } from "@/setup/config";
import { useAuthStore } from "@/stores/auth";
export function useBackendUrl() {
const backendUrl = useAuthStore((s) => s.backendUrl);
return backendUrl ?? conf().BACKEND_URL;
}

View file

@ -1,54 +0,0 @@
import { useCallback } from "react";
import { accountLogin, getUser, removeSession } from "@/backend/accounts/auth";
import { conf } from "@/setup/config";
import { useAuthStore } from "@/stores/auth";
export function useBackendUrl() {
const backendUrl = useAuthStore((s) => s.backendUrl);
return backendUrl ?? conf().BACKEND_URL;
}
export function useAuth() {
const currentAccount = useAuthStore((s) => s.account);
const profile = useAuthStore((s) => s.account?.profile);
const loggedIn = !!useAuthStore((s) => s.account);
const setAccount = useAuthStore((s) => s.setAccount);
const removeAccount = useAuthStore((s) => s.removeAccount);
const backendUrl = useBackendUrl();
const login = useCallback(
async (id: string, device: string) => {
const account = await accountLogin(backendUrl, id, device);
const user = await getUser(backendUrl, account.token);
setAccount({
token: account.token,
userId: user.id,
sessionId: account.session.id,
profile: user.profile,
});
},
[setAccount, backendUrl]
);
const logout = useCallback(async () => {
if (!currentAccount) return;
try {
await removeSession(
backendUrl,
currentAccount.token,
currentAccount.sessionId
);
} catch {
// we dont care about failing to delete session
}
removeAccount(); // TODO clear local data
}, [removeAccount, backendUrl, currentAccount]);
return {
loggedIn,
profile,
login,
logout,
};
}

View file

@ -1,12 +1,15 @@
import "core-js/stable";
import React, { Suspense } from "react";
import React from "react";
import type { ReactNode } from "react";
import ReactDOM from "react-dom";
import { HelmetProvider } from "react-helmet-async";
import { BrowserRouter, HashRouter } from "react-router-dom";
import { useAsync } from "react-use";
import { registerSW } from "virtual:pwa-register";
import { useAuthRestore } from "@/hooks/auth/useAuthRestore";
import { ErrorBoundary } from "@/pages/errors/ErrorBoundary";
import { MigrationPart } from "@/pages/parts/migrations/MigrationPart";
import App from "@/setup/App";
import { conf } from "@/setup/config";
import i18n from "@/setup/i18n";
@ -29,13 +32,24 @@ registerSW({
immediate: true,
});
const LazyLoadedApp = React.lazy(async () => {
await initializeOldStores();
i18n.changeLanguage(useLanguageStore.getState().language);
return {
default: App,
};
});
function AuthWrapper() {
const status = useAuthRestore();
if (status.loading) return <p>Fetching user data</p>;
if (status.error) return <p>Failed to fetch user data</p>;
return <App />;
}
function MigrationRunner() {
const status = useAsync(async () => {
i18n.changeLanguage(useLanguageStore.getState().language);
await initializeOldStores();
}, []);
if (status.loading) return <MigrationPart />;
if (status.error) return <p>Failed to migrate</p>;
return <AuthWrapper />;
}
function TheRouter(props: { children: ReactNode }) {
const normalRouter = conf().NORMAL_ROUTER;
@ -49,9 +63,7 @@ ReactDOM.render(
<ErrorBoundary>
<HelmetProvider>
<TheRouter>
<Suspense fallback="">
<LazyLoadedApp />
</Suspense>
<MigrationRunner />
</TheRouter>
</HelmetProvider>
</ErrorBoundary>

18
src/pages/Login.tsx Normal file
View file

@ -0,0 +1,18 @@
import { useHistory } from "react-router-dom";
import { SubPageLayout } from "@/pages/layouts/SubPageLayout";
import { LoginFormPart } from "@/pages/parts/auth/LoginFormPart";
export function LoginPage() {
const history = useHistory();
return (
<SubPageLayout>
<LoginFormPart
onLogin={() => {
history.push("/");
}}
/>
</SubPageLayout>
);
}

View file

@ -42,7 +42,7 @@ export function RegisterPage() {
{step === 3 ? (
<VerifyPassphrase
mnemonic={mnemonic}
profile={account}
userData={account}
onNext={() => {
setStep(4);
}}

View file

@ -0,0 +1,52 @@
import { useState } from "react";
import { useAsyncFn } from "react-use";
import { verifyValidMnemonic } from "@/backend/accounts/crypto";
import { Button } from "@/components/Button";
import { Input } from "@/components/player/internals/ContextMenu/Input";
import { useAuth } from "@/hooks/auth/useAuth";
interface LoginFormPartProps {
onLogin?: () => void;
}
export function LoginFormPart(props: LoginFormPartProps) {
const [mnemonic, setMnemonic] = useState("");
const [device, setDevice] = useState("");
const { login, restore } = useAuth();
const [result, execute] = useAsyncFn(
async (inputMnemonic: string, inputdevice: string) => {
// TODO verify valid device input
if (!verifyValidMnemonic(inputMnemonic))
throw new Error("Invalid or incomplete passphrase");
// TODO captcha?
await login({
mnemonic: inputMnemonic,
userData: {
device: inputdevice,
},
});
// TODO import (and sort out conflicts)
await restore();
props.onLogin?.();
},
[props, login, restore]
);
return (
<div>
<p>passphrase</p>
<Input value={mnemonic} onInput={setMnemonic} />
<p>Device name</p>
<Input value={device} onInput={setDevice} />
{result.loading ? <p>Loading...</p> : null}
{result.error ? <p>error: {result.error.toString()}</p> : null}
<Button onClick={() => execute(mnemonic, device)}>Login</Button>
</div>
);
}

View file

@ -1,58 +1,42 @@
import { useState } from "react";
import { useAsyncFn } from "react-use";
import {
getRegisterChallengeToken,
registerAccount,
signChallenge,
} from "@/backend/accounts/register";
import { Button } from "@/components/Button";
import { Input } from "@/components/player/internals/ContextMenu/Input";
import { useAuth } from "@/hooks/auth/useAuth";
import { AccountProfile } from "@/pages/parts/auth/AccountCreatePart";
import { conf } from "@/setup/config";
import { useAuthStore } from "@/stores/auth";
interface VerifyPassphraseProps {
mnemonic: string | null;
profile: AccountProfile | null;
userData: AccountProfile | null;
onNext?: () => void;
}
export function VerifyPassphrase(props: VerifyPassphraseProps) {
const [mnemonic, setMnemonic] = useState("");
const setAccount = useAuthStore((s) => s.setAccount);
const { register, restore } = useAuth();
const [result, execute] = useAsyncFn(
async (inputMnemonic: string) => {
if (!props.mnemonic || !props.profile)
if (!props.mnemonic || !props.userData)
throw new Error("invalid input data");
if (inputMnemonic !== props.mnemonic)
throw new Error("Passphrase doesn't match");
const url = conf().BACKEND_URL;
// TODO captcha?
const { challenge } = await getRegisterChallengeToken(url);
const keys = await signChallenge(inputMnemonic, challenge);
const registerResult = await registerAccount(url, {
challenge: {
code: challenge,
signature: keys.signature,
},
publicKey: keys.publicKey,
device: props.profile.device,
profile: props.profile.profile,
await register({
mnemonic: inputMnemonic,
userData: props.userData,
});
setAccount({
profile: registerResult.user.profile,
sessionId: registerResult.session.id,
token: registerResult.token,
userId: registerResult.user.id,
});
// TODO import (and sort out conflicts)
await restore();
props.onNext?.();
},
[props, setAccount]
[props, register, restore]
);
return (

View file

@ -16,6 +16,7 @@ import { AdminPage } from "@/pages/admin/AdminPage";
import { DmcaPage } from "@/pages/Dmca";
import { NotFoundPage } from "@/pages/errors/NotFoundPage";
import { HomePage } from "@/pages/HomePage";
import { LoginPage } from "@/pages/Login";
import { PlayerView } from "@/pages/PlayerView";
import { RegisterPage } from "@/pages/Register";
import { SettingsPage } from "@/pages/Settings";
@ -89,6 +90,7 @@ function App() {
</Route>
<Route exact path={["/browse/:query?", "/"]} component={HomePage} />
<Route exact path="/register" component={RegisterPage} />
<Route exact path="/login" component={LoginPage} />
<Route exact path="/faq" component={AboutPage} />
<Route exact path="/dmca" component={DmcaPage} />

View file

@ -27,7 +27,6 @@ const storeCallbacks: Record<string, ((data: any) => void)[]> = {};
const stores: Record<string, [StoreRet<any>, InternalStoreData]> = {};
export async function initializeOldStores() {
console.log(stores);
// migrate all stores
for (const [store, internal] of Object.values(stores)) {
const versions = internal.versions.sort((a, b) => a.version - b.version);
@ -169,7 +168,6 @@ export function createVersionedStore<T>(): StoreBuilder<T> {
return this;
},
build() {
console.log(_data.key);
assertStore(_data);
const storageObject = buildStorageObject<T>(_data);
stores[_data.key ?? ""] = [storageObject, _data];

View file

@ -2,7 +2,7 @@ import { create } from "zustand";
import { persist } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";
interface Account {
export interface Account {
profile: {
colorA: string;
colorB: string;
@ -10,7 +10,7 @@ interface Account {
};
}
type AccountWithToken = Account & {
export type AccountWithToken = Account & {
sessionId: string;
userId: string;
token: string;

View file

@ -12,16 +12,17 @@ export interface BookmarkMediaItem {
updatedAt: number;
}
export interface ProgressStore {
export interface BookmarkStore {
bookmarks: Record<string, BookmarkMediaItem>;
addBookmark(meta: PlayerMeta): void;
removeBookmark(id: string): void;
replaceBookmarks(items: Record<string, BookmarkMediaItem>): void;
clear(): void;
}
export const useBookmarkStore = create(
persist(
immer<ProgressStore>((set) => ({
immer<BookmarkStore>((set) => ({
bookmarks: {},
removeBookmark(id) {
set((s) => {
@ -44,6 +45,9 @@ export const useBookmarkStore = create(
s.bookmarks = items;
});
},
clear() {
this.replaceBookmarks({});
},
})),
{
name: "__MW::bookmarks",

View file

@ -45,6 +45,7 @@ export interface ProgressStore {
updateItem(ops: UpdateItemOptions): void;
removeItem(id: string): void;
replaceItems(items: Record<string, ProgressMediaItem>): void;
clear(): void;
}
export const useProgressStore = create(
@ -111,6 +112,9 @@ export const useProgressStore = create(
item.episodes[meta.episode.tmdbId].progress = { ...progress };
});
},
clear() {
this.replaceItems({});
},
})),
{
name: "__MW::progress",