diff --git a/.devcontainer.json b/.devcontainer.json index 697ab8b..9cb651b 100644 --- a/.devcontainer.json +++ b/.devcontainer.json @@ -47,7 +47,7 @@ "source=./codeforlife-package-python,target=/workspace/codeforlife-package-python,type=bind,consistency=cached" ], "name": "portal-frontend", - "postCreateCommand": "sudo chmod u+x ./setup && ./setup", + "postCreateCommand": "sudo chmod u+x scripts/setup && scripts/setup", "remoteUser": "root", "service": "base-service", "shutdownAction": "none", diff --git a/.env b/.env index 0a6ea68..a868567 100644 --- a/.env +++ b/.env @@ -1 +1,2 @@ -VITE_API_BASE_URL=REPLACE_ME +VITE_API_BASE_URL=http://localhost:8000/api/ +VITE_SERVICE_NAME=portal diff --git a/.gcloudignore b/.gcloudignore index 09c35ea..8fb98f4 100644 --- a/.gcloudignore +++ b/.gcloudignore @@ -10,7 +10,7 @@ /codecov.yml /*.code-* /*.md -/setup +/scripts /tsconfig.json /tsconfig.node.json /vite.config.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 0d97f88..9f1a90e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,8 +1,8 @@ { "configurations": [ { - "name": "React Dev Server", - "preLaunchTask": "start-react-dev-server", + "name": "Vite Server", + "preLaunchTask": "start-vite-server", "request": "launch", "type": "chrome", "url": "http://localhost:5173" diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 6a0dd90..c9c2abe 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,16 +1,22 @@ { "tasks": [ { + "command": "sudo chmod u+x scripts/hard-install && scripts/hard-install", + "label": "hard-install", + "problemMatcher": [], + "type": "shell" + }, + { + "command": "sudo chmod u+x scripts/run && scripts/run", "isBackground": true, - "label": "start-react-dev-server", + "label": "start-vite-server", "options": { "env": { "BROWSER": "none" } }, "problemMatcher": [], - "script": "start", - "type": "npm" + "type": "shell" } ], "version": "2.0.0" diff --git a/package.json b/package.json index 419e3b0..831bdc3 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ }, "//": "🚫 Don't add `dependencies` below that are inherited from the CFL package.", "dependencies": { - "codeforlife": "github:ocadotechnology/codeforlife-package-javascript#v2.0.0" + "codeforlife": "github:ocadotechnology/codeforlife-package-javascript#v2.0.3" }, "//": "✅ Do add `devDependencies` below that are `peerDependencies` in the CFL package.", "devDependencies": { diff --git a/scripts/hard-install b/scripts/hard-install new file mode 100755 index 0000000..ea46059 --- /dev/null +++ b/scripts/hard-install @@ -0,0 +1,9 @@ +#!/bin/bash +set -e + +cd "${BASH_SOURCE%/*}" + +rm -f ../yarn.lock +rm -rf ../node_modules +yarn cache clean codeforlife +yarn install --production=false diff --git a/scripts/run b/scripts/run new file mode 100755 index 0000000..8ce071e --- /dev/null +++ b/scripts/run @@ -0,0 +1,8 @@ +#!/bin/bash +set -e + +cd "${BASH_SOURCE%/*}" + +source ./setup + +yarn dev diff --git a/setup b/scripts/setup similarity index 62% rename from setup rename to scripts/setup index 1272bff..ce6aabd 100755 --- a/setup +++ b/scripts/setup @@ -3,6 +3,4 @@ set -e cd "${BASH_SOURCE%/*}" -printf "Setting up Node.js environment\n\n" - yarn install --production=false diff --git a/src/App.css b/src/App.css deleted file mode 100644 index 01cc586..0000000 --- a/src/App.css +++ /dev/null @@ -1,39 +0,0 @@ -.App { - text-align: center; -} - -.App-logo { - height: 40vmin; - pointer-events: none; -} - -@media (prefers-reduced-motion: no-preference) { - .App-logo { - animation: App-logo-float infinite 3s ease-in-out; - } -} - -.App-header { - min-height: 100vh; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - font-size: calc(10px + 2vmin); -} - -.App-link { - color: rgb(112, 76, 182); -} - -@keyframes App-logo-float { - 0% { - transform: translateY(0); - } - 50% { - transform: translateY(10px); - } - 100% { - transform: translateY(0px); - } -} diff --git a/src/App.test.tsx b/src/App.test.tsx deleted file mode 100644 index 06b45be..0000000 --- a/src/App.test.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { screen, waitFor } from "@testing-library/react" -import App from "./App" -import { renderWithProviders } from "./utils/test-utils" - -test("App should have correct initial render", () => { - renderWithProviders() - - // The app should be rendered correctly - expect(screen.getByText(/learn/i)).toBeInTheDocument() - - // Initial state: count should be 0, incrementValue should be 2 - expect(screen.getByLabelText("Count")).toHaveTextContent("0") - expect(screen.getByLabelText("Set increment amount")).toHaveValue(2) -}) - -test("Increment value and Decrement value should work as expected", async () => { - const { user } = renderWithProviders() - - // Click on "+" => Count should be 1 - await user.click(screen.getByLabelText("Increment value")) - expect(screen.getByLabelText("Count")).toHaveTextContent("1") - - // Click on "-" => Count should be 0 - await user.click(screen.getByLabelText("Decrement value")) - expect(screen.getByLabelText("Count")).toHaveTextContent("0") -}) - -test("Add Amount should work as expected", async () => { - const { user } = renderWithProviders() - - // "Add Amount" button is clicked => Count should be 2 - await user.click(screen.getByText("Add Amount")) - expect(screen.getByLabelText("Count")).toHaveTextContent("2") - - const incrementValueInput = screen.getByLabelText("Set increment amount") - // incrementValue is 2, click on "Add Amount" => Count should be 4 - await user.clear(incrementValueInput) - await user.type(incrementValueInput, "2") - await user.click(screen.getByText("Add Amount")) - expect(screen.getByLabelText("Count")).toHaveTextContent("4") - - // [Negative number] incrementValue is -1, click on "Add Amount" => Count should be 3 - await user.clear(incrementValueInput) - await user.type(incrementValueInput, "-1") - await user.click(screen.getByText("Add Amount")) - expect(screen.getByLabelText("Count")).toHaveTextContent("3") -}) - -it("Add Async should work as expected", async () => { - const { user } = renderWithProviders() - - // "Add Async" button is clicked => Count should be 2 - await user.click(screen.getByText("Add Async")) - - await waitFor(() => - expect(screen.getByLabelText("Count")).toHaveTextContent("2"), - ) - - const incrementValueInput = screen.getByLabelText("Set increment amount") - // incrementValue is 2, click on "Add Async" => Count should be 4 - await user.clear(incrementValueInput) - await user.type(incrementValueInput, "2") - - await user.click(screen.getByText("Add Async")) - await waitFor(() => - expect(screen.getByLabelText("Count")).toHaveTextContent("4"), - ) - - // [Negative number] incrementValue is -1, click on "Add Async" => Count should be 3 - await user.clear(incrementValueInput) - await user.type(incrementValueInput, "-1") - await user.click(screen.getByText("Add Async")) - await waitFor(() => - expect(screen.getByLabelText("Count")).toHaveTextContent("3"), - ) -}) - -test("Add If Odd should work as expected", async () => { - const { user } = renderWithProviders() - - // "Add If Odd" button is clicked => Count should stay 0 - await user.click(screen.getByText("Add If Odd")) - expect(screen.getByLabelText("Count")).toHaveTextContent("0") - - // Click on "+" => Count should be updated to 1 - await user.click(screen.getByLabelText("Increment value")) - expect(screen.getByLabelText("Count")).toHaveTextContent("1") - - // "Add If Odd" button is clicked => Count should be updated to 3 - await user.click(screen.getByText("Add If Odd")) - expect(screen.getByLabelText("Count")).toHaveTextContent("3") - - const incrementValueInput = screen.getByLabelText("Set increment amount") - // incrementValue is 1, click on "Add If Odd" => Count should be updated to 4 - await user.clear(incrementValueInput) - await user.type(incrementValueInput, "1") - await user.click(screen.getByText("Add If Odd")) - expect(screen.getByLabelText("Count")).toHaveTextContent("4") - - // click on "Add If Odd" => Count should stay 4 - await user.clear(incrementValueInput) - await user.type(incrementValueInput, "-1") - await user.click(screen.getByText("Add If Odd")) - expect(screen.getByLabelText("Count")).toHaveTextContent("4") -}) diff --git a/src/App.tsx b/src/App.tsx index 08ec755..27ec12f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,67 +1,15 @@ -import "./App.css" -import { Counter } from "./features/counter/Counter" -import { Quotes } from "./features/quotes/Quotes" -import logo from "./logo.svg" +import { CssBaseline, ThemeProvider } from "@mui/material" +import type { FC } from "react" -const App = () => { +import theme from "./app/theme" +import Router from "./router" + +const App: FC = () => { return ( -
-
- logo - -

