mirror of
https://github.com/sussy-code/smov.git
synced 2024-12-30 16:17:41 +01:00
authentication register and login
Co-authored-by: William Oldham <github@binaryoverload.co.uk>
This commit is contained in:
parent
791923e78c
commit
743ecc7869
22 changed files with 565 additions and 148 deletions
|
@ -1,5 +1,7 @@
|
||||||
import { ofetch } from "ofetch";
|
import { ofetch } from "ofetch";
|
||||||
|
|
||||||
|
import { UserResponse } from "@/backend/accounts/user";
|
||||||
|
|
||||||
export interface SessionResponse {
|
export interface SessionResponse {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
@ -8,26 +10,12 @@ export interface SessionResponse {
|
||||||
device: string;
|
device: string;
|
||||||
userAgent: 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 {
|
export interface LoginResponse {
|
||||||
session: SessionResponse;
|
session: SessionResponse;
|
||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAuthHeaders(token: string): Record<string, string> {
|
export function getAuthHeaders(token: string): Record<string, string> {
|
||||||
return {
|
return {
|
||||||
authorization: `Bearer ${token}`,
|
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(
|
export async function removeSession(
|
||||||
url: string,
|
url: string,
|
||||||
token: string,
|
token: string,
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { pbkdf2Async } from "@noble/hashes/pbkdf2";
|
import { pbkdf2Async } from "@noble/hashes/pbkdf2";
|
||||||
import { sha256 } from "@noble/hashes/sha256";
|
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 { wordlist } from "@scure/bip39/wordlists/english";
|
||||||
import forge from "node-forge";
|
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 seed = await seedFromMnemonic(mnemonic);
|
||||||
|
|
||||||
const { privateKey, publicKey } = forge.pki.ed25519.generateKeyPair({
|
const { privateKey, publicKey } = forge.pki.ed25519.generateKeyPair({
|
||||||
|
@ -45,3 +49,12 @@ export function bytesToBase64Url(bytes: Uint8Array): string {
|
||||||
.replace(/\+/g, "-")
|
.replace(/\+/g, "-")
|
||||||
.replace(/=+$/, "");
|
.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 { ofetch } from "ofetch";
|
||||||
|
|
||||||
import { SessionResponse, UserResponse } from "@/backend/accounts/auth";
|
import { SessionResponse } from "@/backend/accounts/auth";
|
||||||
import {
|
import { UserResponse } from "@/backend/accounts/user";
|
||||||
bytesToBase64Url,
|
|
||||||
keysFromMenmonic as keysFromMnemonic,
|
|
||||||
signCode,
|
|
||||||
} from "@/backend/accounts/crypto";
|
|
||||||
|
|
||||||
export interface ChallengeTokenResponse {
|
export interface ChallengeTokenResponse {
|
||||||
challenge: string;
|
challenge: string;
|
||||||
|
@ -57,12 +53,3 @@ export async function registerAccount(
|
||||||
baseURL: url,
|
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 classNames from "classnames";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import { UserAvatar } from "@/components/Avatar";
|
||||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||||
import { Icons } from "@/components/Icon";
|
import { Icons } from "@/components/Icon";
|
||||||
import { Lightbar } from "@/components/utils/Lightbar";
|
import { Lightbar } from "@/components/utils/Lightbar";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/auth/useAuth";
|
||||||
import { BlurEllipsis } from "@/pages/layouts/SubPageLayout";
|
import { BlurEllipsis } from "@/pages/layouts/SubPageLayout";
|
||||||
import { conf } from "@/setup/config";
|
import { conf } from "@/setup/config";
|
||||||
import { useBannerSize } from "@/stores/banner";
|
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 className="absolute -bottom-24 h-24 w-full bg-gradient-to-b from-background-main to-transparent" />
|
||||||
</div>
|
</div>
|
||||||
<div className="pointer-events-auto px-7 py-5 relative flex flex-1 items-center space-x-3">
|
<div className="pointer-events-auto px-7 py-5 relative flex flex-1 items-center">
|
||||||
<div className="flex items-center flex-1">
|
<div className="flex items-center flex-1 space-x-3">
|
||||||
<Link className="block" to="/">
|
<Link className="block" to="/">
|
||||||
<BrandPill clickable />
|
<BrandPill clickable />
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -81,9 +82,7 @@ export function Navigation(props: NavigationProps) {
|
||||||
<IconPatch icon={Icons.GITHUB} clickable downsized />
|
<IconPatch icon={Icons.GITHUB} clickable downsized />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>{loggedIn ? <UserAvatar /> : <p>Not logged in</p>}</div>
|
||||||
<p>User: {JSON.stringify(loggedIn)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</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 "core-js/stable";
|
||||||
import React, { Suspense } from "react";
|
import React from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
import { HelmetProvider } from "react-helmet-async";
|
import { HelmetProvider } from "react-helmet-async";
|
||||||
import { BrowserRouter, HashRouter } from "react-router-dom";
|
import { BrowserRouter, HashRouter } from "react-router-dom";
|
||||||
|
import { useAsync } from "react-use";
|
||||||
import { registerSW } from "virtual:pwa-register";
|
import { registerSW } from "virtual:pwa-register";
|
||||||
|
|
||||||
|
import { useAuthRestore } from "@/hooks/auth/useAuthRestore";
|
||||||
import { ErrorBoundary } from "@/pages/errors/ErrorBoundary";
|
import { ErrorBoundary } from "@/pages/errors/ErrorBoundary";
|
||||||
|
import { MigrationPart } from "@/pages/parts/migrations/MigrationPart";
|
||||||
import App from "@/setup/App";
|
import App from "@/setup/App";
|
||||||
import { conf } from "@/setup/config";
|
import { conf } from "@/setup/config";
|
||||||
import i18n from "@/setup/i18n";
|
import i18n from "@/setup/i18n";
|
||||||
|
@ -29,13 +32,24 @@ registerSW({
|
||||||
immediate: true,
|
immediate: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const LazyLoadedApp = React.lazy(async () => {
|
function AuthWrapper() {
|
||||||
await initializeOldStores();
|
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);
|
i18n.changeLanguage(useLanguageStore.getState().language);
|
||||||
return {
|
await initializeOldStores();
|
||||||
default: App,
|
}, []);
|
||||||
};
|
|
||||||
});
|
if (status.loading) return <MigrationPart />;
|
||||||
|
if (status.error) return <p>Failed to migrate</p>;
|
||||||
|
return <AuthWrapper />;
|
||||||
|
}
|
||||||
|
|
||||||
function TheRouter(props: { children: ReactNode }) {
|
function TheRouter(props: { children: ReactNode }) {
|
||||||
const normalRouter = conf().NORMAL_ROUTER;
|
const normalRouter = conf().NORMAL_ROUTER;
|
||||||
|
@ -49,9 +63,7 @@ ReactDOM.render(
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<HelmetProvider>
|
<HelmetProvider>
|
||||||
<TheRouter>
|
<TheRouter>
|
||||||
<Suspense fallback="">
|
<MigrationRunner />
|
||||||
<LazyLoadedApp />
|
|
||||||
</Suspense>
|
|
||||||
</TheRouter>
|
</TheRouter>
|
||||||
</HelmetProvider>
|
</HelmetProvider>
|
||||||
</ErrorBoundary>
|
</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 ? (
|
{step === 3 ? (
|
||||||
<VerifyPassphrase
|
<VerifyPassphrase
|
||||||
mnemonic={mnemonic}
|
mnemonic={mnemonic}
|
||||||
profile={account}
|
userData={account}
|
||||||
onNext={() => {
|
onNext={() => {
|
||||||
setStep(4);
|
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 { useState } from "react";
|
||||||
import { useAsyncFn } from "react-use";
|
import { useAsyncFn } from "react-use";
|
||||||
|
|
||||||
import {
|
|
||||||
getRegisterChallengeToken,
|
|
||||||
registerAccount,
|
|
||||||
signChallenge,
|
|
||||||
} from "@/backend/accounts/register";
|
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Input } from "@/components/player/internals/ContextMenu/Input";
|
import { Input } from "@/components/player/internals/ContextMenu/Input";
|
||||||
|
import { useAuth } from "@/hooks/auth/useAuth";
|
||||||
import { AccountProfile } from "@/pages/parts/auth/AccountCreatePart";
|
import { AccountProfile } from "@/pages/parts/auth/AccountCreatePart";
|
||||||
import { conf } from "@/setup/config";
|
|
||||||
import { useAuthStore } from "@/stores/auth";
|
|
||||||
|
|
||||||
interface VerifyPassphraseProps {
|
interface VerifyPassphraseProps {
|
||||||
mnemonic: string | null;
|
mnemonic: string | null;
|
||||||
profile: AccountProfile | null;
|
userData: AccountProfile | null;
|
||||||
onNext?: () => void;
|
onNext?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VerifyPassphrase(props: VerifyPassphraseProps) {
|
export function VerifyPassphrase(props: VerifyPassphraseProps) {
|
||||||
const [mnemonic, setMnemonic] = useState("");
|
const [mnemonic, setMnemonic] = useState("");
|
||||||
const setAccount = useAuthStore((s) => s.setAccount);
|
const { register, restore } = useAuth();
|
||||||
|
|
||||||
const [result, execute] = useAsyncFn(
|
const [result, execute] = useAsyncFn(
|
||||||
async (inputMnemonic: string) => {
|
async (inputMnemonic: string) => {
|
||||||
if (!props.mnemonic || !props.profile)
|
if (!props.mnemonic || !props.userData)
|
||||||
throw new Error("invalid input data");
|
throw new Error("invalid input data");
|
||||||
if (inputMnemonic !== props.mnemonic)
|
if (inputMnemonic !== props.mnemonic)
|
||||||
throw new Error("Passphrase doesn't match");
|
throw new Error("Passphrase doesn't match");
|
||||||
const url = conf().BACKEND_URL;
|
|
||||||
|
|
||||||
// TODO captcha?
|
// TODO captcha?
|
||||||
const { challenge } = await getRegisterChallengeToken(url);
|
|
||||||
const keys = await signChallenge(inputMnemonic, challenge);
|
await register({
|
||||||
const registerResult = await registerAccount(url, {
|
mnemonic: inputMnemonic,
|
||||||
challenge: {
|
userData: props.userData,
|
||||||
code: challenge,
|
|
||||||
signature: keys.signature,
|
|
||||||
},
|
|
||||||
publicKey: keys.publicKey,
|
|
||||||
device: props.profile.device,
|
|
||||||
profile: props.profile.profile,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setAccount({
|
// TODO import (and sort out conflicts)
|
||||||
profile: registerResult.user.profile,
|
|
||||||
sessionId: registerResult.session.id,
|
await restore();
|
||||||
token: registerResult.token,
|
|
||||||
userId: registerResult.user.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
props.onNext?.();
|
props.onNext?.();
|
||||||
},
|
},
|
||||||
[props, setAccount]
|
[props, register, restore]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -16,6 +16,7 @@ import { AdminPage } from "@/pages/admin/AdminPage";
|
||||||
import { DmcaPage } from "@/pages/Dmca";
|
import { DmcaPage } from "@/pages/Dmca";
|
||||||
import { NotFoundPage } from "@/pages/errors/NotFoundPage";
|
import { NotFoundPage } from "@/pages/errors/NotFoundPage";
|
||||||
import { HomePage } from "@/pages/HomePage";
|
import { HomePage } from "@/pages/HomePage";
|
||||||
|
import { LoginPage } from "@/pages/Login";
|
||||||
import { PlayerView } from "@/pages/PlayerView";
|
import { PlayerView } from "@/pages/PlayerView";
|
||||||
import { RegisterPage } from "@/pages/Register";
|
import { RegisterPage } from "@/pages/Register";
|
||||||
import { SettingsPage } from "@/pages/Settings";
|
import { SettingsPage } from "@/pages/Settings";
|
||||||
|
@ -89,6 +90,7 @@ function App() {
|
||||||
</Route>
|
</Route>
|
||||||
<Route exact path={["/browse/:query?", "/"]} component={HomePage} />
|
<Route exact path={["/browse/:query?", "/"]} component={HomePage} />
|
||||||
<Route exact path="/register" component={RegisterPage} />
|
<Route exact path="/register" component={RegisterPage} />
|
||||||
|
<Route exact path="/login" component={LoginPage} />
|
||||||
<Route exact path="/faq" component={AboutPage} />
|
<Route exact path="/faq" component={AboutPage} />
|
||||||
<Route exact path="/dmca" component={DmcaPage} />
|
<Route exact path="/dmca" component={DmcaPage} />
|
||||||
|
|
||||||
|
|
|
@ -27,7 +27,6 @@ const storeCallbacks: Record<string, ((data: any) => void)[]> = {};
|
||||||
const stores: Record<string, [StoreRet<any>, InternalStoreData]> = {};
|
const stores: Record<string, [StoreRet<any>, InternalStoreData]> = {};
|
||||||
|
|
||||||
export async function initializeOldStores() {
|
export async function initializeOldStores() {
|
||||||
console.log(stores);
|
|
||||||
// migrate all stores
|
// migrate all stores
|
||||||
for (const [store, internal] of Object.values(stores)) {
|
for (const [store, internal] of Object.values(stores)) {
|
||||||
const versions = internal.versions.sort((a, b) => a.version - b.version);
|
const versions = internal.versions.sort((a, b) => a.version - b.version);
|
||||||
|
@ -169,7 +168,6 @@ export function createVersionedStore<T>(): StoreBuilder<T> {
|
||||||
return this;
|
return this;
|
||||||
},
|
},
|
||||||
build() {
|
build() {
|
||||||
console.log(_data.key);
|
|
||||||
assertStore(_data);
|
assertStore(_data);
|
||||||
const storageObject = buildStorageObject<T>(_data);
|
const storageObject = buildStorageObject<T>(_data);
|
||||||
stores[_data.key ?? ""] = [storageObject, _data];
|
stores[_data.key ?? ""] = [storageObject, _data];
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { create } from "zustand";
|
||||||
import { persist } from "zustand/middleware";
|
import { persist } from "zustand/middleware";
|
||||||
import { immer } from "zustand/middleware/immer";
|
import { immer } from "zustand/middleware/immer";
|
||||||
|
|
||||||
interface Account {
|
export interface Account {
|
||||||
profile: {
|
profile: {
|
||||||
colorA: string;
|
colorA: string;
|
||||||
colorB: string;
|
colorB: string;
|
||||||
|
@ -10,7 +10,7 @@ interface Account {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type AccountWithToken = Account & {
|
export type AccountWithToken = Account & {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
token: string;
|
token: string;
|
||||||
|
|
|
@ -12,16 +12,17 @@ export interface BookmarkMediaItem {
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProgressStore {
|
export interface BookmarkStore {
|
||||||
bookmarks: Record<string, BookmarkMediaItem>;
|
bookmarks: Record<string, BookmarkMediaItem>;
|
||||||
addBookmark(meta: PlayerMeta): void;
|
addBookmark(meta: PlayerMeta): void;
|
||||||
removeBookmark(id: string): void;
|
removeBookmark(id: string): void;
|
||||||
replaceBookmarks(items: Record<string, BookmarkMediaItem>): void;
|
replaceBookmarks(items: Record<string, BookmarkMediaItem>): void;
|
||||||
|
clear(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useBookmarkStore = create(
|
export const useBookmarkStore = create(
|
||||||
persist(
|
persist(
|
||||||
immer<ProgressStore>((set) => ({
|
immer<BookmarkStore>((set) => ({
|
||||||
bookmarks: {},
|
bookmarks: {},
|
||||||
removeBookmark(id) {
|
removeBookmark(id) {
|
||||||
set((s) => {
|
set((s) => {
|
||||||
|
@ -44,6 +45,9 @@ export const useBookmarkStore = create(
|
||||||
s.bookmarks = items;
|
s.bookmarks = items;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
clear() {
|
||||||
|
this.replaceBookmarks({});
|
||||||
|
},
|
||||||
})),
|
})),
|
||||||
{
|
{
|
||||||
name: "__MW::bookmarks",
|
name: "__MW::bookmarks",
|
||||||
|
|
|
@ -45,6 +45,7 @@ export interface ProgressStore {
|
||||||
updateItem(ops: UpdateItemOptions): void;
|
updateItem(ops: UpdateItemOptions): void;
|
||||||
removeItem(id: string): void;
|
removeItem(id: string): void;
|
||||||
replaceItems(items: Record<string, ProgressMediaItem>): void;
|
replaceItems(items: Record<string, ProgressMediaItem>): void;
|
||||||
|
clear(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useProgressStore = create(
|
export const useProgressStore = create(
|
||||||
|
@ -111,6 +112,9 @@ export const useProgressStore = create(
|
||||||
item.episodes[meta.episode.tmdbId].progress = { ...progress };
|
item.episodes[meta.episode.tmdbId].progress = { ...progress };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
clear() {
|
||||||
|
this.replaceItems({});
|
||||||
|
},
|
||||||
})),
|
})),
|
||||||
{
|
{
|
||||||
name: "__MW::progress",
|
name: "__MW::progress",
|
||||||
|
|
Loading…
Reference in a new issue