22 changed files with 565 additions and 148 deletions
@ -0,0 +1,48 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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