- Edit src/App.tsx and save to reload. -

- - - Learn - - React - - , - - Redux - - , - - Redux Toolkit - - , - - React Redux - - , and - - Reselect - - -
-
+ + + + ) } diff --git a/src/api/authFactor.ts b/src/api/authFactor.ts index 1daaa2d..f4bc253 100644 --- a/src/api/authFactor.ts +++ b/src/api/authFactor.ts @@ -8,7 +8,7 @@ import { type DestroyResult, type ListArg, type ListResult, -} from "codeforlife/utils/rtkQuery" +} from "codeforlife/utils/api" import api from "." diff --git a/src/api/index.ts b/src/api/index.ts index 57bca53..c5734da 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,12 +1,71 @@ import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react" +import Cookies from "js-cookie" + import { tagTypes } from "codeforlife/api" +// https://docs.djangoproject.com/en/3.2/ref/csrf/ +const getCsrfCookie = () => + Cookies.get(`${import.meta.env.VITE_SERVICE_NAME}_csrftoken`) + +const fetch = fetchBaseQuery({ + baseUrl: import.meta.env.VITE_API_BASE_URL, + credentials: "include", + prepareHeaders: (headers, { type }) => { + if (type === "mutation") { + let csrfToken = getCsrfCookie() + if (csrfToken) headers.set("x-csrftoken", csrfToken) + } + + return headers + }, +}) + const api = createApi({ - baseQuery: fetchBaseQuery({ - baseUrl: import.meta.env.VITE_API_BASE_URL, - }), + baseQuery: async (args, api, extraOptions) => { + if (api.type === "mutation" && getCsrfCookie() === undefined) { + // Get the CSRF token. + const { error } = await fetch( + { url: "/csrf/cookie", method: "GET" }, + api, + {}, + ) + + // Validate we got the CSRF token. + if (error !== undefined) { + console.error(error) + // TODO + // window.location.href = `${PORTAL_BASE_URL}/error/500` + } + if (getCsrfCookie() === undefined) { + // TODO + // window.location.href = `${PORTAL_BASE_URL}/error/500` + } + } + + // Send the HTTP request and fetch the response. + return await fetch(args, api, extraOptions) + }, tagTypes: [...tagTypes, "SchoolTeacherInvitation"], - endpoints: () => ({}), + endpoints: build => ({ + logout: build.mutation({ + query: () => ({ + url: "session/logout/", + method: "POST", + }), + async onQueryStarted(_, { dispatch, queryFulfilled }) { + try { + await queryFulfilled + } catch (error) { + console.error("Failed to log out...", error) + } finally { + Cookies.remove("session_key") + Cookies.remove("session_metadata") + dispatch(api.util.resetApiState()) + } + }, + }), + }), }) export default api +export const { useLogoutMutation } = api diff --git a/src/api/klass.ts b/src/api/klass.ts index 7e59ead..71f980c 100644 --- a/src/api/klass.ts +++ b/src/api/klass.ts @@ -12,7 +12,7 @@ import { type RetrieveResult, type UpdateArg, type UpdateResult, -} from "codeforlife/utils/rtkQuery" +} from "codeforlife/utils/api" import api from "." diff --git a/src/api/school.ts b/src/api/school.ts index aead92e..67dd433 100644 --- a/src/api/school.ts +++ b/src/api/school.ts @@ -8,7 +8,7 @@ import { type RetrieveResult, type UpdateArg, type UpdateResult, -} from "codeforlife/utils/rtkQuery" +} from "codeforlife/utils/api" import api from "." diff --git a/src/api/schoolTeacherInvitation.ts b/src/api/schoolTeacherInvitation.ts index e568a32..36a34e4 100644 --- a/src/api/schoolTeacherInvitation.ts +++ b/src/api/schoolTeacherInvitation.ts @@ -14,7 +14,7 @@ import { type RetrieveResult, type UpdateArg, type UpdateResult, -} from "codeforlife/utils/rtkQuery" +} from "codeforlife/utils/api" import api from "." diff --git a/src/api/sso.ts b/src/api/sso.ts new file mode 100644 index 0000000..8ecd99d --- /dev/null +++ b/src/api/sso.ts @@ -0,0 +1,78 @@ +// TODO: rename this file to session.ts and move to codeforlife-sso-frontend. + +import { + type Class, + type OtpBypassToken, + type Student, + type User, +} from "codeforlife/api" +import { type SessionMetadata } from "codeforlife/hooks" +import { type Arg } from "codeforlife/utils/api" + +import api from "." + +const baseUrl = "sso/session/" + +const ssoApi = api.injectEndpoints({ + endpoints: build => ({ + loginWithEmail: build.mutation< + SessionMetadata, + Arg + >({ + query: body => ({ + url: baseUrl + "login-with-email/", + method: "POST", + body, + }), + }), + loginWithOtp: build.mutation({ + query: body => ({ + url: baseUrl + "login-with-otp/", + method: "POST", + body, + }), + }), + loginWithOtpBypassToken: build.mutation< + SessionMetadata, + Arg + >({ + query: body => ({ + url: baseUrl + "login-with-otp-bypass-token/", + method: "POST", + body, + }), + }), + loginAsStudent: build.mutation< + SessionMetadata, + Arg & { class_id: Class["id"] } + >({ + query: body => ({ + url: baseUrl + "login-as-student/", + method: "POST", + body, + }), + }), + autoLoginAsStudent: build.mutation< + SessionMetadata, + { + student_id: Student["id"] + auto_gen_password: string + } + >({ + query: body => ({ + url: baseUrl + "auto-login-as-student/", + method: "POST", + body, + }), + }), + }), +}) + +export default ssoApi +export const { + useLoginWithEmailMutation, + useLoginWithOtpMutation, + useLoginWithOtpBypassTokenMutation, + useLoginAsStudentMutation, + useAutoLoginAsStudentMutation, +} = ssoApi diff --git a/src/api/student.ts b/src/api/student.ts index 36fb51d..d74cb29 100644 --- a/src/api/student.ts +++ b/src/api/student.ts @@ -8,7 +8,7 @@ import { type BulkDestroyResult, type BulkUpdateArg, type BulkUpdateResult, -} from "codeforlife/utils/rtkQuery" +} from "codeforlife/utils/api" import api from "." diff --git a/src/api/teacher.ts b/src/api/teacher.ts index f3b6a9a..ab228ce 100644 --- a/src/api/teacher.ts +++ b/src/api/teacher.ts @@ -8,7 +8,7 @@ import { type DestroyResult, type UpdateArg, type UpdateResult, -} from "codeforlife/utils/rtkQuery" +} from "codeforlife/utils/api" import api from "." diff --git a/src/api/user.ts b/src/api/user.ts index 7f037de..e64f1c9 100644 --- a/src/api/user.ts +++ b/src/api/user.ts @@ -13,7 +13,7 @@ import { type RetrieveResult, type UpdateArg, type UpdateResult, -} from "codeforlife/utils/rtkQuery" +} from "codeforlife/utils/api" import api from "." diff --git a/src/app/schemas.ts b/src/app/schemas.ts new file mode 100644 index 0000000..96a8f77 --- /dev/null +++ b/src/app/schemas.ts @@ -0,0 +1,35 @@ +import * as yup from "yup" + +export const classIdSchema = yup + .string() + .matches(/^[A-Z0-9]{5}$/, "Invalid class code") + +const passwordSchema = yup.string().required("required") + +export const teacherPasswordSchema = passwordSchema.test({ + message: "too-weak", + test: password => + password.length >= 10 && + !( + password.search(/[A-Z]/) === -1 || + password.search(/[a-z]/) === -1 || + password.search(/[0-9]/) === -1 || + password.search(/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/) === -1 + ), +}) + +export const studentPasswordSchema = passwordSchema.test({ + message: "too-weak", + test: password => password.length >= 6, +}) + +export const indyPasswordSchema = passwordSchema.test({ + message: "too-weak", + test: password => + password.length >= 8 && + !( + password.search(/[A-Z]/) === -1 || + password.search(/[a-z]/) === -1 || + password.search(/[0-9]/) === -1 + ), +}) diff --git a/src/app/store.ts b/src/app/store.ts index 9de8802..6435007 100644 --- a/src/app/store.ts +++ b/src/app/store.ts @@ -1,38 +1,23 @@ import type { Action, ThunkAction } from "@reduxjs/toolkit" -import { combineSlices, configureStore } from "@reduxjs/toolkit" -import { setupListeners } from "@reduxjs/toolkit/query" -import { counterSlice } from "../features/counter/counterSlice" -import { quotesApiSlice } from "../features/quotes/quotesApiSlice" +import { combineSlices } from "@reduxjs/toolkit" + +import { makeStore } from "codeforlife/utils/store" + +import api from "../api" // `combineSlices` automatically combines the reducers using // their `reducerPath`s, therefore we no longer need to call `combineReducers`. -const rootReducer = combineSlices(counterSlice, quotesApiSlice) -// Infer the `RootState` type from the root reducer -export type RootState = ReturnType +const reducer = combineSlices(api) -// The store setup is wrapped in `makeStore` to allow reuse -// when setting up tests that need the same store config -export const makeStore = (preloadedState?: Partial) => { - const store = configureStore({ - reducer: rootReducer, - // Adding the api middleware enables caching, invalidation, polling, - // and other useful features of `rtk-query`. - middleware: getDefaultMiddleware => { - return getDefaultMiddleware().concat(quotesApiSlice.middleware) - }, - preloadedState, - }) - // configure listeners using the provided defaults - // optional, but required for `refetchOnFocus`/`refetchOnReconnect` behaviors - setupListeners(store.dispatch) - return store -} +// Infer the `RootState` type from the root reducer +export type RootState = ReturnType -export const store = makeStore() +// TODO: create middleware for api errors. +// https://redux-toolkit.js.org/rtk-query/usage/error-handling#handling-errors-at-a-macro-level +const store = makeStore({ reducer, middlewares: [api.middleware] }) -// Infer the type of `store` +export default store export type AppStore = typeof store -// Infer the `AppDispatch` type from the store itself export type AppDispatch = AppStore["dispatch"] export type AppThunk = ThunkAction< ThunkReturnType, diff --git a/src/app/theme.ts b/src/app/theme.ts new file mode 100644 index 0000000..e0abe14 --- /dev/null +++ b/src/app/theme.ts @@ -0,0 +1,16 @@ +import { + createTheme, + responsiveFontSizes, + type ThemeOptions, +} from "@mui/material" + +import { themeOptions as cflThemeOptions } from "codeforlife/theme" + +// Unpack the base options to extend the theme +export const themeOptions: ThemeOptions = { + ...cflThemeOptions, +} + +const theme = responsiveFontSizes(createTheme(themeOptions)) + +export default theme diff --git a/src/features/counter/Counter.module.css b/src/features/counter/Counter.module.css deleted file mode 100644 index a0e619d..0000000 --- a/src/features/counter/Counter.module.css +++ /dev/null @@ -1,81 +0,0 @@ -.row { - display: flex; - align-items: center; - justify-content: center; -} - -.row > button { - margin-left: 4px; - margin-right: 8px; -} - -.row:not(:last-child) { - margin-bottom: 16px; -} - -.value { - font-size: 78px; - padding-left: 16px; - padding-right: 16px; - margin-top: 2px; - font-family: "Courier New", Courier, monospace; -} - -.button { - appearance: none; - background: none; - font-size: 32px; - padding-left: 12px; - padding-right: 12px; - outline: none; - border: 2px solid transparent; - color: rgb(112, 76, 182); - padding-bottom: 4px; - cursor: pointer; - background-color: rgba(112, 76, 182, 0.1); - border-radius: 2px; - transition: all 0.15s; -} - -.textbox { - font-size: 32px; - padding: 2px; - width: 64px; - text-align: center; - margin-right: 4px; -} - -.button:hover, -.button:focus { - border: 2px solid rgba(112, 76, 182, 0.4); -} - -.button:active { - background-color: rgba(112, 76, 182, 0.2); -} - -.asyncButton { - composes: button; - position: relative; -} - -.asyncButton:after { - content: ""; - background-color: rgba(112, 76, 182, 0.15); - display: block; - position: absolute; - width: 100%; - height: 100%; - left: 0; - top: 0; - opacity: 0; - transition: - width 1s linear, - opacity 0.5s ease 1s; -} - -.asyncButton:active:after { - width: 0%; - opacity: 1; - transition: 0s; -} diff --git a/src/features/counter/Counter.tsx b/src/features/counter/Counter.tsx deleted file mode 100644 index a286d80..0000000 --- a/src/features/counter/Counter.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { useState } from "react" - -import { useAppDispatch, useAppSelector } from "../../app/hooks" -import styles from "./Counter.module.css" -import { - decrement, - increment, - incrementAsync, - incrementByAmount, - incrementIfOdd, - selectCount, - selectStatus, -} from "./counterSlice" - -export const Counter = () => { - const dispatch = useAppDispatch() - const count = useAppSelector(selectCount) - const status = useAppSelector(selectStatus) - const [incrementAmount, setIncrementAmount] = useState("2") - - const incrementValue = Number(incrementAmount) || 0 - - return ( -
-
- - - {count} - - -
-
- { - setIncrementAmount(e.target.value) - }} - /> - - - -
-
- ) -} diff --git a/src/features/counter/counterAPI.ts b/src/features/counter/counterAPI.ts deleted file mode 100644 index aca3ef6..0000000 --- a/src/features/counter/counterAPI.ts +++ /dev/null @@ -1,6 +0,0 @@ -// A mock function to mimic making an async request for data -export const fetchCount = (amount = 1) => { - return new Promise<{ data: number }>(resolve => - setTimeout(() => resolve({ data: amount }), 500), - ) -} diff --git a/src/features/counter/counterSlice.test.ts b/src/features/counter/counterSlice.test.ts deleted file mode 100644 index 12eafe1..0000000 --- a/src/features/counter/counterSlice.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { AppStore } from "../../app/store" -import { makeStore } from "../../app/store" -import type { CounterSliceState } from "./counterSlice" -import { - counterSlice, - decrement, - increment, - incrementByAmount, - selectCount, -} from "./counterSlice" - -interface LocalTestContext { - store: AppStore -} - -describe("counter reducer", it => { - beforeEach(context => { - const initialState: CounterSliceState = { - value: 3, - status: "idle", - } - - const store = makeStore({ counter: initialState }) - - context.store = store - }) - - it("should handle initial state", () => { - expect(counterSlice.reducer(undefined, { type: "unknown" })).toStrictEqual({ - value: 0, - status: "idle", - }) - }) - - it("should handle increment", ({ store }) => { - expect(selectCount(store.getState())).toBe(3) - - store.dispatch(increment()) - - expect(selectCount(store.getState())).toBe(4) - }) - - it("should handle decrement", ({ store }) => { - expect(selectCount(store.getState())).toBe(3) - - store.dispatch(decrement()) - - expect(selectCount(store.getState())).toBe(2) - }) - - it("should handle incrementByAmount", ({ store }) => { - expect(selectCount(store.getState())).toBe(3) - - store.dispatch(incrementByAmount(2)) - - expect(selectCount(store.getState())).toBe(5) - }) -}) diff --git a/src/features/counter/counterSlice.ts b/src/features/counter/counterSlice.ts deleted file mode 100644 index 07bc1f5..0000000 --- a/src/features/counter/counterSlice.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { PayloadAction } from "@reduxjs/toolkit" -import { createAppSlice } from "../../app/createAppSlice" -import type { AppThunk } from "../../app/store" -import { fetchCount } from "./counterAPI" - -export interface CounterSliceState { - value: number - status: "idle" | "loading" | "failed" -} - -const initialState: CounterSliceState = { - value: 0, - status: "idle", -} - -// If you are not using async thunks you can use the standalone `createSlice`. -export const counterSlice = createAppSlice({ - name: "counter", - // `createSlice` will infer the state type from the `initialState` argument - initialState, - // The `reducers` field lets us define reducers and generate associated actions - reducers: create => ({ - increment: create.reducer(state => { - // Redux Toolkit allows us to write "mutating" logic in reducers. It - // doesn't actually mutate the state because it uses the Immer library, - // which detects changes to a "draft state" and produces a brand new - // immutable state based off those changes - state.value += 1 - }), - decrement: create.reducer(state => { - state.value -= 1 - }), - // Use the `PayloadAction` type to declare the contents of `action.payload` - incrementByAmount: create.reducer( - (state, action: PayloadAction) => { - state.value += action.payload - }, - ), - // The function below is called a thunk and allows us to perform async logic. It - // can be dispatched like a regular action: `dispatch(incrementAsync(10))`. This - // will call the thunk with the `dispatch` function as the first argument. Async - // code can then be executed and other actions can be dispatched. Thunks are - // typically used to make async requests. - incrementAsync: create.asyncThunk( - async (amount: number) => { - const response = await fetchCount(amount) - // The value we return becomes the `fulfilled` action payload - return response.data - }, - { - pending: state => { - state.status = "loading" - }, - fulfilled: (state, action) => { - state.status = "idle" - state.value += action.payload - }, - rejected: state => { - state.status = "failed" - }, - }, - ), - }), - // You can define your selectors here. These selectors receive the slice - // state as their first argument. - selectors: { - selectCount: counter => counter.value, - selectStatus: counter => counter.status, - }, -}) - -// Action creators are generated for each case reducer function. -export const { decrement, increment, incrementByAmount, incrementAsync } = - counterSlice.actions - -// Selectors returned by `slice.selectors` take the root state as their first argument. -export const { selectCount, selectStatus } = counterSlice.selectors - -// We can also write thunks by hand, which may contain both sync and async logic. -// Here's an example of conditionally dispatching actions based on current state. -export const incrementIfOdd = - (amount: number): AppThunk => - (dispatch, getState) => { - const currentValue = selectCount(getState()) - - if (currentValue % 2 === 1 || currentValue % 2 === -1) { - dispatch(incrementByAmount(amount)) - } - } diff --git a/src/features/quotes/Quotes.module.css b/src/features/quotes/Quotes.module.css deleted file mode 100644 index 1f85690..0000000 --- a/src/features/quotes/Quotes.module.css +++ /dev/null @@ -1,20 +0,0 @@ -.select { - font-size: 25px; - padding: 5px; - padding-top: 2px; - padding-bottom: 2px; - size: 50; - outline: none; - border: 2px solid transparent; - color: rgb(112, 76, 182); - cursor: pointer; - background-color: rgba(112, 76, 182, 0.1); - border-radius: 5px; - transition: all 0.15s; -} - -.container { - display: flex; - flex-direction: column; - align-items: center; -} diff --git a/src/features/quotes/Quotes.tsx b/src/features/quotes/Quotes.tsx deleted file mode 100644 index c490c4a..0000000 --- a/src/features/quotes/Quotes.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { useState } from "react" -import styles from "./Quotes.module.css" -import { useGetQuotesQuery } from "./quotesApiSlice" - -const options = [5, 10, 20, 30] - -export const Quotes = () => { - const [numberOfQuotes, setNumberOfQuotes] = useState(10) - // Using a query hook automatically fetches data and returns query values - const { data, isError, isLoading, isSuccess } = - useGetQuotesQuery(numberOfQuotes) - - if (isError) { - return ( -
-

