mirror of
https://github.com/movie-web/movie-web.git
synced 2024-12-28 22:36:04 +00:00
authentication register and login
Co-authored-by: William Oldham <github@binaryoverload.co.uk>
This commit is contained in:
parent
791923e78c
commit
743ecc7869
|
@ -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,
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
48
src/backend/accounts/login.ts
Normal file
48
src/backend/accounts/login.ts
Normal 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,
|
||||
});
|
||||
}
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
128
src/backend/accounts/user.ts
Normal file
128
src/backend/accounts/user.ts
Normal 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
32
src/components/Avatar.tsx
Normal 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} />;
|
||||
}
|
|
@ -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
126
src/hooks/auth/useAuth.ts
Normal 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,
|
||||
};
|
||||
}
|
63
src/hooks/auth/useAuthData.ts
Normal file
63
src/hooks/auth/useAuthData.ts
Normal 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,
|
||||
};
|
||||
}
|
16
src/hooks/auth/useAuthRestore.ts
Normal file
16
src/hooks/auth/useAuthRestore.ts
Normal 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;
|
||||
}
|
7
src/hooks/auth/useBackendUrl.ts
Normal file
7
src/hooks/auth/useBackendUrl.ts
Normal 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;
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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
18
src/pages/Login.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -42,7 +42,7 @@ export function RegisterPage() {
|
|||
{step === 3 ? (
|
||||
<VerifyPassphrase
|
||||
mnemonic={mnemonic}
|
||||
profile={account}
|
||||
userData={account}
|
||||
onNext={() => {
|
||||
setStep(4);
|
||||
}}
|
||||
|
|
52
src/pages/parts/auth/LoginFormPart.tsx
Normal file
52
src/pages/parts/auth/LoginFormPart.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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 (
|
||||
|
|
|
@ -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} />
|
||||
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Reference in a new issue