From a89ef8a901883b504ea92694100bea82b3186d5a Mon Sep 17 00:00:00 2001 From: Jorrin Date: Sun, 21 Apr 2024 16:42:07 +0200 Subject: [PATCH] add login, register, logout and devices list (including remove device) --- apps/expo/src/app/(tabs)/movie-web.tsx | 60 +------ apps/expo/src/app/sync/login.tsx | 51 +++++- apps/expo/src/app/sync/register/account.tsx | 166 +++++------------- apps/expo/src/app/sync/register/confirm.tsx | 55 ++++-- apps/expo/src/app/sync/register/index.tsx | 14 +- .../trust/{[url].tsx => [backendUrl].tsx} | 18 +- .../components/account/AccountInformation.tsx | 142 +++++++++++++++ apps/expo/src/components/account/Avatar.tsx | 38 ++++ .../src/components/account/ColorPicker.tsx | 39 ++++ .../src/components/account/GetStarted.tsx | 60 +++++++ .../src/components/account/UserIconPicker.tsx | 53 ++++++ apps/expo/src/components/ui/Button.tsx | 33 ++++ apps/expo/src/hooks/useAuth.ts | 13 +- packages/api/src/fetch.ts | 5 +- 14 files changed, 546 insertions(+), 201 deletions(-) rename apps/expo/src/app/sync/trust/{[url].tsx => [backendUrl].tsx} (83%) create mode 100644 apps/expo/src/components/account/AccountInformation.tsx create mode 100644 apps/expo/src/components/account/Avatar.tsx create mode 100644 apps/expo/src/components/account/ColorPicker.tsx create mode 100644 apps/expo/src/components/account/GetStarted.tsx create mode 100644 apps/expo/src/components/account/UserIconPicker.tsx diff --git a/apps/expo/src/app/(tabs)/movie-web.tsx b/apps/expo/src/app/(tabs)/movie-web.tsx index 1d20c47..a73a7c1 100644 --- a/apps/expo/src/app/(tabs)/movie-web.tsx +++ b/apps/expo/src/app/(tabs)/movie-web.tsx @@ -1,60 +1,10 @@ -import { Link } from "expo-router"; -import { H3, H5, Paragraph, View } from "tamagui"; - -import ScreenLayout from "~/components/layout/ScreenLayout"; -import { MWButton } from "~/components/ui/Button"; -import { MWCard } from "~/components/ui/Card"; -import { MWInput } from "~/components/ui/Input"; +import { AccountInformation } from "~/components/account/AccountInformation"; +import { AccountGetStarted } from "~/components/account/GetStarted"; import { useAuthStore } from "~/stores/settings"; export default function MovieWebScreen() { - const { backendUrl, setBackendUrl } = useAuthStore(); + const account = useAuthStore((state) => state.account); - return ( - - - -

- Sync to the cloud -

-
- Share your watch progress between devices and keep them synced. -
- - First choose the backend you want to use. If you do not know what - this does, use the default and click on 'Get started'. - -
- - - - - - - - - Get started - - - -
-
- ); + if (account) return ; + return ; } diff --git a/apps/expo/src/app/sync/login.tsx b/apps/expo/src/app/sync/login.tsx index 603faab..4381803 100644 --- a/apps/expo/src/app/sync/login.tsx +++ b/apps/expo/src/app/sync/login.tsx @@ -1,12 +1,40 @@ -import { Stack } from "expo-router"; +import { useState } from "react"; +import { Link, Stack, useRouter } from "expo-router"; +import { useMutation } from "@tanstack/react-query"; import { H4, Label, Paragraph, Text, YStack } from "tamagui"; import ScreenLayout from "~/components/layout/ScreenLayout"; import { MWButton } from "~/components/ui/Button"; import { MWCard } from "~/components/ui/Card"; import { MWInput } from "~/components/ui/Input"; +import { useAuth } from "~/hooks/useAuth"; +import { useAuthStore } from "~/stores/settings"; export default function Page() { + const backendUrl = useAuthStore((state) => state.backendUrl); + const router = useRouter(); + const { login } = useAuth(); + + const [passphrase, setPassphrase] = useState(""); + const [deviceName, setDeviceName] = useState(""); + + const mutation = useMutation({ + mutationKey: ["login", backendUrl, passphrase, deviceName], + mutationFn: () => + login({ + mnemonic: passphrase, + userData: { + device: deviceName, + }, + }), + onSuccess: (data) => { + if (data) { + return router.push("/(tabs)/movie-web"); + } + return null; + }, + }); + return ( @@ -54,6 +84,8 @@ export default function Page() { type="authentication" placeholder="Personal phone" autoCorrect={false} + value={deviceName} + onChangeText={setDeviceName} /> @@ -64,13 +96,22 @@ export default function Page() { flexDirection="column" gap="$4" > - Login + mutation.mutate()}> + Login + + {mutation.isError && ( + + {mutation.error.message} + + )} Don't have an account yet?{"\n"} - - Create an account. - + + + Create an account. + + diff --git a/apps/expo/src/app/sync/register/account.tsx b/apps/expo/src/app/sync/register/account.tsx index 162a69b..b4dd052 100644 --- a/apps/expo/src/app/sync/register/account.tsx +++ b/apps/expo/src/app/sync/register/account.tsx @@ -1,115 +1,43 @@ import { useState } from "react"; -import { Link, Stack } from "expo-router"; -import { FontAwesome6, Ionicons } from "@expo/vector-icons"; -import { Circle, H4, Label, Paragraph, View, XStack, YStack } from "tamagui"; -import { LinearGradient } from "tamagui/linear-gradient"; +import { Stack, useRouter } from "expo-router"; +import { H4, Label, Paragraph, View, YStack } from "tamagui"; +import { Avatar } from "~/components/account/Avatar"; +import { ColorPicker, colors } from "~/components/account/ColorPicker"; +import { + expoIcons, + expoIconsToDbIcons, + UserIconPicker, +} from "~/components/account/UserIconPicker"; import ScreenLayout from "~/components/layout/ScreenLayout"; import { MWButton } from "~/components/ui/Button"; import { MWCard } from "~/components/ui/Card"; import { MWInput } from "~/components/ui/Input"; -const colors = ["#0A54FF", "#CF2E68", "#F9DD7F", "#7652DD", "#2ECFA8"] as const; - -function ColorPicker(props: { - value: (typeof colors)[number]; - onInput: (v: (typeof colors)[number]) => void; -}) { - return ( - - {colors.map((color) => { - return ( - props.onInput(color)} - flexGrow={1} - height="$4" - borderRadius="$4" - justifyContent="center" - alignItems="center" - backgroundColor={color} - key={color} - > - {props.value === color ? ( - - ) : null} - - ); - })} - - ); -} - -const icons = [ - "user-group", - "couch", - "mobile-screen", - "ticket", - "handcuffs", -] as const; - -function UserIconPicker(props: { - value: (typeof icons)[number]; - onInput: (v: (typeof icons)[number]) => void; -}) { - return ( - - {icons.map((icon) => { - return ( - props.onInput(icon)} - > - - - ); - })} - - ); -} - -interface AvatarProps { - colorA: string; - colorB: string; - icon: (typeof icons)[number]; -} - -export function Avatar(props: AvatarProps) { - return ( - - - - - - ); -} - export default function Page() { - const [color, setColor] = useState<(typeof colors)[number]>(colors[0]); - const [color2, setColor2] = useState<(typeof colors)[number]>(colors[0]); - const [icon, setIcon] = useState<(typeof icons)[number]>(icons[0]); + const router = useRouter(); + + const [deviceName, setDeviceName] = useState(""); + const [errorMessage, setErrorMessage] = useState(null); + const [colorA, setColorA] = useState<(typeof colors)[number]>(colors[0]); + const [colorB, setColorB] = useState<(typeof colors)[number]>(colors[0]); + const [icon, setIcon] = useState<(typeof expoIcons)[number]>(expoIcons[0]); + + const handleNext = () => { + if (!deviceName) { + setErrorMessage("Please enter a device name"); + return; + } + return router.push({ + pathname: "/sync/register/confirm", + params: { + deviceName, + colorA, + colorB, + icon: expoIconsToDbIcons[icon], + }, + }); + }; return ( - +

@@ -152,18 +80,19 @@ export default function Page() { - setColor(color)} /> + setColorA(color)} /> - setColor2(color)} /> + setColorB(color)} /> @@ -172,17 +101,14 @@ export default function Page() { - - - Next - - + {errorMessage && ( + + {errorMessage} + + )} + + Next + diff --git a/apps/expo/src/app/sync/register/confirm.tsx b/apps/expo/src/app/sync/register/confirm.tsx index 517de54..5e38b26 100644 --- a/apps/expo/src/app/sync/register/confirm.tsx +++ b/apps/expo/src/app/sync/register/confirm.tsx @@ -1,12 +1,45 @@ -import { Link, Stack } from "expo-router"; +import { useState } from "react"; +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import { useMutation } from "@tanstack/react-query"; import { H4, Label, Paragraph, YStack } from "tamagui"; import ScreenLayout from "~/components/layout/ScreenLayout"; import { MWButton } from "~/components/ui/Button"; import { MWCard } from "~/components/ui/Card"; import { MWInput } from "~/components/ui/Input"; +import { useAuth } from "~/hooks/useAuth"; export default function Page() { + const router = useRouter(); + const { deviceName, colorA, colorB, icon } = useLocalSearchParams<{ + deviceName: string; + colorA: string; + colorB: string; + icon: string; + }>(); + const { register } = useAuth(); + + const [passphrase, setPassphrase] = useState(""); + + const mutation = useMutation({ + mutationKey: ["register", deviceName, colorA, colorB, icon], + mutationFn: () => + register({ + // TODO: "Add recaptchaToken", + mnemonic: passphrase, + userData: { + device: deviceName, + profile: { colorA, colorB, icon }, + }, + }), + onSuccess: (data) => { + if (data) { + return router.push("/(tabs)/movie-web"); + } + return null; + }, + }); + return ( - - Create account - + {mutation.isError && ( + + {mutation.error.message} + + )} + + mutation.mutate()}> + Create account + diff --git a/apps/expo/src/app/sync/register/index.tsx b/apps/expo/src/app/sync/register/index.tsx index 65ca629..d1ceb0a 100644 --- a/apps/expo/src/app/sync/register/index.tsx +++ b/apps/expo/src/app/sync/register/index.tsx @@ -28,7 +28,7 @@ function PassphraseWord({ word }: { word: string }) { export default function Page() { const theme = useTheme(); - const words = genMnemonic().split(" "); + const words = genMnemonic(); return ( { - await Clipboard.setStringAsync(words.join("")); + await Clipboard.setStringAsync(words); }} > @@ -103,7 +103,7 @@ export default function Page() { justifyContent="center" padding="$3" > - {words.map((word, index) => ( + {words.split(" ").map((word, index) => ( ))} @@ -122,7 +122,13 @@ export default function Page() { Already have an account?{"\n"} - Login here + + Login here + diff --git a/apps/expo/src/app/sync/trust/[url].tsx b/apps/expo/src/app/sync/trust/[backendUrl].tsx similarity index 83% rename from apps/expo/src/app/sync/trust/[url].tsx rename to apps/expo/src/app/sync/trust/[backendUrl].tsx index 828afb2..42eeadf 100644 --- a/apps/expo/src/app/sync/trust/[url].tsx +++ b/apps/expo/src/app/sync/trust/[backendUrl].tsx @@ -7,13 +7,16 @@ import { getBackendMeta } from "@movie-web/api"; import ScreenLayout from "~/components/layout/ScreenLayout"; import { MWButton } from "~/components/ui/Button"; import { MWCard } from "~/components/ui/Card"; +import { useAuthStore } from "~/stores/settings"; export default function Page() { - const { url } = useLocalSearchParams<{ url: string }>(); + const { backendUrl } = useLocalSearchParams<{ backendUrl: string }>(); + + const setBackendUrl = useAuthStore((state) => state.setBackendUrl); const meta = useQuery({ - queryKey: ["backendMeta", url], - queryFn: () => getBackendMeta(url as unknown as string), + queryKey: ["backendMeta", backendUrl], + queryFn: () => getBackendMeta(backendUrl as unknown as string), }); return ( @@ -52,7 +55,7 @@ export default function Page() { color="white" textDecorationLine="underline" > - {url} + {backendUrl} . Please confirm you trust it before making an account. @@ -95,8 +98,13 @@ export default function Page() { pathname: "/sync/register", }} asChild + onPress={() => { + setBackendUrl(backendUrl); + }} > - I trust this server + + I trust this server + state.account); + const backendUrl = useAuthStore((state) => state.backendUrl); + const queryClient = useQueryClient(); + + const { decryptedName, logout } = useAuth(); + + const logoutMutation = useMutation({ + mutationKey: ["logout"], + mutationFn: logout, + }); + + const removeSessionMutation = useMutation({ + mutationKey: ["removeSession"], + mutationFn: (sessionId: string) => + removeSession(backendUrl, account!.token, sessionId), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: ["sessions", backendUrl, account], + }); + }, + }); + + const sessions = useQuery({ + queryKey: ["sessions", backendUrl, account], + queryFn: () => getSessions(backendUrl, account!), + enabled: !!account, + }); + + const deviceListSorted = useMemo(() => { + let list = + sessions.data?.map((session) => { + const decryptedName = decryptData( + session.device, + base64ToBuffer(account!.seed), + ); + return { + current: session.id === account!.sessionId, + id: session.id, + name: decryptedName, + }; + }) ?? []; + list = list.sort((a, b) => { + if (a.current) return -1; + if (b.current) return 1; + return a.name.localeCompare(b.name); + }); + return list; + }, [sessions.data, account]); + + if (!account) return null; + + return ( + + + + + Account + + + + + + + + Device name + + + logoutMutation.mutate()} + > + Logout + + + + + + + + + Devices + + + {sessions.isLoading && } + {deviceListSorted.map((device) => ( + + + + + Device name + + {device.name} + + {!device.current && ( + removeSessionMutation.mutate(device.id)} + > + Remove + + )} + + + ))} + + + + ); +} diff --git a/apps/expo/src/components/account/Avatar.tsx b/apps/expo/src/components/account/Avatar.tsx new file mode 100644 index 0000000..248035d --- /dev/null +++ b/apps/expo/src/components/account/Avatar.tsx @@ -0,0 +1,38 @@ +import type { CircleProps } from "tamagui"; +import { FontAwesome6 } from "@expo/vector-icons"; +import { Circle } from "tamagui"; +import { LinearGradient } from "tamagui/linear-gradient"; + +import type { expoIcons } from "./UserIconPicker"; + +export interface AvatarProps { + colorA: string; + colorB: string; + icon: (typeof expoIcons)[number]; +} + +export function Avatar(props: AvatarProps & CircleProps) { + return ( + + + + + + ); +} diff --git a/apps/expo/src/components/account/ColorPicker.tsx b/apps/expo/src/components/account/ColorPicker.tsx new file mode 100644 index 0000000..d21aefa --- /dev/null +++ b/apps/expo/src/components/account/ColorPicker.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { Ionicons } from "@expo/vector-icons"; +import { View, XStack } from "tamagui"; + +export const colors = [ + "#0A54FF", + "#CF2E68", + "#F9DD7F", + "#7652DD", + "#2ECFA8", +] as const; + +export function ColorPicker(props: { + value: (typeof colors)[number]; + onInput: (v: (typeof colors)[number]) => void; +}) { + return ( + + {colors.map((color) => { + return ( + props.onInput(color)} + flexGrow={1} + height="$4" + borderRadius="$4" + justifyContent="center" + alignItems="center" + backgroundColor={color} + key={color} + > + {props.value === color ? ( + + ) : null} + + ); + })} + + ); +} diff --git a/apps/expo/src/components/account/GetStarted.tsx b/apps/expo/src/components/account/GetStarted.tsx new file mode 100644 index 0000000..eacacaf --- /dev/null +++ b/apps/expo/src/components/account/GetStarted.tsx @@ -0,0 +1,60 @@ +import { Link } from "expo-router"; +import { H3, H5, Paragraph, View } from "tamagui"; + +import { useAuthStore } from "~/stores/settings"; +import ScreenLayout from "../layout/ScreenLayout"; +import { MWButton } from "../ui/Button"; +import { MWCard } from "../ui/Card"; +import { MWInput } from "../ui/Input"; + +export function AccountGetStarted() { + const { backendUrl, setBackendUrl } = useAuthStore(); + + return ( + + + +

+ Sync to the cloud +

+
+ Share your watch progress between devices and keep them synced. +
+ + First choose the backend you want to use. If you do not know what + this does, use the default and click on 'Get started'. + +
+ + + + + + + + + Get started + + + +
+
+ ); +} diff --git a/apps/expo/src/components/account/UserIconPicker.tsx b/apps/expo/src/components/account/UserIconPicker.tsx new file mode 100644 index 0000000..644cd71 --- /dev/null +++ b/apps/expo/src/components/account/UserIconPicker.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import { FontAwesome6 } from "@expo/vector-icons"; +import { View, XStack } from "tamagui"; + +export const expoIcons = [ + "user-group", + "couch", + "mobile-screen", + "ticket", + "handcuffs", +] as const; + +export const expoIconsToDbIcons: Record<(typeof expoIcons)[number], string> = { + "user-group": "userGroup", + couch: "couch", + "mobile-screen": "mobile", + ticket: "ticket", + handcuffs: "handcuffs", +}; + +export const getExpoIconFromDbIcon = (icon: string) => { + return Object.keys(expoIconsToDbIcons).find( + (key) => expoIconsToDbIcons[key as (typeof expoIcons)[number]] === icon, + ) as (typeof expoIcons)[number]; +}; + +export function UserIconPicker(props: { + value: (typeof expoIcons)[number]; + onInput: (v: (typeof expoIcons)[number]) => void; +}) { + return ( + + {expoIcons.map((icon) => { + return ( + props.onInput(icon)} + > + + + ); + })} + + ); +} diff --git a/apps/expo/src/components/ui/Button.tsx b/apps/expo/src/components/ui/Button.tsx index c19f48c..9aa2cbf 100644 --- a/apps/expo/src/components/ui/Button.tsx +++ b/apps/expo/src/components/ui/Button.tsx @@ -10,6 +10,11 @@ export const MWButton = styled(Button, { pressStyle: { backgroundColor: "$silver100", }, + disabledStyle: { + backgroundColor: "$ash500", + color: "$ash200", + pointerEvents: "none", + }, }, secondary: { backgroundColor: "$ash700", @@ -18,6 +23,11 @@ export const MWButton = styled(Button, { pressStyle: { backgroundColor: "$ash500", }, + disabledStyle: { + backgroundColor: "$ash900", + color: "$ash200", + pointerEvents: "none", + }, }, purple: { backgroundColor: "$purple500", @@ -26,6 +36,11 @@ export const MWButton = styled(Button, { pressStyle: { backgroundColor: "$purple400", }, + disabledStyle: { + backgroundColor: "$purple700", + color: "$ash200", + pointerEvents: "none", + }, }, cancel: { backgroundColor: "$ash500", @@ -34,6 +49,24 @@ export const MWButton = styled(Button, { pressStyle: { backgroundColor: "$ash300", }, + disabledStyle: { + backgroundColor: "$ash700", + color: "$ash200", + pointerEvents: "none", + }, + }, + danger: { + backgroundColor: "$rose300", + color: "white", + fontWeight: "bold", + pressStyle: { + backgroundColor: "$rose200", + }, + disabledStyle: { + backgroundColor: "$rose500", + color: "$ash200", + pointerEvents: "none", + }, }, }, } as const, diff --git a/apps/expo/src/hooks/useAuth.ts b/apps/expo/src/hooks/useAuth.ts index 65e9cbe..099ad5a 100644 --- a/apps/expo/src/hooks/useAuth.ts +++ b/apps/expo/src/hooks/useAuth.ts @@ -1,4 +1,4 @@ -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; import type { AccountWithToken, @@ -8,9 +8,11 @@ import type { UserResponse, } from "@movie-web/api"; import { + base64ToBuffer, bookmarkMediaToInput, bytesToBase64, bytesToBase64Url, + decryptData, encryptData, getBookmarks, getLoginChallengeToken, @@ -192,9 +194,18 @@ export function useAuth() { [backendUrl, syncData, logout], ); + const decryptedName = useMemo(() => { + if (!currentAccount) return ""; + return decryptData( + currentAccount.deviceName, + base64ToBuffer(currentAccount.seed), + ); + }, [currentAccount]); + return { loggedIn, profile, + decryptedName, login, logout, register, diff --git a/packages/api/src/fetch.ts b/packages/api/src/fetch.ts index c0260b4..1e7805c 100644 --- a/packages/api/src/fetch.ts +++ b/packages/api/src/fetch.ts @@ -38,7 +38,10 @@ export async function f(url: string, ops?: FetcherOptions): Promise { const fullUrl = makeFullUrl(url, ops); const response = await fetch(fullUrl, { method: ops?.method ?? "GET", - headers: ops?.headers, + headers: { + "Content-Type": "application/json", + ...ops?.headers, + }, body: ops?.body ? JSON.stringify(ops.body) : undefined, });