22 changed files with 565 additions and 148 deletions
@ -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, |
||||||
|
}); |
||||||
|
} |
@ -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, |
||||||
|
}); |
||||||
|
} |
@ -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} />; |
||||||
|
} |
@ -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, |
||||||
|
}; |
||||||
|
} |
@ -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, |
||||||
|
}; |
||||||
|
} |
@ -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; |
||||||
|
} |
@ -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, |
|
||||||
}; |
|
||||||
} |
|
@ -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> |
||||||
|
); |
||||||
|
} |
@ -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> |
||||||
|
); |
||||||
|
} |
Loading…
Reference in new issue