From d3e61299aed34cf9280c87c41ed89144d7594594 Mon Sep 17 00:00:00 2001 From: Tomasz Chojnacki Date: Sun, 15 Oct 2023 22:58:52 +0200 Subject: [PATCH] PR: Add full identity handling to the frontend #32 (#33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Notes for reviewers:** The diff has definitely too many files, since I moved one folder 😠 The files, that were actually meaningfully changed: - `/common/identity/*.ts` - **entire new folder, main thing to review** - `/tests/common/identity.test.tsx` - **tests for new functionality** - `/app/auth/(login|register).tsx` - **communication with backend, redirects, error handling** - `/app/dashboard.tsx` - stub file for dashboard - `/app/profiles/list.tsx` - stub file for profile selection, to be merged with #34 - `/common/util/mock-router.tsx` - self-explainatory All of the other changes are just renames, import fixes, etc. **Notes for all contributors:** Familiarize yourself with the functions exported from `@/common/identity`, example usage is show on the `login` and `register` pages. --- .gitattributes | 2 +- .prettierignore | 2 +- app/_layout.tsx | 11 +- app/auth/login.tsx | 34 +++- app/auth/register.tsx | 42 ++++- app/dashboard.tsx | 23 +++ app/index.tsx | 15 +- app/profile/interfaces.ts | 7 - app/profile/list.tsx | 46 ++++-- assets/i18n.json | 10 +- {util => common}/api/client.ts | 0 {util => common}/api/consts.ts | 0 {util => common}/api/index.tsx | 1 - {util => common}/api/mocks.ts | 0 {util => common}/api/schema.d.ts | 71 ++++++--- {util => common}/api/swr.ts | 11 +- .../Colors.ts => common/constants/colors.ts | 2 +- common/constants/index.ts | 1 + {util => common}/i18n.tsx | 0 common/identity/context.tsx | 65 ++++++++ common/identity/guards.ts | 5 + common/identity/index.ts | 13 ++ common/identity/mocks.tsx | 45 ++++++ common/identity/redirects.tsx | 82 ++++++++++ common/identity/storage.ts | 19 +++ common/identity/types.ts | 61 +++++++ common/identity/util.ts | 35 ++++ .../util/font-loader.ts | 2 +- common/util/index.ts | 3 + common/util/mock-router.tsx | 22 +++ {util => common/util}/provider-list.tsx | 0 {util => common/util}/setup-tests.ts | 2 +- .../ThemeProvider.tsx | 0 components/Themed.tsx | 6 +- package-lock.json | 10 ++ package.json | 5 +- tests/{ => app}/index.test.tsx | 13 +- tests/common/api.test.ts | 61 +++++++ tests/{util => common}/i18n.test.tsx | 2 +- tests/common/identity.test.tsx | 149 ++++++++++++++++++ tests/util/api.test.ts | 77 --------- util/api/auth.tsx | 37 ----- 42 files changed, 777 insertions(+), 215 deletions(-) create mode 100644 app/dashboard.tsx delete mode 100644 app/profile/interfaces.ts rename {util => common}/api/client.ts (100%) rename {util => common}/api/consts.ts (100%) rename {util => common}/api/index.tsx (70%) rename {util => common}/api/mocks.ts (100%) rename {util => common}/api/schema.d.ts (59%) rename {util => common}/api/swr.ts (90%) rename constants/Colors.ts => common/constants/colors.ts (93%) create mode 100644 common/constants/index.ts rename {util => common}/i18n.tsx (100%) create mode 100644 common/identity/context.tsx create mode 100644 common/identity/guards.ts create mode 100644 common/identity/index.ts create mode 100644 common/identity/mocks.tsx create mode 100644 common/identity/redirects.tsx create mode 100644 common/identity/storage.ts create mode 100644 common/identity/types.ts create mode 100644 common/identity/util.ts rename util/use-font-loader.ts => common/util/font-loader.ts (86%) create mode 100644 common/util/index.ts create mode 100644 common/util/mock-router.tsx rename {util => common/util}/provider-list.tsx (100%) rename {util => common/util}/setup-tests.ts (89%) rename util/theme-provider.tsx => components/ThemeProvider.tsx (100%) rename tests/{ => app}/index.test.tsx (74%) create mode 100644 tests/common/api.test.ts rename tests/{util => common}/i18n.test.tsx (95%) create mode 100644 tests/common/identity.test.tsx delete mode 100644 tests/util/api.test.ts delete mode 100644 util/api/auth.tsx diff --git a/.gitattributes b/.gitattributes index 2965906..aef20c0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,2 @@ android/** linguist-generated=true -util/api/schema.d.ts linguist-generated=true +common/api/schema.d.ts linguist-generated=true diff --git a/.prettierignore b/.prettierignore index f7d228e..cbf4211 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,3 @@ *.md expo-env.d.ts -util/api/schema.d.ts +common/api/schema.d.ts diff --git a/app/_layout.tsx b/app/_layout.tsx index e0d64c8..5eaf5cb 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,11 +1,10 @@ import { SplashScreen, Stack } from "expo-router"; import { PaperProvider } from "react-native-paper"; -import { AuthProvider } from "@/util/api/auth"; -import { I18nProvider } from "@/util/i18n"; -import { ProviderList } from "@/util/provider-list"; -import { ThemeProvider } from "@/util/theme-provider"; -import { useFontLoader } from "@/util/use-font-loader"; +import { I18nProvider } from "@/common/i18n"; +import { IdentityProvider } from "@/common/identity"; +import { ProviderList, useFontLoader } from "@/common/util"; +import { ThemeProvider } from "@/components/ThemeProvider"; export { ErrorBoundary } from "expo-router"; @@ -19,7 +18,7 @@ export default function RootLayout() { return ( diff --git a/app/auth/login.tsx b/app/auth/login.tsx index 8d2f273..5afbd48 100644 --- a/app/auth/login.tsx +++ b/app/auth/login.tsx @@ -1,5 +1,6 @@ -import { router, Link, Stack } from "expo-router"; +import { Link, Stack } from "expo-router"; import { Formik } from "formik"; +import { useState } from "react"; import { Keyboard, StyleSheet, @@ -14,22 +15,35 @@ import { useTheme, } from "react-native-paper"; -import { useI18n } from "@/util/i18n"; +import { POST } from "@/common/api"; +import { useI18n } from "@/common/i18n"; +import { useIdentity, RedirectIfLoggedIn } from "@/common/identity"; const Page = () => { + const identity = useIdentity(); const theme = useTheme(); const { t } = useI18n(); - const handleFormSubmit = (values: { email: string; password: string }) => { - // TODO: Handle form submission, send data to backend API - console.log(values); - router.replace("/profile/list"); + const [status, setStatus] = useState<"idle" | "pending" | "error">("idle"); + + if (identity.isLoggedIn) { + return ; + } + + const onSubmit = async (body: { email: string; password: string }) => { + setStatus("pending"); + const res = await POST("/api/v1/token", { body }); + if (res.error) { + setStatus("error"); + } else { + identity.logIn(res.data.token); + } }; return ( { const errors: { email?: string; password?: string } = {}; if (!values.email) { @@ -90,7 +104,13 @@ const Page = () => { {errors.password} )} + {status === "error" ? ( + + {t("login.badCredentials")} + + ) : null} + + ); +}; + +export default Page; diff --git a/app/index.tsx b/app/index.tsx index 561b448..38a67bc 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -2,14 +2,17 @@ import { Link, Stack } from "expo-router"; import { StyleSheet, View } from "react-native"; import { ActivityIndicator, Button, Text } from "react-native-paper"; +import { BASE_URL, useApi } from "@/common/api"; +import { useI18n } from "@/common/i18n"; +import { useIdentity, RedirectIfLoggedIn } from "@/common/identity"; import { MonoText } from "@/components/StyledText"; -import { BASE_URL, useApi, useAuth } from "@/util/api"; -import { useI18n } from "@/util/i18n"; export const Landing = ({ debug = false }: { debug?: boolean }) => { - const { data, isLoading, error, mutate } = useApi({ url: "/healthz" }); - const { token } = useAuth(); + const { data, isLoading, error, mutate } = useApi({ + url: "/api/v1/healthz", + }); const { t } = useI18n(); + const identity = useIdentity(); if (isLoading) { return ; @@ -47,8 +50,8 @@ export const Landing = ({ debug = false }: { debug?: boolean }) => { ); } - if (token) { - // TODO: redirect if logged in + if (identity.isLoggedIn) { + return ; } return ( diff --git a/app/profile/interfaces.ts b/app/profile/interfaces.ts deleted file mode 100644 index 7f0348e..0000000 --- a/app/profile/interfaces.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type ProfileType = "caretaker" | "senior"; - -export interface IProfile { - type: ProfileType; - seniorId: string; - seniorAlias?: string; -} diff --git a/app/profile/list.tsx b/app/profile/list.tsx index 75ba6c9..561f118 100644 --- a/app/profile/list.tsx +++ b/app/profile/list.tsx @@ -1,27 +1,39 @@ -import { Link, Stack } from "expo-router"; +import { Link, Stack, router } from "expo-router"; import { ScrollView, StyleSheet, View } from "react-native"; import { Avatar, Button, List } from "react-native-paper"; -import { IProfile } from "./interfaces"; - -import { useI18n } from "@/util/i18n"; +import { useI18n } from "@/common/i18n"; +import { + Profile, + isSenior, + isCaretaker, + useIdentity, + RedirectIfLoggedOut, +} from "@/common/identity"; const mockApiResponse = { profiles: [ - { type: "caretaker", seniorId: "2137", seniorAlias: "Jan Kowalski" }, - { type: "caretaker", seniorId: "123", seniorAlias: "Grzegorz Floryda" }, - { type: "senior", seniorId: "789" }, - ] as IProfile[], + { type: "caretaker", seniorId: 2137, seniorAlias: "Jan Kowalski" }, + { type: "caretaker", seniorId: 123, seniorAlias: "Grzegorz Floryda" }, + { type: "senior", seniorId: 789 }, + ] as Profile[], }; const ProfilesList = () => { const { t } = useI18n(); + const identity = useIdentity(); + + if (!identity.isLoggedIn) { + return ; + } + const profiles = mockApiResponse.profiles; - const seniorProfile = profiles.find(({ type }) => type === "senior"); - const caretakerProfiles = profiles.filter(({ type }) => type === "caretaker"); + const seniorProfile = profiles.find(isSenior); + const caretakerProfiles = profiles.filter(isCaretaker); - const handleItemPress = (seniorId: string) => { - console.log(seniorId); // TODO: navigate to profile + const handleItemPress = (profile: Profile) => { + identity.selectProfile(profile); + router.push("/dashboard"); }; return ( @@ -34,13 +46,13 @@ const ProfilesList = () => { )} - {caretakerProfiles.map(({ seniorId, seniorAlias }) => { + {caretakerProfiles.map((p) => { return ( handleItemPress(seniorId)} + description={`Senior: ${p.seniorAlias}`} + onPress={() => handleItemPress(p)} style={styles.listItem} titleStyle={styles.listItemTitle} descriptionStyle={styles.listItemDescription} @@ -63,7 +75,7 @@ const ProfilesList = () => { handleItemPress(seniorProfile.seniorId)} + onPress={() => handleItemPress(seniorProfile)} style={styles.listItem} titleStyle={styles.listItemTitle} descriptionStyle={styles.listItemDescription} diff --git a/assets/i18n.json b/assets/i18n.json index cfbb35b..8285621 100644 --- a/assets/i18n.json +++ b/assets/i18n.json @@ -54,6 +54,10 @@ "en": "Register", "pl": "Zarejestruj się" }, + "login.badCredentials": { + "en": "The credentials are incorrect", + "pl": "Podane dane są nieprawidłowe" + }, "register.pageTitle": { "en": "Register", "pl": "Rejestracja" @@ -83,7 +87,7 @@ "pl": "Zaloguj się" }, "register.badPasswordLength": { - "en": "Password must have at least {length} letters", + "en": "Password must have at least {length} characters", "pl": "Hasło musi mieć co najmniej {length} znaków" }, "register.passwordMismatch": { @@ -94,6 +98,10 @@ "en": "Invalid phone number", "pl": "Błędny numer telefonu" }, + "register.serverError": { + "en": "Could not create the account", + "pl": "Nie udało się utworzyć konta" + }, "landing.pageTitle": { "en": "Start", "pl": "Start" diff --git a/util/api/client.ts b/common/api/client.ts similarity index 100% rename from util/api/client.ts rename to common/api/client.ts diff --git a/util/api/consts.ts b/common/api/consts.ts similarity index 100% rename from util/api/consts.ts rename to common/api/consts.ts diff --git a/util/api/index.tsx b/common/api/index.tsx similarity index 70% rename from util/api/index.tsx rename to common/api/index.tsx index e9b42d2..3d90d50 100644 --- a/util/api/index.tsx +++ b/common/api/index.tsx @@ -1,4 +1,3 @@ -export { useAuth, AuthProvider } from "./auth"; export { POST, PUT, DELETE } from "./client"; export { BASE_URL } from "./consts"; export { useApi } from "./swr"; diff --git a/util/api/mocks.ts b/common/api/mocks.ts similarity index 100% rename from util/api/mocks.ts rename to common/api/mocks.ts diff --git a/util/api/schema.d.ts b/common/api/schema.d.ts similarity index 59% rename from util/api/schema.d.ts rename to common/api/schema.d.ts index b4672b4..c385f99 100644 --- a/util/api/schema.d.ts +++ b/common/api/schema.d.ts @@ -5,48 +5,65 @@ export interface paths { - "/healthz": { - get: { + "/api/v1/account": { + post: { + requestBody?: { + content: { + "application/json": components["schemas"]["CreateAccountDto"]; + "text/json": components["schemas"]["CreateAccountDto"]; + "application/*+json": components["schemas"]["CreateAccountDto"]; + }; + }; responses: { - /** @description Success */ - 200: { + /** @description No Content */ + 204: { + content: never; + }; + /** @description Bad Request */ + 400: { content: { - "text/plain": components["schemas"]["HealthcheckDto"]; - "application/json": components["schemas"]["HealthcheckDto"]; - "text/json": components["schemas"]["HealthcheckDto"]; + "text/plain": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ProblemDetails"]; + "text/json": components["schemas"]["ProblemDetails"]; }; }; }; }; }; - "/user": { + "/api/v1/healthz": { get: { responses: { /** @description Success */ 200: { content: { - "text/plain": components["schemas"]["UserDto"][]; - "application/json": components["schemas"]["UserDto"][]; - "text/json": components["schemas"]["UserDto"][]; + "text/plain": components["schemas"]["HealthcheckDto"]; + "application/json": components["schemas"]["HealthcheckDto"]; + "text/json": components["schemas"]["HealthcheckDto"]; }; }; }; }; + }; + "/api/v1/token": { post: { requestBody?: { content: { - "application/json": components["schemas"]["CreateUserDto"]; - "text/json": components["schemas"]["CreateUserDto"]; - "application/*+json": components["schemas"]["CreateUserDto"]; + "application/json": components["schemas"]["GetAccountByCredentialsDto"]; + "text/json": components["schemas"]["GetAccountByCredentialsDto"]; + "application/*+json": components["schemas"]["GetAccountByCredentialsDto"]; }; }; responses: { - /** @description No Content */ - 204: { - content: never; + /** @description Success */ + 200: { + content: { + "text/plain": components["schemas"]["TokenDto"]; + "application/json": components["schemas"]["TokenDto"]; + "text/json": components["schemas"]["TokenDto"]; + }; }; - /** @description Bad Request */ - 400: { + /** @description Unauthorized */ + 401: { content: { "text/plain": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ProblemDetails"]; @@ -62,8 +79,14 @@ export type webhooks = Record; export interface components { schemas: { - CreateUserDto: { - name?: string; + CreateAccountDto: { + email: string; + password: string; + phoneNumber?: string | null; + }; + GetAccountByCredentialsDto: { + email: string; + password: string; }; HealthcheckDto: { server?: components["schemas"]["HealthcheckStatus"]; @@ -80,10 +103,8 @@ export interface components { instance?: string | null; [key: string]: unknown; }; - UserDto: { - /** Format: int32 */ - id?: number; - name?: string; + TokenDto: { + token: string; }; }; responses: never; diff --git a/util/api/swr.ts b/common/api/swr.ts similarity index 90% rename from util/api/swr.ts rename to common/api/swr.ts index 75089b5..a39dbbf 100644 --- a/util/api/swr.ts +++ b/common/api/swr.ts @@ -1,8 +1,9 @@ import useSWR from "swr"; -import { useAuth } from "./auth"; import { type GetPath, type GetOptions, fetcher } from "./client"; +import { Identity, useIdentity } from "@/common/identity"; + const TEST_CONFIG = { provider: () => new Map(), loadingTimeout: 0, @@ -16,10 +17,10 @@ type UseApiArg

