diff --git a/src/backend/accounts/sessions.ts b/src/backend/accounts/sessions.ts index 745dc35f..b2adc3d8 100644 --- a/src/backend/accounts/sessions.ts +++ b/src/backend/accounts/sessions.ts @@ -12,6 +12,10 @@ export interface SessionResponse { userAgent: string; } +export interface SessionUpdate { + deviceName: string; +} + export async function getSessions(url: string, account: AccountWithToken) { return ofetch(`/users/${account.userId}/sessions`, { headers: getAuthHeaders(account.token), @@ -19,6 +23,19 @@ export async function getSessions(url: string, account: AccountWithToken) { }); } +export async function updateSession( + url: string, + account: AccountWithToken, + update: SessionUpdate +) { + return ofetch(`/sessions/${account.sessionId}`, { + method: "PATCH", + headers: getAuthHeaders(account.token), + body: update, + baseURL: url, + }); +} + export async function removeSession( url: string, token: string, diff --git a/src/backend/accounts/settings.ts b/src/backend/accounts/settings.ts index 09405a40..8205b931 100644 --- a/src/backend/accounts/settings.ts +++ b/src/backend/accounts/settings.ts @@ -5,7 +5,7 @@ import { AccountWithToken } from "@/stores/auth"; export interface SettingsInput { applicationLanguage?: string; - applicationTheme?: string; + applicationTheme?: string | null; defaultSubtitleLanguage?: string; } diff --git a/src/backend/accounts/user.ts b/src/backend/accounts/user.ts index 5bde3d4e..b13aba9f 100644 --- a/src/backend/accounts/user.ts +++ b/src/backend/accounts/user.ts @@ -18,6 +18,14 @@ export interface UserResponse { }; } +export interface UserEdit { + profile?: { + colorA: string; + colorB: string; + icon: string; + }; +} + export interface BookmarkResponse { tmdbId: string; meta: { @@ -122,6 +130,22 @@ export async function getUser( ); } +export async function editUser( + url: string, + account: AccountWithToken, + object: UserEdit +): Promise<{ user: UserResponse; session: SessionResponse }> { + return ofetch<{ user: UserResponse; session: SessionResponse }>( + `/users/${account.userId}`, + { + method: "PATCH", + headers: getAuthHeaders(account.token), + body: object, + baseURL: url, + } + ); +} + export async function deleteUser( url: string, account: AccountWithToken diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx index 2e6cd99e..0ca96e9a 100644 --- a/src/components/Avatar.tsx +++ b/src/components/Avatar.tsx @@ -9,20 +9,31 @@ export interface AvatarProps { profile: AccountProfile["profile"]; sizeClass?: string; iconClass?: string; + bottom?: React.ReactNode; } export function Avatar(props: AvatarProps) { return ( -
- +
+
+ +
+ {props.bottom ? ( +
+ {props.bottom} +
+ ) : null}
); } @@ -35,18 +46,12 @@ export function UserAvatar(props: { const auth = useAuthStore(); if (!auth.account) return null; return ( -
- - {props.bottom ? ( -
- {props.bottom} -
- ) : null} -
+ ); } diff --git a/src/components/PassphraseDisplay.tsx b/src/components/PassphraseDisplay.tsx index 9db42b63..88338343 100644 --- a/src/components/PassphraseDisplay.tsx +++ b/src/components/PassphraseDisplay.tsx @@ -3,7 +3,7 @@ import { useCopyToClipboard, useMountedState } from "react-use"; import { Icon, Icons } from "./Icon"; -export function PassphaseDisplay(props: { mnemonic: string }) { +export function PassphraseDisplay(props: { mnemonic: string }) { const individualWords = props.mnemonic.split(" "); const [, copy] = useCopyToClipboard(); @@ -23,7 +23,7 @@ export function PassphaseDisplay(props: { mnemonic: string }) { return (
-

Passphase

+

Passphrase

- {individualWords.map((word) => ( + {individualWords.map((word, i) => (
{word}
diff --git a/src/hooks/useSettingsState.ts b/src/hooks/useSettingsState.ts index 4e34b8bd..4f755205 100644 --- a/src/hooks/useSettingsState.ts +++ b/src/hooks/useSettingsState.ts @@ -1,11 +1,18 @@ import isEqual from "lodash.isequal"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { + Dispatch, + SetStateAction, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; import { SubtitleStyling } from "@/stores/subtitles"; export function useDerived( initial: T -): [T, (v: T) => void, () => void, boolean] { +): [T, Dispatch>, () => void, boolean] { const [overwrite, setOverwrite] = useState(undefined); useEffect(() => { setOverwrite(undefined); @@ -14,19 +21,39 @@ export function useDerived( () => !isEqual(overwrite, initial) && overwrite !== undefined, [overwrite, initial] ); + const setter = useCallback>>( + (inp) => { + if (!(inp instanceof Function)) setOverwrite(inp); + else setOverwrite((s) => inp(s ?? initial)); + }, + [initial, setOverwrite] + ); const data = overwrite === undefined ? initial : overwrite; const reset = useCallback(() => setOverwrite(undefined), [setOverwrite]); - return [data, setOverwrite, reset, changed]; + return [data, setter, reset, changed]; } export function useSettingsState( theme: string | null, appLanguage: string, subtitleStyling: SubtitleStyling, - deviceName?: string + deviceName: string, + proxyUrls: string[] | null, + backendUrl: string | null, + profile: + | { + colorA: string; + colorB: string; + icon: string; + } + | undefined ) { + const [proxyUrlsState, setProxyUrls, resetProxyUrls, proxyUrlsChanged] = + useDerived(proxyUrls); + const [backendUrlState, setBackendUrl, resetBackendUrl, backendUrlChanged] = + useDerived(backendUrl); const [themeState, setTheme, resetTheme, themeChanged] = useDerived(theme); const [ appLanguageState, @@ -42,22 +69,27 @@ export function useSettingsState( resetDeviceName, deviceNameChanged, ] = useDerived(deviceName); + const [profileState, setProfileState, resetProfile, profileChanged] = + useDerived(profile); function reset() { resetTheme(); resetAppLanguage(); resetSubStyling(); + resetProxyUrls(); + resetBackendUrl(); resetDeviceName(); + resetProfile(); } - const changed = useMemo( - () => - themeChanged || - appLanguageChanged || - subStylingChanged || - deviceNameChanged, - [themeChanged, appLanguageChanged, subStylingChanged, deviceNameChanged] - ); + const changed = + themeChanged || + appLanguageChanged || + subStylingChanged || + deviceNameChanged || + backendUrlChanged || + proxyUrlsChanged || + profileChanged; return { reset, @@ -65,18 +97,37 @@ export function useSettingsState( theme: { state: themeState, set: setTheme, + changed: themeChanged, }, appLanguage: { state: appLanguageState, set: setAppLanguage, + changed: appLanguageChanged, }, subtitleStyling: { state: subStylingState, set: setSubStyling, + changed: subStylingChanged, }, deviceName: { state: deviceNameState, set: setDeviceNameState, + changed: deviceNameChanged, + }, + proxyUrls: { + state: proxyUrlsState, + set: setProxyUrls, + changed: proxyUrlsChanged, + }, + backendUrl: { + state: backendUrlState, + set: setBackendUrl, + changed: backendUrlChanged, + }, + profile: { + state: profileState, + set: setProfileState, + changed: profileChanged, }, }; } diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 8341715e..f1c832a3 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -2,12 +2,19 @@ import classNames from "classnames"; import { useCallback, useEffect, useMemo } from "react"; import { useAsyncFn } from "react-use"; -import { base64ToBuffer, decryptData } from "@/backend/accounts/crypto"; -import { getSessions } from "@/backend/accounts/sessions"; +import { + base64ToBuffer, + decryptData, + encryptData, +} from "@/backend/accounts/crypto"; +import { getSessions, updateSession } from "@/backend/accounts/sessions"; import { updateSettings } from "@/backend/accounts/settings"; +import { editUser } from "@/backend/accounts/user"; import { Button } from "@/components/Button"; import { WideContainer } from "@/components/layout/WideContainer"; +import { UserIcons } from "@/components/UserIcon"; import { Heading1 } from "@/components/utils/Text"; +import { useAuth } from "@/hooks/auth/useAuth"; import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; import { useIsMobile } from "@/hooks/useIsMobile"; import { useSettingsState } from "@/hooks/useSettingsState"; @@ -45,7 +52,17 @@ function SettingsLayout(props: { children: React.ReactNode }) { ); } -export function AccountSettings(props: { account: AccountWithToken }) { +export function AccountSettings(props: { + account: AccountWithToken; + deviceName: string; + setDeviceName: (s: string) => void; + colorA: string; + setColorA: (s: string) => void; + colorB: string; + setColorB: (s: string) => void; + userIcon: UserIcons; + setUserIcon: (s: UserIcons) => void; +}) { const url = useBackendUrl(); const { account } = props; const [sessionsResult, execSessions] = useAsyncFn(() => { @@ -57,7 +74,16 @@ export function AccountSettings(props: { account: AccountWithToken }) { return ( <> - + s.styling); const setSubStyling = useSubtitleStore((s) => s.updateStyling); + const proxySet = useAuthStore((s) => s.proxySet); + const setProxySet = useAuthStore((s) => s.setProxySet); + + const backendUrlSetting = useAuthStore((s) => s.backendUrl); + const setBackendUrl = useAuthStore((s) => s.setBackendUrl); + const account = useAuthStore((s) => s.account); + const updateProfile = useAuthStore((s) => s.setAccountProfile); + const updateDeviceName = useAuthStore((s) => s.updateDeviceName); const decryptedName = useMemo(() => { if (!account) return ""; return decryptData(account.deviceName, base64ToBuffer(account.seed)); @@ -87,29 +121,71 @@ export function SettingsPage() { const backendUrl = useBackendUrl(); + const { logout } = useAuth(); const user = useAuthStore(); const state = useSettingsState( activeTheme, appLanguage, subStyling, - decryptedName + decryptedName, + proxySet, + backendUrlSetting, + account?.profile ); const saveChanges = useCallback(async () => { - console.log(state); - if (account) { - await updateSettings(backendUrl, account, { - applicationLanguage: state.appLanguage.state, - applicationTheme: state.theme.state ?? undefined, - }); + if (state.appLanguage.changed || state.theme.changed) { + await updateSettings(backendUrl, account, { + applicationLanguage: state.appLanguage.state, + applicationTheme: state.theme.state, + }); + } + if (state.deviceName.changed) { + const newDeviceName = await encryptData( + state.deviceName.state, + base64ToBuffer(account.seed) + ); + await updateSession(backendUrl, account, { + deviceName: newDeviceName, + }); + updateDeviceName(newDeviceName); + } + if (state.profile.changed) { + await editUser(backendUrl, account, { + profile: state.profile.state, + }); + } } setAppLanguage(state.appLanguage.state); setTheme(state.theme.state); setSubStyling(state.subtitleStyling.state); - }, [state, account, backendUrl, setAppLanguage, setTheme, setSubStyling]); + setProxySet(state.proxyUrls.state); + + if (state.profile.state) { + updateProfile(state.profile.state); + } + + // when backend url gets changed, log the user out first + if (state.backendUrl.changed) { + await logout(); + setBackendUrl(state.backendUrl.state); + } + }, [ + state, + account, + backendUrl, + setAppLanguage, + setTheme, + setSubStyling, + updateDeviceName, + updateProfile, + setProxySet, + setBackendUrl, + logout, + ]); return ( @@ -117,8 +193,24 @@ export function SettingsPage() { Account - {user.account ? ( - + {user.account && state.profile.state ? ( + { + state.profile.set((s) => (s ? { ...s, colorA: v } : undefined)); + }} + colorB={state.profile.state.colorB} + setColorB={(v) => + state.profile.set((s) => (s ? { ...s, colorB: v } : undefined)) + } + userIcon={state.profile.state.icon as any} + setUserIcon={(v) => + state.profile.set((s) => (s ? { ...s, icon: v } : undefined)) + } + /> ) : ( )} @@ -139,7 +231,12 @@ export function SettingsPage() { />
- +
void; @@ -23,7 +23,7 @@ export function PassphraseGeneratePart(props: PassphraseGeneratePartProps) { If you lose this, you're a silly goose and will be posted on the wall of shame™️ - + diff --git a/src/pages/parts/settings/ConnectionsPart.tsx b/src/pages/parts/settings/ConnectionsPart.tsx index a71f81cb..539a6d3d 100644 --- a/src/pages/parts/settings/ConnectionsPart.tsx +++ b/src/pages/parts/settings/ConnectionsPart.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { Dispatch, SetStateAction, useCallback } from "react"; import { Button } from "@/components/Button"; import { Toggle } from "@/components/buttons/Toggle"; @@ -8,45 +8,38 @@ import { AuthInputBox } from "@/components/text-inputs/AuthInputBox"; import { Divider } from "@/components/utils/Divider"; import { Heading1 } from "@/components/utils/Text"; -let idNum = 0; - -interface ProxyItem { - url: string; - id: number; +interface ProxyEditProps { + proxyUrls: string[] | null; + setProxyUrls: Dispatch>; } -function ProxyEdit() { - const [customWorkers, setCustomWorkers] = useState(null); +interface BackendEditProps { + backendUrl: string | null; + setBackendUrl: Dispatch>; +} +function ProxyEdit({ proxyUrls, setProxyUrls }: ProxyEditProps) { const add = useCallback(() => { - idNum += 1; - setCustomWorkers((s) => [ - ...(s ?? []), - { - id: idNum, - url: "", - }, - ]); - }, [setCustomWorkers]); + setProxyUrls((s) => [...(s ?? []), ""]); + }, [setProxyUrls]); const changeItem = useCallback( - (id: number, val: string) => { - setCustomWorkers((s) => [ - ...(s ?? []).map((v) => { - if (v.id !== id) return v; - v.url = val; - return v; + (index: number, val: string) => { + setProxyUrls((s) => [ + ...(s ?? []).map((v, i) => { + if (i !== index) return v; + return val; }), ]); }, - [setCustomWorkers] + [setProxyUrls] ); const removeItem = useCallback( - (id: number) => { - setCustomWorkers((s) => [...(s ?? []).filter((v) => v.id !== id)]); + (index: number) => { + setProxyUrls((s) => [...(s ?? []).filter((v, i) => i !== index)]); }, - [setCustomWorkers] + [setProxyUrls] ); return ( @@ -61,30 +54,35 @@ function ProxyEdit() {
setCustomWorkers((s) => (s === null ? [] : null))} - enabled={customWorkers !== null} + onClick={() => setProxyUrls((s) => (s === null ? [] : null))} + enabled={proxyUrls !== null} />
- {customWorkers !== null ? ( + {proxyUrls !== null ? ( <>

Worker URLs

- {(customWorkers?.length ?? 0) === 0 ? ( + {(proxyUrls?.length ?? 0) === 0 ? (

No workers yet, add one below

) : null} - {(customWorkers ?? []).map((v) => ( -
+ {(proxyUrls ?? []).map((v, i) => ( +
changeItem(v.id, val)} + value={v} + onChange={(val) => changeItem(i, val)} placeholder="https://" />
- {customBackendUrl !== null ? ( + {backendUrl !== null ? ( <>

Custom server URL

- + ) : null} ); } -export function ConnectionsPart() { +export function ConnectionsPart(props: BackendEditProps & ProxyEditProps) { return (
Connections
- - + +
); diff --git a/src/pages/parts/settings/ProfileEditModal.tsx b/src/pages/parts/settings/ProfileEditModal.tsx index 0170ead7..d2667c25 100644 --- a/src/pages/parts/settings/ProfileEditModal.tsx +++ b/src/pages/parts/settings/ProfileEditModal.tsx @@ -1,5 +1,3 @@ -import { useState } from "react"; - import { Button } from "@/components/Button"; import { ColorPicker } from "@/components/form/ColorPicker"; import { IconPicker } from "@/components/form/IconPicker"; @@ -10,28 +8,34 @@ import { Heading2 } from "@/components/utils/Text"; export interface ProfileEditModalProps { id: string; close?: () => void; + colorA: string; + setColorA: (s: string) => void; + colorB: string; + setColorB: (s: string) => void; + userIcon: UserIcons; + setUserIcon: (s: UserIcons) => void; } export function ProfileEditModal(props: ProfileEditModalProps) { - const [colorA, setColorA] = useState("#2E65CF"); - const [colorB, setColorB] = useState("#2E65CF"); - const [userIcon, setUserIcon] = useState(UserIcons.USER); - return ( Edit profile picture
- +
diff --git a/src/stores/auth/index.ts b/src/stores/auth/index.ts index aa908ac0..e2f18172 100644 --- a/src/stores/auth/index.ts +++ b/src/stores/auth/index.ts @@ -26,7 +26,9 @@ interface AuthStore { setAccount(acc: AccountWithToken): void; updateDeviceName(deviceName: string): void; updateAccount(acc: Account): void; + setAccountProfile(acc: Account["profile"]): void; setBackendUrl(url: null | string): void; + setProxySet(urls: null | string[]): void; } export const useAuthStore = create( @@ -50,6 +52,18 @@ export const useAuthStore = create( s.backendUrl = v; }); }, + setProxySet(urls) { + set((s) => { + s.proxySet = urls; + }); + }, + setAccountProfile(profile) { + set((s) => { + if (s.account) { + s.account.profile = profile; + } + }); + }, updateAccount(acc) { set((s) => { if (!s.account) return;