There was an error!!!

-
- ) - } - - if (isLoading) { - return ( -
-

Loading...

-
- ) - } - - if (isSuccess) { - return ( -
-

Select the Quantity of Quotes to Fetch:

- - {data.quotes.map(({ author, quote, id }) => ( -
- “{quote}” -
- {author} -
-
- ))} -
- ) - } - - return null -} diff --git a/src/features/quotes/quotesApiSlice.ts b/src/features/quotes/quotesApiSlice.ts deleted file mode 100644 index a1c7b5a..0000000 --- a/src/features/quotes/quotesApiSlice.ts +++ /dev/null @@ -1,38 +0,0 @@ -// Need to use the React-specific entry point to import `createApi` -import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react" - -interface Quote { - id: number - quote: string - author: string -} - -interface QuotesApiResponse { - quotes: Quote[] - total: number - skip: number - limit: number -} - -// Define a service using a base URL and expected endpoints -export const quotesApiSlice = createApi({ - baseQuery: fetchBaseQuery({ baseUrl: "https://dummyjson.com/quotes" }), - reducerPath: "quotesApi", - // Tag types are used for caching and invalidation. - tagTypes: ["Quotes"], - endpoints: build => ({ - // Supply generics for the return type (in this case `QuotesApiResponse`) - // and the expected query argument. If there is no argument, use `void` - // for the argument type instead. - getQuotes: build.query({ - query: (limit = 10) => `?limit=${limit}`, - // `providesTags` determines which 'tag' is attached to the - // cached data returned by the query. - providesTags: (result, error, id) => [{ type: "Quotes", id }], - }), - }), -}) - -// Hooks are auto-generated by RTK-Query -// Same as `quotesApiSlice.endpoints.getQuotes.useQuery` -export const { useGetQuotesQuery } = quotesApiSlice diff --git a/src/index.css b/src/index.css deleted file mode 100644 index 4a1df4d..0000000 --- a/src/index.css +++ /dev/null @@ -1,13 +0,0 @@ -body { - margin: 0; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", - "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", - sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -code { - font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", - monospace; -} diff --git a/src/logo.svg b/src/logo.svg deleted file mode 100644 index 8466738..0000000 --- a/src/logo.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/main.tsx b/src/main.tsx index 45c0705..ef92ba8 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,9 +1,9 @@ import React from "react" import { createRoot } from "react-dom/client" import { Provider } from "react-redux" + import App from "./App" -import { store } from "./app/store" -import "./index.css" +import store from "./app/store" const container = document.getElementById("root") diff --git a/src/pages/login/BaseForm.tsx b/src/pages/login/BaseForm.tsx new file mode 100644 index 0000000..eb516bf --- /dev/null +++ b/src/pages/login/BaseForm.tsx @@ -0,0 +1,45 @@ +import { Stack, Typography, useTheme } from "@mui/material" +import { type FormikValues } from "formik" + +import { Form, type FormProps } from "codeforlife/components/form" +import { ThemedBox, type ThemedBoxProps } from "codeforlife/theme" + +import { themeOptions } from "../../app/theme" + +export interface BaseFormProps extends FormProps { + themedBoxProps: Omit + header: string + subheader?: string +} + +const BaseForm = ({ + themedBoxProps, + header, + subheader, + ...formProps +}: BaseFormProps): JSX.Element => { + const theme = useTheme() + + return ( + + + + {header} + + {subheader && ( + + {subheader} + + )} +
+ + + ) +} + +export default BaseForm diff --git a/src/pages/login/IndyForm.tsx b/src/pages/login/IndyForm.tsx new file mode 100644 index 0000000..7eb1209 --- /dev/null +++ b/src/pages/login/IndyForm.tsx @@ -0,0 +1,60 @@ +import { Stack, Typography } from "@mui/material" +import type { FC } from "react" + +import * as form from "codeforlife/components/form" +import { Link } from "codeforlife/components/router" +import { useNavigate } from "codeforlife/hooks" +import { submitForm } from "codeforlife/utils/form" + +import { useLoginWithEmailMutation } from "../../api/sso" +import { paths } from "../../router" +import BaseForm from "./BaseForm" + +export interface IndyFormProps {} + +const IndyForm: FC = () => { + const [loginWithEmail] = useLoginWithEmailMutation() + const navigate = useNavigate() + + return ( + { + navigate(paths.indy.dashboard._) + }, + })} + > + + + + + Forgotten your password? + + + Don't worry, you can  + + reset your password + + . + + + + + Part of a school or club?  + + Log in here + + + + + Log in + + + ) +} + +export default IndyForm diff --git a/src/pages/login/Login.test.tsx b/src/pages/login/Login.test.tsx new file mode 100644 index 0000000..0eceb8c --- /dev/null +++ b/src/pages/login/Login.test.tsx @@ -0,0 +1,2 @@ +// TODO: replace with proper tests +test("Dummy test", async () => {}) diff --git a/src/pages/login/Login.tsx b/src/pages/login/Login.tsx new file mode 100644 index 0000000..0bafb8b --- /dev/null +++ b/src/pages/login/Login.tsx @@ -0,0 +1,83 @@ +import { useEffect, type FC } from "react" +import * as yup from "yup" + +import * as page from "codeforlife/components/page" +import { + useNavigate, + useSearchParamEntries, + useSessionMetadata, +} from "codeforlife/hooks" +import { tryValidateSync } from "codeforlife/utils/schema" + +import { paths } from "../../router" +import IndyForm from "./IndyForm" +import * as studentForms from "./studentForms" +import * as teacherForms from "./teacherForms" + +export interface LoginProps { + form: + | "teacher-email" + | "teacher-otp" + | "teacher-otp-bypass-token" + | "student-class" + | "student-first-name" + | "indy" +} + +const Login: FC = ({ form }) => { + const sessionMetadata = useSessionMetadata() + const navigate = useNavigate() + + const searchParams = tryValidateSync( + useSearchParamEntries(), + yup.object({ + verifyEmail: yup.boolean().default(false), + }), + ) + + useEffect(() => { + if (sessionMetadata) { + if ( + sessionMetadata.user_type === "teacher" && + sessionMetadata.auth_factors.includes("otp") && + form !== "teacher-otp" && + form !== "teacher-otp-bypass-token" + ) { + navigate(paths.login.teacher.otp._, { replace: true }) + } else { + navigate( + { + teacher: paths.teacher.dashboard.school._, + student: paths.student.dashboard._, + indy: paths.indy.dashboard._, + }[sessionMetadata.user_type], + { replace: true }, + ) + } + } + }, [sessionMetadata, navigate, form]) + + return ( + + {searchParams?.verifyEmail && ( + + Your email address was successfully verified, please log in. + + )} + + { + { + "teacher-email": , + "teacher-otp": , + "teacher-otp-bypass-token": , + "student-class": , + "student-first-name": , + indy: , + }[form] + } + + + ) +} + +export default Login diff --git a/src/pages/login/studentForms/Class.tsx b/src/pages/login/studentForms/Class.tsx new file mode 100644 index 0000000..9ff4e4e --- /dev/null +++ b/src/pages/login/studentForms/Class.tsx @@ -0,0 +1,93 @@ +import { ChevronRight as ChevronRightIcon } from "@mui/icons-material" +import { Stack, Typography } from "@mui/material" +import { useEffect, type FC } from "react" +import { generatePath } from "react-router-dom" +import * as yup from "yup" + +import * as form from "codeforlife/components/form" +import { useNavigate, useSearchParamEntries } from "codeforlife/hooks" +import { tryValidateSync } from "codeforlife/utils/schema" + +import { useAutoLoginAsStudentMutation } from "../../../api/sso" +import { classIdSchema } from "../../../app/schemas" +import { paths } from "../../../router" +import BaseForm from "../BaseForm" + +export interface ClassProps {} + +const Class: FC = () => { + const [autoLoginAsStudent] = useAutoLoginAsStudentMutation() + const navigate = useNavigate() + + const searchParams = tryValidateSync( + useSearchParamEntries(), + yup.object({ + id: yup.number().required(), + agp: yup.string().required(), + }), + ) + + useEffect(() => { + if (searchParams) { + autoLoginAsStudent({ + student_id: searchParams.id, + auto_gen_password: searchParams.agp, + }) + .unwrap() + .then(() => { + navigate(paths.student.dashboard._) + }) + .catch(() => { + navigate(".", { + replace: true, + state: { + notifications: [ + { + props: { + error: true, + children: + "Failed to automatically log in student. Please log" + + "in manually.", + }, + }, + ], + }, + }) + }) + } + }, [searchParams, autoLoginAsStudent, navigate]) + + return ( + <> + {!searchParams && ( + { + navigate(generatePath(paths.login.student.class._, { classId })) + }} + > + + + Forgotten your login details? Please check with your teacher. + + + }> + Next + + + + )} + + ) +} + +export default Class diff --git a/src/pages/login/studentForms/FirstName.tsx b/src/pages/login/studentForms/FirstName.tsx new file mode 100644 index 0000000..b1aa6bf --- /dev/null +++ b/src/pages/login/studentForms/FirstName.tsx @@ -0,0 +1,76 @@ +import { ChevronRight as ChevronRightIcon } from "@mui/icons-material" +import { Stack } from "@mui/material" +import { useEffect, type FC } from "react" +import { useParams } from "react-router-dom" +import * as yup from "yup" + +import * as form from "codeforlife/components/form" +import { useNavigate } from "codeforlife/hooks" +import { submitForm } from "codeforlife/utils/form" +import { tryValidateSync } from "codeforlife/utils/schema" + +import { useLoginAsStudentMutation } from "../../../api/sso" +import { classIdSchema } from "../../../app/schemas" +import { paths } from "../../../router" +import BaseForm from "../BaseForm" + +export interface FirstNameProps {} + +const FirstName: FC = () => { + const [loginAsStudent] = useLoginAsStudentMutation() + const navigate = useNavigate() + + const params = tryValidateSync( + useParams(), + yup.object({ classId: classIdSchema.required() }), + ) + + useEffect(() => { + if (!params) { + navigate(paths.login.student._, { + state: { + notifications: [ + { + props: { + error: true, + children: "Please provide the correct code for your class.", + }, + }, + ], + }, + }) + } + }, [navigate, params]) + + return ( + <> + {params && ( + { + navigate(paths.student.dashboard._) + }, + })} + > + + + + }> + Log in + + + + )} + + ) +} + +export default FirstName diff --git a/src/pages/login/studentForms/index.tsx b/src/pages/login/studentForms/index.tsx new file mode 100644 index 0000000..662cdb3 --- /dev/null +++ b/src/pages/login/studentForms/index.tsx @@ -0,0 +1,4 @@ +import Class, { type ClassProps } from "./Class" +import FirstName, { type FirstNameProps } from "./FirstName" + +export { Class, FirstName, type ClassProps, type FirstNameProps } diff --git a/src/pages/login/teacherForms/Email.tsx b/src/pages/login/teacherForms/Email.tsx new file mode 100644 index 0000000..2e742f1 --- /dev/null +++ b/src/pages/login/teacherForms/Email.tsx @@ -0,0 +1,53 @@ +import { Stack, Typography } from "@mui/material" +import type { FC } from "react" + +import * as form from "codeforlife/components/form" +import { Link } from "codeforlife/components/router" +import { useNavigate } from "codeforlife/hooks" +import { submitForm } from "codeforlife/utils/form" + +import { useLoginWithEmailMutation } from "../../../api/sso" +import { paths } from "../../../router" +import BaseForm from "../BaseForm" + +export interface EmailProps {} + +const Email: FC = () => { + const [loginWithEmail] = useLoginWithEmailMutation() + const navigate = useNavigate() + + return ( + { + navigate( + auth_factors.includes("otp") + ? paths.login.teacher.otp._ + : paths.teacher.dashboard.school._, + ) + }, + })} + > + + + + + Forgotten your password? + + + Don't worry, you can  + reset your password. + + + + Log in + + + ) +} + +export default Email diff --git a/src/pages/login/teacherForms/Otp.tsx b/src/pages/login/teacherForms/Otp.tsx new file mode 100644 index 0000000..72397d8 --- /dev/null +++ b/src/pages/login/teacherForms/Otp.tsx @@ -0,0 +1,53 @@ +import { Stack } from "@mui/material" +import { type FC } from "react" + +import * as form from "codeforlife/components/form" +import { LinkButton } from "codeforlife/components/router" +import { useNavigate, useSession } from "codeforlife/hooks" +import { submitForm } from "codeforlife/utils/form" + +import { useLoginWithOtpMutation } from "../../../api/sso" +import { paths } from "../../../router" +import BaseForm from "../BaseForm" + +export interface OtpProps {} + +const Otp: FC = () => { + const [loginWithOtp] = useLoginWithOtpMutation() + const navigate = useNavigate() + + return useSession( + ({ otp_bypass_token_exists }) => ( + { + navigate(paths.teacher.dashboard.school._) + }, + })} + > + + {otp_bypass_token_exists && ( + + Use an otp-bypass token + + )} + + + Cancel + + Log in + + + ), + { userType: "teacher", next: false }, + ) +} + +export default Otp diff --git a/src/pages/login/teacherForms/OtpBypassToken.tsx b/src/pages/login/teacherForms/OtpBypassToken.tsx new file mode 100644 index 0000000..4a8cb8d --- /dev/null +++ b/src/pages/login/teacherForms/OtpBypassToken.tsx @@ -0,0 +1,61 @@ +import { Stack, Typography, useTheme } from "@mui/material" +import type { FC } from "react" +import * as yup from "yup" + +import * as form from "codeforlife/components/form" +import { LinkButton } from "codeforlife/components/router" +import { useNavigate, useSession } from "codeforlife/hooks" +import { submitForm } from "codeforlife/utils/form" + +import { useLoginWithOtpBypassTokenMutation } from "../../../api/sso" +import { paths } from "../../../router" +import BaseForm from "../BaseForm" + +export interface OtpBypassTokenProps {} + +const OtpBypassToken: FC = () => { + const [loginWithOtpBypassToken] = useLoginWithOtpBypassTokenMutation() + const navigate = useNavigate() + const theme = useTheme() + + return useSession( + { + navigate(paths.teacher.dashboard.school._) + }, + })} + > + + Use this form for entering backup tokens for logging in. These tokens + have been generated for you to print and keep safe. Please enter one of + these backup tokens to login to your account. + + + + + Cancel + + Log in + + , + { userType: "teacher", next: false }, + ) +} + +export default OtpBypassToken diff --git a/src/pages/login/teacherForms/index.tsx b/src/pages/login/teacherForms/index.tsx new file mode 100644 index 0000000..78d66a2 --- /dev/null +++ b/src/pages/login/teacherForms/index.tsx @@ -0,0 +1,12 @@ +import Email, { type EmailProps } from "./Email" +import Otp, { type OtpProps } from "./Otp" +import OtpBypassToken, { type OtpBypassTokenProps } from "./OtpBypassToken" + +export { + Email, + Otp, + OtpBypassToken, + type EmailProps, + type OtpBypassTokenProps, + type OtpProps, +} diff --git a/src/router/Router.tsx b/src/router/Router.tsx new file mode 100644 index 0000000..5758aab --- /dev/null +++ b/src/router/Router.tsx @@ -0,0 +1,28 @@ +import type { FC } from "react" +import { BrowserRouter, Routes } from "react-router-dom" + +// import Header from '../../features/header/Header'; +// import Footer from '../../features/footer/Footer'; +// import general from './routes/general'; +import authentication from "./routes/authentication" +// import teacher from './routes/teacher'; +// import student from './routes/student'; +// import error from './routes/error'; + +export interface RouterProps {} + +const Router: FC = () => ( + + {/*
*/} + + {/* {general} */} + {authentication} + {/* {teacher} */} + {/* {student} */} + {/* {error} */} {/* this must be last */} + + {/*