= const buildOptions =

( { params }: NonNullable>, - token: string | null, + identity: Identity, ): GetOptions

=> ({ ...({ params } as GetOptions

), - headers: { Authorization: `Bearer ${token}` }, + ...(identity.isLoggedIn ? { Authorization: `Bearer ${identity.token}` } : {}), }); /** @@ -57,9 +58,9 @@ const buildOptions =

( * mutate({ name: "Blue bike" }); */ export const useApi =

(arg: UseApiArg

) => { - const { token } = useAuth(); + const identity = useIdentity(); return useSWR( - arg ? [arg.url, buildOptions(arg, token)] : null, + arg ? [arg.url, buildOptions(arg, identity)] : null, (args) => fetcher(...args), __DEV__ ? TEST_CONFIG : undefined, ); diff --git a/constants/Colors.ts b/common/constants/colors.ts similarity index 93% rename from constants/Colors.ts rename to common/constants/colors.ts index 5ebc624..37796fd 100644 --- a/constants/Colors.ts +++ b/common/constants/colors.ts @@ -1,7 +1,7 @@ const tintColorLight = "#2f95dc"; const tintColorDark = "#fff"; -export default { +export const colors = { light: { text: "#000", background: "#fff", diff --git a/common/constants/index.ts b/common/constants/index.ts new file mode 100644 index 0000000..bbdc171 --- /dev/null +++ b/common/constants/index.ts @@ -0,0 +1 @@ +export * from "./colors"; diff --git a/util/i18n.tsx b/common/i18n.tsx similarity index 100% rename from util/i18n.tsx rename to common/i18n.tsx diff --git a/common/identity/context.tsx b/common/identity/context.tsx new file mode 100644 index 0000000..a36de77 --- /dev/null +++ b/common/identity/context.tsx @@ -0,0 +1,65 @@ +import { + type PropsWithChildren, + createContext, + useContext, + useCallback, + useMemo, +} from "react"; + +import { useIdentityStorage } from "./storage"; +import type { Identity, IdentityData, Profile } from "./types"; +import { buildIdentity } from "./util"; + +export const IdentityContext = createContext<{ + data: IdentityData; + setData: (newData: IdentityData) => void; +}>({ data: { known: "nothing" }, setData: () => {} }); + +/** + * Reads or modifies data from the encompassing identity context. + * + * @see IdentityProvider + * @example + * const identity = useIdentity(); + */ +export const useIdentity = (): Identity => { + const { data, setData } = useContext(IdentityContext); + + const logIn = useCallback( + (token: string) => setData({ known: "account", token }), + [setData], + ); + const logOut = useCallback(() => setData({ known: "nothing" }), [setData]); + const selectProfile = useCallback( + (profile: Profile) => + setData( + data.known !== "nothing" + ? { + known: "profile", + token: data.token, + profile, + } + : data, + ), + [data, setData], + ); + + return useMemo( + () => buildIdentity(data, { logIn, logOut, selectProfile }), + [data, logIn, logOut, selectProfile], + ); +}; + +/** + * Provides identity context access to its children. + * + * @see useIdentity + */ +export const IdentityProvider = ({ children }: PropsWithChildren) => { + const [data, setData] = useIdentityStorage({ known: "nothing" }); + return ( + + {children} + + ); +}; diff --git a/common/identity/guards.ts b/common/identity/guards.ts new file mode 100644 index 0000000..12fb1e7 --- /dev/null +++ b/common/identity/guards.ts @@ -0,0 +1,5 @@ +import { Profile, SeniorProfile, CaretakerProfile } from "./types"; + +export const isSenior = (p: Profile): p is SeniorProfile => p.type === "senior"; +export const isCaretaker = (p: Profile): p is CaretakerProfile => + p.type === "caretaker"; diff --git a/common/identity/index.ts b/common/identity/index.ts new file mode 100644 index 0000000..4c110d1 --- /dev/null +++ b/common/identity/index.ts @@ -0,0 +1,13 @@ +export { useIdentity, IdentityProvider } from "./context"; +export { + RedirectIfNoProfile, + RedirectIfLoggedOut, + RedirectIfLoggedIn, +} from "./redirects"; +export type { + Identity, + Profile, + SeniorProfile, + CaretakerProfile, +} from "./types"; +export { isSenior, isCaretaker } from "./guards"; diff --git a/common/identity/mocks.tsx b/common/identity/mocks.tsx new file mode 100644 index 0000000..11e9938 --- /dev/null +++ b/common/identity/mocks.tsx @@ -0,0 +1,45 @@ +import { PropsWithChildren } from "react"; + +import { IdentityContext } from "./context"; +import { IdentityData } from "./types"; +import { buildIdentity } from "./util"; + +/** + * Used to enforce a specific identity in tests. + * + * @see useIdentity + * @see IdentityProvider + */ +export const MockIdentityProvider = ({ + children, + data, +}: PropsWithChildren<{ data: IdentityData }>) => ( + {} }}> + {children} + +); + +const mockActions = { + logIn: () => {}, + logOut: () => {}, + selectProfile: () => {}, +}; + +export const mockIdentityLoggedOut = buildIdentity( + { known: "nothing" }, + mockActions, +); + +export const mockIdentityLoggedIn = buildIdentity( + { known: "account", token: "TOKEN" }, + mockActions, +); + +export const mockIdentityWithProfile = buildIdentity( + { + known: "profile", + token: "TOKEN", + profile: { type: "senior", seniorId: 1 }, + }, + mockActions, +); diff --git a/common/identity/redirects.tsx b/common/identity/redirects.tsx new file mode 100644 index 0000000..9469aff --- /dev/null +++ b/common/identity/redirects.tsx @@ -0,0 +1,82 @@ +import { Redirect } from "expo-router"; +import { FC } from "react"; + +import type { Identity } from "./types"; + +type RedirectProps = { identity: Identity }; + +class InvalidRedirectUsageError extends Error { + constructor(component: FC) { + super(`Invalid ${component.name} usage!`); + } +} + +/** + * Redirects to the login page if the user is not logged in. + * + * Redirects to the profile selection page if the user is logged in but has no selected profile. + * + * Should be used inside a conditional to narrow the type of the identity object. + * + * @throws {InvalidRedirectUsageError} If the user is logged out. + * @see RedirectIfLoggedOut + * @see RedirectIfLoggedIn + * @example + * if (!identity.hasProfile) { + * return ; + * } + */ +export const RedirectIfNoProfile = ({ identity }: RedirectProps) => { + if (!identity.isLoggedIn) { + return ; + } + if (!identity.hasProfile) { + return ; + } + throw new InvalidRedirectUsageError(RedirectIfNoProfile); +}; + +/** + * Redirects to the login page if the user is not logged in. + * + * Should be used inside a conditional to narrow the type of the identity object. + * + * @throws {InvalidRedirectUsageError} If the user is logged in. + * @see RedirectIfLoggedIn + * @see RedirectIfNoProfile + * @example + * if (!identity.isLoggedIn) { + * return ; + * } + */ +export const RedirectIfLoggedOut = ({ identity }: RedirectProps) => { + if (!identity.isLoggedIn) { + return ; + } + throw new InvalidRedirectUsageError(RedirectIfLoggedOut); +}; + +/** + * Redirects to the dashboard if the user is logged in and has a selected profile. + * + * Redirects to the profile selection page if the user is logged in but has no selected profile. + * + * Should be used inside a conditional to narrow the type of the identity object. + * + * @throws {InvalidRedirectUsageError} If the user is logged out. + * @see RedirectIfLoggedOut + * @see RedirectIfNoProfile + * @example + * if (identity.isLoggedIn) { + * return ; + * } + */ +export const RedirectIfLoggedIn = ({ identity }: RedirectProps) => { + if (identity.hasProfile) { + return ; + } + if (identity.isLoggedIn) { + return ; + } + throw new InvalidRedirectUsageError(RedirectIfLoggedIn); +}; diff --git a/common/identity/storage.ts b/common/identity/storage.ts new file mode 100644 index 0000000..4fc785b --- /dev/null +++ b/common/identity/storage.ts @@ -0,0 +1,19 @@ +import { useState } from "react"; +import { MMKV, useMMKVObject } from "react-native-mmkv"; + +import { IdentityData } from "./types"; + +// We can't use storage in Expo Go, so we need to disable it in dev mode. + +const identityStorage = __DEV__ ? null : new MMKV({ id: "identity" }); + +export const useIdentityStorage = ( + defaultData: IdentityData, +): [IdentityData, (newData: IdentityData) => void] => { + const [value, setValue] = __DEV__ + ? // eslint-disable-next-line react-hooks/rules-of-hooks + useState(defaultData) + : // eslint-disable-next-line react-hooks/rules-of-hooks + useMMKVObject("identity-data", identityStorage!); + return [value ?? defaultData, setValue]; +}; diff --git a/common/identity/types.ts b/common/identity/types.ts new file mode 100644 index 0000000..ae31f56 --- /dev/null +++ b/common/identity/types.ts @@ -0,0 +1,61 @@ +export type SeniorProfile = { + type: "senior"; + /** For `type` `"senior"`, this is the account ID of the current user. */ + seniorId: number; +}; + +export type CaretakerProfile = { + type: "caretaker"; + /** For `type` `"caretaker"`, this is the account ID of the senior which is taken care of (another user). */ + seniorId: number; + /** Custom alias for a given senior's account, set by the current caretaker. */ + seniorAlias: string; +}; + +export type Profile = SeniorProfile | CaretakerProfile; + +/** + * State machine representing the current state of the user's identity. + * This is only used internally, the consumer should use the `Identity` type. + * + * @see Identity + */ +export type IdentityData = + | { known: "nothing" } + | { known: "account"; token: string } + | { known: "profile"; token: string; profile: Profile }; + +export type LogIn = (token: string) => void; +export type LogOut = () => void; +export type SelectProfile = (profile: Profile) => void; + +type IdentityNothingKnown = { + isLoggedIn: false; + hasProfile: false; + logIn: LogIn; +}; +type IdentityAccountKnown = { + token: string; + hasProfile: false; + isLoggedIn: true; + logOut: LogOut; + selectProfile: SelectProfile; +}; +type IdentityProfileKnown = { + token: string; + profile: Profile; + hasProfile: true; + isLoggedIn: true; + logOut: LogOut; + selectProfile: SelectProfile; +}; + +/** + * Properties and methods available for the current user's identity. + * The available methods depend on the current state of the user's identity. + * They should be narrowed down using the `isLoggedIn` and `hasProfile` properties. + */ +export type Identity = + | IdentityNothingKnown + | IdentityAccountKnown + | IdentityProfileKnown; diff --git a/common/identity/util.ts b/common/identity/util.ts new file mode 100644 index 0000000..b156d7f --- /dev/null +++ b/common/identity/util.ts @@ -0,0 +1,35 @@ +import { Identity, IdentityData, LogIn, LogOut, SelectProfile } from "./types"; + +export const buildIdentity = ( + data: IdentityData, + { + logIn, + logOut, + selectProfile, + }: { logIn: LogIn; logOut: LogOut; selectProfile: SelectProfile }, +): Identity => { + if (data.known === "nothing") { + return { isLoggedIn: false, hasProfile: false, logIn }; + } + + const common = { + token: data.token, + isLoggedIn: true as const, + logOut, + selectProfile, + }; + + switch (data.known) { + case "account": + return { + ...common, + hasProfile: false, + }; + case "profile": + return { + ...common, + profile: data.profile, + hasProfile: true, + }; + } +}; diff --git a/util/use-font-loader.ts b/common/util/font-loader.ts similarity index 86% rename from util/use-font-loader.ts rename to common/util/font-loader.ts index 437a951..14247fb 100644 --- a/util/use-font-loader.ts +++ b/common/util/font-loader.ts @@ -5,7 +5,7 @@ import { useEffect } from "react"; export const useFontLoader = () => { const [loaded, error] = useFonts({ - SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"), + SpaceMono: require("../../assets/fonts/SpaceMono-Regular.ttf"), ...FontAwesome.font, }); diff --git a/common/util/index.ts b/common/util/index.ts new file mode 100644 index 0000000..e537b7b --- /dev/null +++ b/common/util/index.ts @@ -0,0 +1,3 @@ +export * from "./font-loader"; +export * from "./mock-router"; +export * from "./provider-list"; diff --git a/common/util/mock-router.tsx b/common/util/mock-router.tsx new file mode 100644 index 0000000..83b9544 --- /dev/null +++ b/common/util/mock-router.tsx @@ -0,0 +1,22 @@ +import { ExpoRoot } from "expo-router"; +import { FC } from "react"; + +const mockContext = (context: Record) => + Object.assign((id: string) => ({ default: context[id] }), { + keys: () => Object.keys(context), + resolve: (key: string) => key, + id: "", + }); + +export const MockRouter = ({ + routes, + initialRoute, +}: { + routes: Record; + initialRoute: string; +}) => ( + +); diff --git a/util/provider-list.tsx b/common/util/provider-list.tsx similarity index 100% rename from util/provider-list.tsx rename to common/util/provider-list.tsx diff --git a/util/setup-tests.ts b/common/util/setup-tests.ts similarity index 89% rename from util/setup-tests.ts rename to common/util/setup-tests.ts index de009de..25399dc 100644 --- a/util/setup-tests.ts +++ b/common/util/setup-tests.ts @@ -2,7 +2,7 @@ import "@testing-library/jest-native/extend-expect"; import { act } from "@testing-library/react-native"; import { mutate } from "swr"; -import { mockServer } from "./api/mocks"; +import { mockServer } from "@/common/api/mocks"; global.fetch = require("node-fetch"); diff --git a/util/theme-provider.tsx b/components/ThemeProvider.tsx similarity index 100% rename from util/theme-provider.tsx rename to components/ThemeProvider.tsx diff --git a/components/Themed.tsx b/components/Themed.tsx index 9959dfc..0a73101 100644 --- a/components/Themed.tsx +++ b/components/Themed.tsx @@ -9,7 +9,7 @@ import { View as DefaultView, } from "react-native"; -import Colors from "@/constants/Colors"; +import { colors } from "@/common/constants"; type ThemeProps = { lightColor?: string; @@ -21,7 +21,7 @@ export type ViewProps = ThemeProps & DefaultView["props"]; export function useThemeColor( props: { light?: string; dark?: string }, - colorName: keyof typeof Colors.light & keyof typeof Colors.dark, + colorName: keyof typeof colors.light & keyof typeof colors.dark, ) { const theme = useColorScheme() ?? "light"; const colorFromProps = props[theme]; @@ -29,7 +29,7 @@ export function useThemeColor( if (colorFromProps) { return colorFromProps; } else { - return Colors[theme][colorName]; + return colors[theme][colorName]; } } diff --git a/package-lock.json b/package-lock.json index ef55df2..399b226 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "react-dom": "18.2.0", "react-native": "0.72.4", "react-native-gesture-handler": "~2.12.0", + "react-native-mmkv": "^2.10.2", "react-native-paper": "^5.10.6", "react-native-safe-area-context": "^4.6.3", "react-native-screens": "~3.22.0", @@ -18959,6 +18960,15 @@ "react-native": "*" } }, + "node_modules/react-native-mmkv": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/react-native-mmkv/-/react-native-mmkv-2.10.2.tgz", + "integrity": "sha512-hNrZzwvIFyogJkqf//rVSw7EwceYqkx/jl3hb5tzct6qqwEmS1L9ybvnDjzDkaMyDeouQIqAnsdnb6AuDSrgQQ==", + "peerDependencies": { + "react": "*", + "react-native": ">=0.71.0" + } + }, "node_modules/react-native-paper": { "version": "5.10.6", "resolved": "https://registry.npmjs.org/react-native-paper/-/react-native-paper-5.10.6.tgz", diff --git a/package.json b/package.json index 347a1dc..6d3bc60 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "test": "jest", "lint": "tsc -p . --noEmit && eslint . --max-warnings=0", "format": "prettier . --write --log-level silent", - "gen-api": "npx openapi-typescript http://localhost:8088/swagger/v1/swagger.json -o util/api/schema.d.ts", + "gen-api": "npx openapi-typescript http://localhost:8088/swagger/v1/swagger.json -o common/api/schema.d.ts", "prepare": "husky install" }, "jest": { @@ -16,7 +16,7 @@ "@/(.*)": "/$1" }, "setupFilesAfterEnv": [ - "/util/setup-tests.ts" + "/common/util/setup-tests.ts" ] }, "dependencies": { @@ -37,6 +37,7 @@ "react-dom": "18.2.0", "react-native": "0.72.4", "react-native-gesture-handler": "~2.12.0", + "react-native-mmkv": "^2.10.2", "react-native-paper": "^5.10.6", "react-native-safe-area-context": "^4.6.3", "react-native-screens": "~3.22.0", diff --git a/tests/index.test.tsx b/tests/app/index.test.tsx similarity index 74% rename from tests/index.test.tsx rename to tests/app/index.test.tsx index cc50d30..334908c 100644 --- a/tests/index.test.tsx +++ b/tests/app/index.test.tsx @@ -1,12 +1,11 @@ import { render, screen, waitFor } from "@testing-library/react-native"; -import { Landing } from "../app/index"; - -import { mockApi } from "@/util/api/mocks"; +import { Landing } from "@/app/index"; +import { mockApi } from "@/common/api/mocks"; describe(Landing, () => { it("should show the landing page when the server is working", async () => { - mockApi("get", "/healthz", (ctx) => + mockApi("get", "/api/v1/healthz", (ctx) => ctx.json({ server: "Ok", database: "Ok" }), ); @@ -16,7 +15,7 @@ describe(Landing, () => { }); it("should show an error when the server is down", async () => { - mockApi("get", "/healthz", (ctx) => ctx.status(500)); + mockApi("get", "/api/v1/healthz", (ctx) => ctx.status(500)); render(); @@ -25,7 +24,7 @@ describe(Landing, () => { describe("debug info", () => { it("should be displayed in development", async () => { - mockApi("get", "/healthz", (ctx) => ctx.status(500)); + mockApi("get", "/api/v1/healthz", (ctx) => ctx.status(500)); render(); await waitFor(() => screen.findByText("Error")); @@ -34,7 +33,7 @@ describe(Landing, () => { }); it("should be hidden in production", async () => { - mockApi("get", "/healthz", (ctx) => ctx.status(500)); + mockApi("get", "/api/v1/healthz", (ctx) => ctx.status(500)); render(); await waitFor(() => screen.findByText("Error")); diff --git a/tests/common/api.test.ts b/tests/common/api.test.ts new file mode 100644 index 0000000..104e800 --- /dev/null +++ b/tests/common/api.test.ts @@ -0,0 +1,61 @@ +import { renderHook, waitFor } from "@testing-library/react-native"; + +import { POST, useApi } from "@/common/api"; +import { fetcher } from "@/common/api/client"; +import { mockApi } from "@/common/api/mocks"; + +describe("API", () => { + describe(POST, () => { + it("invokes HTTP POST on the correct endpoint", async () => { + const handler = jest.fn().mockReturnValue(201); + mockApi("post", "/api/v1/account", (ctx) => ctx.status(handler())); + + const { response } = await POST("/api/v1/account", { parseAs: "text" }); + + expect(handler).toHaveBeenCalled(); + expect(response.status).toBe(201); + }); + }); + + describe(fetcher, () => { + it("invokes HTTP GET on the correct endpoint", async () => { + const handler = jest.fn().mockReturnValue(["John", "Mark"]); + mockApi("get", "/api/v1/healthz", (ctx) => ctx.json(handler())); + + const data = await fetcher("/api/v1/healthz", {}); + + expect(handler).toHaveBeenCalled(); + expect(data).toEqual(["John", "Mark"]); + }); + + it("throws for error code responses", async () => { + mockApi("get", "/api/v1/healthz", (ctx) => ctx.status(404)); + + const promise = fetcher("/api/v1/healthz", {}); + + await expect(promise).rejects.toThrow(); + }); + }); + + describe(useApi, () => { + it("fetches data correctly", async () => { + mockApi("get", "/api/v1/healthz", (ctx) => ctx.json(["Anne"])); + + const { result } = renderHook(() => useApi({ url: "/api/v1/healthz" })); + + await waitFor(() => expect(result.current.data).toEqual(["Anne"])); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeFalsy(); + }); + + it("returns error when data is unavailable", async () => { + mockApi("get", "/api/v1/healthz", (ctx) => ctx.status(404)); + + const { result } = renderHook(() => useApi({ url: "/api/v1/healthz" })); + + await waitFor(() => expect(result.current.error).toBeTruthy()); + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + }); + }); +}); diff --git a/tests/util/i18n.test.tsx b/tests/common/i18n.test.tsx similarity index 95% rename from tests/util/i18n.test.tsx rename to tests/common/i18n.test.tsx index f269ac6..9f0fdaa 100644 --- a/tests/util/i18n.test.tsx +++ b/tests/common/i18n.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from "@testing-library/react-native"; +import { I18nProvider, MockI18nProvider, useI18n } from "@/common/i18n"; import { Text } from "@/components/Themed"; -import { I18nProvider, MockI18nProvider, useI18n } from "@/util/i18n"; type TArgs = Parameters["t"]>; diff --git a/tests/common/identity.test.tsx b/tests/common/identity.test.tsx new file mode 100644 index 0000000..3949bdb --- /dev/null +++ b/tests/common/identity.test.tsx @@ -0,0 +1,149 @@ +import { render, renderHook, screen } from "@testing-library/react-native"; +import { ReactNode, useEffect } from "react"; +import { Text } from "react-native"; + +import { + IdentityProvider, + RedirectIfNoProfile, + RedirectIfLoggedOut, + RedirectIfLoggedIn, + useIdentity, +} from "@/common/identity"; +import { + MockIdentityProvider, + mockIdentityLoggedIn, + mockIdentityLoggedOut, + mockIdentityWithProfile, +} from "@/common/identity/mocks"; +import { IdentityData } from "@/common/identity/types"; +import { MockRouter } from "@/common/util"; + +const AutomaticLogin = () => { + const identity = useIdentity(); + + useEffect(() => { + if (!identity.isLoggedIn) { + identity.logIn("TOKEN"); + } + }, [identity]); + + return {identity.isLoggedIn ? "LOGGED IN" : "LOGGED OUT"}; +}; + +const renderUseIdentity = (data: IdentityData) => + renderHook(useIdentity, { + wrapper: ({ children }) => ( + {children} + ), + }).result.current; + +const testRedirect = async ( + initialRoute: string, + redirect: ReactNode, + targetRoute: string, +) => { + const routes = ["/dashboard", "/auth/login", "/profile/list"]; + + render( + [route, () => {route}]), + ), + [initialRoute]: () => redirect, + }} + initialRoute={initialRoute} + />, + ); + + expect(await screen.findByText(targetRoute)).toBeVisible(); +}; + +describe("Identity", () => { + describe(useIdentity, () => { + it("correctly reads logged out identity", () => { + const identity = renderUseIdentity({ known: "nothing" }); + + expect(identity.isLoggedIn).toBe(false); + expect(identity.hasProfile).toBe(false); + }); + + it("correctly reads logged in identity", () => { + const identity = renderUseIdentity({ known: "account", token: "TOKEN" }); + + expect(identity.isLoggedIn).toBe(true); + expect(identity.hasProfile).toBe(false); + + if (!identity.isLoggedIn) throw new Error(); + + expect(identity.token).toBe("TOKEN"); + }); + + it("correctly reads identity with profile", () => { + const identity = renderUseIdentity({ + known: "profile", + token: "TOKEN", + profile: { type: "senior", seniorId: 3 }, + }); + + expect(identity.isLoggedIn).toBe(true); + expect(identity.hasProfile).toBe(true); + + if (!identity.hasProfile) throw new Error(); + + expect(identity.token).toBe("TOKEN"); + expect(identity.profile).toEqual({ type: "senior", seniorId: 3 }); + }); + }); + + describe(IdentityProvider, () => { + it("reacts to identity changes", async () => { + render(, { wrapper: IdentityProvider }); + + expect(await screen.findByText("LOGGED IN")).toBeVisible(); + }); + }); + + describe("Redirects", () => { + describe(RedirectIfNoProfile, () => { + it("redirects to the login page if the user is not logged in", () => + testRedirect( + "/dashboard", + , + "/auth/login", + )); + + it("redirects to the profile selection page if the user has no selected profile", () => + testRedirect( + "/dashboard", + , + "/profile/list", + )); + }); + + describe(RedirectIfLoggedOut, () => { + it("redirects to the login page if the user is not logged in", () => + testRedirect( + "/dashboard", + , + "/auth/login", + )); + }); + + describe(RedirectIfLoggedIn, () => { + it("redirects to the dashboard if the user is logged in and has a selected profile", () => + testRedirect( + "/auth/login", + , + "/dashboard", + )); + + it("redirects to the profile selection page if the user has no selected profile", () => + testRedirect( + "/auth/login", + , + "/profile/list", + )); + }); + }); +}); diff --git a/tests/util/api.test.ts b/tests/util/api.test.ts deleted file mode 100644 index 12c531f..0000000 --- a/tests/util/api.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { renderHook, act, waitFor } from "@testing-library/react-native"; - -import { AuthProvider, POST, useApi, useAuth } from "@/util/api"; -import { fetcher } from "@/util/api/client"; -import { mockApi } from "@/util/api/mocks"; - -describe("API", () => { - describe(useAuth, () => { - it("initially returns a null token", () => { - const { result } = renderHook(() => useAuth(), { wrapper: AuthProvider }); - - expect(result.current.token).toBeNull(); - }); - - it("can change the token", () => { - const { result } = renderHook(() => useAuth(), { wrapper: AuthProvider }); - - act(() => result.current.setToken("SUPER_SECURE_TOKEN")); - - expect(result.current.token).toBe("SUPER_SECURE_TOKEN"); - }); - }); - - describe(POST, () => { - it("invokes HTTP POST on the correct endpoint", async () => { - const handler = jest.fn().mockReturnValue(201); - mockApi("post", "/user", (ctx) => ctx.status(handler())); - - const { response } = await POST("/user", { parseAs: "text" }); - - expect(handler).toHaveBeenCalled(); - expect(response.status).toBe(201); - }); - }); - - describe(fetcher, () => { - it("invokes HTTP GET on the correct endpoint", async () => { - const handler = jest.fn().mockReturnValue(["John", "Mark"]); - mockApi("get", "/user", (ctx) => ctx.json(handler())); - - const data = await fetcher("/user", {}); - - expect(handler).toHaveBeenCalled(); - expect(data).toEqual(["John", "Mark"]); - }); - - it("throws for error code responses", async () => { - mockApi("get", "/user", (ctx) => ctx.status(404)); - - const promise = fetcher("/user", {}); - - await expect(promise).rejects.toThrow(); - }); - }); - - describe(useApi, () => { - it("fetches data correctly", async () => { - mockApi("get", "/user", (ctx) => ctx.json(["Anne"])); - - const { result } = renderHook(() => useApi({ url: "/user" })); - - await waitFor(() => expect(result.current.data).toEqual(["Anne"])); - expect(result.current.isLoading).toBe(false); - expect(result.current.error).toBeFalsy(); - }); - - it("returns error when data is unavailable", async () => { - mockApi("get", "/user", (ctx) => ctx.status(404)); - - const { result } = renderHook(() => useApi({ url: "/user" })); - - await waitFor(() => expect(result.current.error).toBeTruthy()); - expect(result.current.isLoading).toBe(false); - expect(result.current.data).toBeUndefined(); - }); - }); -}); diff --git a/util/api/auth.tsx b/util/api/auth.tsx deleted file mode 100644 index c343f65..0000000 --- a/util/api/auth.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { - type PropsWithChildren, - createContext, - useContext, - useState, -} from "react"; - -type AuthData = { - token: string | null; - setToken: (token: string | null) => void; -}; - -const AuthContext = createContext({ - token: null, - setToken: () => {}, -}); - -/** - * Reads or modifies data from the encompassing authorization context. - * - * @see AuthProvider - */ -export const useAuth = () => useContext(AuthContext); - -/** - * Provides authorization context access to its children. - * - * @see useAuth - */ -export const AuthProvider = ({ children }: PropsWithChildren) => { - const [token, setToken] = useState(null); - return ( - - {children} - - ); -};