diff --git a/client/.eslintrc.json b/client/.eslintrc.json
index 194146f..0417aa6 100644
--- a/client/.eslintrc.json
+++ b/client/.eslintrc.json
@@ -1,21 +1,28 @@
{
- "env": {
- "browser": true,
- "es2021": true
- },
- "extends": [
- "eslint:recommended",
- "plugin:@typescript-eslint/recommended",
- "plugin:react/jsx-runtime",
- "prettier"
- ],
- "parser": "@typescript-eslint/parser",
- "parserOptions": {
- "ecmaVersion": "latest",
- "sourceType": "module"
- },
- "plugins": [
- "@typescript-eslint",
- "react"
+ "root": true,
+ "env": {
+ "browser": true,
+ "es2021": true
+ },
+ "extends": [
+ "eslint:recommended",
+ "plugin:@typescript-eslint/recommended",
+ "plugin:react/jsx-runtime",
+ "prettier"
+ ],
+ "parser": "@typescript-eslint/parser",
+ "parserOptions": {
+ "ecmaVersion": "latest",
+ "sourceType": "module",
+ "project": "./tsconfig.json"
+ },
+ "plugins": [
+ "@typescript-eslint",
+ "react"
+ ],
+ "rules": {
+ "@typescript-eslint/no-floating-promises": [
+ "error"
]
+ }
}
diff --git a/client/package-lock.json b/client/package-lock.json
index fffb7e4..19a6901 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -9,11 +9,16 @@
"version": "0.0.1",
"dependencies": {
"react": "^18.2.0",
- "react-dom": "^18.2.0"
+ "react-dom": "^18.2.0",
+ "react-router-dom": "^6.21.0",
+ "zod": "^3.22.4"
},
"devDependencies": {
+ "@parcel/transformer-sass": "^2.10.3",
"@parcel/transformer-typescript-tsc": "^2.10.3",
"@parcel/validator-typescript": "^2.10.3",
+ "@types/eslint": "^8.44.9",
+ "@types/eslint-config-prettier": "^6.11.3",
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0",
@@ -1638,6 +1643,25 @@
"url": "https://opencollective.com/parcel"
}
},
+ "node_modules/@parcel/transformer-sass": {
+ "version": "2.10.3",
+ "resolved": "https://registry.npmjs.org/@parcel/transformer-sass/-/transformer-sass-2.10.3.tgz",
+ "integrity": "sha512-Q8pTsMO+YuczBjmW8NdFcUjYpCdvY6EzYhPYw4eTyvldalGkaUVs1mJoKmWDHIDsIpdiaARxTpXP946B9cgE3w==",
+ "dev": true,
+ "dependencies": {
+ "@parcel/plugin": "2.10.3",
+ "@parcel/source-map": "^2.1.1",
+ "sass": "^1.38.0"
+ },
+ "engines": {
+ "node": ">= 12.0.0",
+ "parcel": "^2.10.3"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
"node_modules/@parcel/transformer-svg": {
"version": "2.10.3",
"resolved": "https://registry.npmjs.org/@parcel/transformer-svg/-/transformer-svg-2.10.3.tgz",
@@ -2063,6 +2087,14 @@
"@parcel/core": "^2.10.3"
}
},
+ "node_modules/@remix-run/router": {
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.14.0.tgz",
+ "integrity": "sha512-WOHih+ClN7N8oHk9N4JUiMxQJmRVaOxcg8w7F/oHUXzJt920ekASLI/7cYX8XkntDWRhLZtsk6LbGrkgOAvi5A==",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/@swc/core": {
"version": "1.3.100",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.3.100.tgz",
@@ -2274,6 +2306,28 @@
"node": ">=10.13.0"
}
},
+ "node_modules/@types/eslint": {
+ "version": "8.44.9",
+ "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.44.9.tgz",
+ "integrity": "sha512-6yBxcvwnnYoYT1Uk2d+jvIfsuP4mb2EdIxFnrPABj5a/838qe5bGkNLFOiipX4ULQ7XVQvTxOh7jO+BTAiqsEw==",
+ "dev": true,
+ "dependencies": {
+ "@types/estree": "*",
+ "@types/json-schema": "*"
+ }
+ },
+ "node_modules/@types/eslint-config-prettier": {
+ "version": "6.11.3",
+ "resolved": "https://registry.npmjs.org/@types/eslint-config-prettier/-/eslint-config-prettier-6.11.3.tgz",
+ "integrity": "sha512-3wXCiM8croUnhg9LdtZUJQwNcQYGWxxdOWDjPe1ykCqJFPVpzAKfs/2dgSoCtAvdPeaponcWPI7mPcGGp9dkKQ==",
+ "dev": true
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz",
+ "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==",
+ "dev": true
+ },
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2580,6 +2634,19 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -2733,6 +2800,15 @@
"safe-buffer": "^5.0.1"
}
},
+ "node_modules/binary-extensions": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
+ "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
@@ -2852,6 +2928,45 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
+ "node_modules/chokidar": {
+ "version": "3.5.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
+ "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ ],
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/chokidar/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/chrome-trace-event": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz",
@@ -3772,6 +3887,20 @@
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"dev": true
},
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -4104,6 +4233,12 @@
"node": ">= 4"
}
},
+ "node_modules/immutable": {
+ "version": "4.3.4",
+ "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.4.tgz",
+ "integrity": "sha512-fsXeu4J4i6WNWSikpI88v/PcVflZz+6kMhUfIwc5SY+poQRPnaf5V7qds6SUyUN3cVxEzuCab7QIoLOQ+DQ1wA==",
+ "dev": true
+ },
"node_modules/import-fresh": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
@@ -4206,6 +4341,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-boolean-object": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz",
@@ -5020,6 +5167,15 @@
"integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==",
"dev": true
},
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
@@ -5500,6 +5656,48 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-router": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.21.0.tgz",
+ "integrity": "sha512-hGZ0HXbwz3zw52pLZV3j3+ec+m/PQ9cTpBvqjFQmy2XVUWGn5MD+31oXHb6dVTxYzmAeaiUBYjkoNz66n3RGCg==",
+ "dependencies": {
+ "@remix-run/router": "1.14.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8"
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.21.0.tgz",
+ "integrity": "sha512-1dUdVj3cwc1npzJaf23gulB562ESNvxf7E4x8upNJycqyUm5BRRZ6dd3LrlzhtLaMrwOCO8R0zoiYxdaJx4LlQ==",
+ "dependencies": {
+ "@remix-run/router": "1.14.0",
+ "react-router": "6.21.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8",
+ "react-dom": ">=16.8"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
"node_modules/reflect.getprototypeof": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz",
@@ -5669,6 +5867,23 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/sass": {
+ "version": "1.69.5",
+ "resolved": "https://registry.npmjs.org/sass/-/sass-1.69.5.tgz",
+ "integrity": "sha512-qg2+UCJibLr2LCVOt3OlPhr/dqVHWOa9XtZf2OjbLs/T4VPSJ00udtgJxH3neXZm+QqX8B+3cU7RaLqp1iVfcQ==",
+ "dev": true,
+ "dependencies": {
+ "chokidar": ">=3.0.0 <4.0.0",
+ "immutable": "^4.0.0",
+ "source-map-js": ">=0.6.2 <2.0.0"
+ },
+ "bin": {
+ "sass": "sass.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/scheduler": {
"version": "0.23.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz",
@@ -5779,8 +5994,6 @@
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
"integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
"dev": true,
- "optional": true,
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -6283,6 +6496,14 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
+ },
+ "node_modules/zod": {
+ "version": "3.22.4",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz",
+ "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
}
}
}
diff --git a/client/package.json b/client/package.json
index 4369082..6659bca 100644
--- a/client/package.json
+++ b/client/package.json
@@ -11,11 +11,16 @@
"browserslist": "> 0.5%, last 2 versions, not dead",
"dependencies": {
"react": "^18.2.0",
- "react-dom": "^18.2.0"
+ "react-dom": "^18.2.0",
+ "react-router-dom": "^6.21.0",
+ "zod": "^3.22.4"
},
"devDependencies": {
+ "@parcel/transformer-sass": "^2.10.3",
"@parcel/transformer-typescript-tsc": "^2.10.3",
"@parcel/validator-typescript": "^2.10.3",
+ "@types/eslint": "^8.44.9",
+ "@types/eslint-config-prettier": "^6.11.3",
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0",
diff --git a/client/src/App.scss b/client/src/App.scss
new file mode 100644
index 0000000..6779299
--- /dev/null
+++ b/client/src/App.scss
@@ -0,0 +1,6 @@
+#appRoot {
+ font-family: sans-serif;
+ max-width: 50rem;
+ margin-left: auto;
+ margin-right: auto;
+}
\ No newline at end of file
diff --git a/client/src/App.tsx b/client/src/App.tsx
index 1b317bf..f1b35da 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -1,3 +1,75 @@
+import React from "react";
+import {
+ createBrowserRouter,
+ redirect,
+ RouterProvider,
+} from "react-router-dom";
+
+import "./App.scss";
+import { deleteToken, getToken } from "./api/utils";
+import { Login } from "./Login";
+import { Signup } from "./Signup";
+import { Home } from "./Home";
+import { loginAction, signupAction } from "./actions";
+import { homeLoader } from "./loaders";
+import { isError } from "./api/dto";
+
+const router = createBrowserRouter([
+ {
+ path: "/",
+ loader: async () => {
+ if (getToken() == null) {
+ return redirect("/login");
+ } else {
+ return redirect("/home");
+ }
+ },
+ },
+ {
+ path: "/home",
+ loader: async () => {
+ if (getToken() == null) {
+ return redirect("/login");
+ }
+ const ret = await homeLoader();
+ if (isError(ret)) {
+ deleteToken();
+ return redirect("/");
+ }
+ return ret;
+ },
+ element: ,
+ },
+ {
+ path: "/login",
+ element: ,
+ loader: async () => {
+ if (getToken()) {
+ return redirect("/");
+ }
+ return null;
+ },
+ action: loginAction,
+ },
+ {
+ path: "/signup",
+ element: ,
+ loader: async () => {
+ if (getToken()) {
+ return redirect("/");
+ }
+ return null;
+ },
+ action: signupAction,
+ },
+]);
+
export function App() {
- return
Hello worlffffd!
;
+ return (
+
+
+
+
+
+ );
}
diff --git a/client/src/Auth.scss b/client/src/Auth.scss
new file mode 100644
index 0000000..33f288f
--- /dev/null
+++ b/client/src/Auth.scss
@@ -0,0 +1,4 @@
+
+.authForm {
+
+}
\ No newline at end of file
diff --git a/client/src/Home.tsx b/client/src/Home.tsx
new file mode 100644
index 0000000..f76fe5d
--- /dev/null
+++ b/client/src/Home.tsx
@@ -0,0 +1,21 @@
+import { Outlet, useLoaderData } from "react-router-dom";
+import { homeLoader } from "./loaders";
+import { isError } from "./api/dto";
+
+export function Home() {
+ const loaderData = useLoaderData() as
+ | Awaited>
+ | undefined;
+
+ if (!loaderData || isError(loaderData)) {
+ return Error
;
+ }
+
+ return (
+ <>
+ username: {loaderData.username}
+ name: {loaderData.fullName}
+
+ >
+ );
+}
diff --git a/client/src/Login.tsx b/client/src/Login.tsx
new file mode 100644
index 0000000..89dae46
--- /dev/null
+++ b/client/src/Login.tsx
@@ -0,0 +1,41 @@
+import "./Auth.scss";
+import { Form, Link, useActionData, useNavigation } from "react-router-dom";
+import { loginAction } from "./actions";
+import { isError } from "./api/dto";
+
+export function Login() {
+ const data = useActionData() as
+ | Awaited>
+ | undefined;
+
+ if (data && !isError(data)) {
+ return Success, now log in!;
;
+ }
+
+ let errors: JSX.Element[] = [];
+
+ if (data) {
+ errors = data.errors.map((e) => {
+ return {e};
+ });
+ }
+
+ const navigation = useNavigation();
+ const busy = navigation.state === "submitting";
+
+ return (
+
+ {errors}
+
+
+ );
+}
diff --git a/client/src/Signup.tsx b/client/src/Signup.tsx
new file mode 100644
index 0000000..b88497b
--- /dev/null
+++ b/client/src/Signup.tsx
@@ -0,0 +1,43 @@
+import "./Auth.scss";
+import { Form, Link, useActionData, useNavigation } from "react-router-dom";
+import { signupAction } from "./actions";
+import { isError } from "./api/dto";
+
+export function Signup() {
+ const data = useActionData() as
+ | Awaited>
+ | undefined;
+
+ if (data && !isError(data)) {
+ return Success, now log in!;
;
+ }
+
+ let errors: JSX.Element[] = [];
+
+ if (data) {
+ errors = data.errors.map((e) => {
+ return {e};
+ });
+ }
+
+ const navigation = useNavigation();
+ const busy = navigation.state === "submitting";
+
+ return (
+
+ {errors}
+
+
+ );
+}
diff --git a/client/src/actions.ts b/client/src/actions.ts
new file mode 100644
index 0000000..ece4b3c
--- /dev/null
+++ b/client/src/actions.ts
@@ -0,0 +1,29 @@
+import { signup } from "./api/Person";
+import { ActionFunctionArgs, redirect } from "react-router-dom";
+import { login } from "./api/Token";
+import { isError } from "./api/dto";
+import { setToken } from "./api/utils";
+
+export async function loginAction({ request }: ActionFunctionArgs) {
+ const formData = await request.formData();
+ const ret = await login(
+ formData.get("username")!.toString(),
+ formData.get("password")!.toString(),
+ );
+
+ if (!isError(ret)) {
+ setToken(ret.token);
+ return redirect("/home");
+ }
+
+ return ret;
+}
+
+export async function signupAction({ request }: ActionFunctionArgs) {
+ const formData = await request.formData();
+ return await signup(
+ formData.get("username")!.toString(),
+ formData.get("fullName")!.toString(),
+ formData.get("password")!.toString(),
+ );
+}
diff --git a/client/src/api/Person.ts b/client/src/api/Person.ts
new file mode 100644
index 0000000..3135234
--- /dev/null
+++ b/client/src/api/Person.ts
@@ -0,0 +1,18 @@
+import { fetchJSON, fetchJSONAuth } from "./utils";
+import { PersonToResp, TPersonToResp } from "./dto";
+
+export async function signup(
+ username: string,
+ fullName: string,
+ password: string,
+): Promise {
+ return fetchJSON("/person", "POST", PersonToResp, {
+ username,
+ fullName,
+ password,
+ });
+}
+
+export async function getSelf(): Promise {
+ return fetchJSONAuth("/person", "GET", PersonToResp);
+}
diff --git a/client/src/api/Token.ts b/client/src/api/Token.ts
new file mode 100644
index 0000000..a36336e
--- /dev/null
+++ b/client/src/api/Token.ts
@@ -0,0 +1,12 @@
+import { TokenToResp, TTokenToResp } from "./dto";
+import { fetchJSON } from "./utils";
+
+export async function login(
+ username: string,
+ password: string,
+): Promise {
+ return fetchJSON("/token", "POST", TokenToResp, {
+ username,
+ password,
+ });
+}
diff --git a/client/src/api/dto.ts b/client/src/api/dto.ts
new file mode 100644
index 0000000..7a40e3b
--- /dev/null
+++ b/client/src/api/dto.ts
@@ -0,0 +1,46 @@
+import { z } from "zod";
+
+export const ErrorTo = z.object({
+ errors: z.array(z.string()),
+ code: z.number(),
+});
+export type TErrorTo = z.infer;
+
+function CreateAPIResponse(obj: T) {
+ return z.union([ErrorTo, obj]);
+}
+
+export const PersonSignupTo = z.object({
+ username: z.string(),
+ fullName: z.string(),
+ password: z.string(),
+});
+export type TPersonSignupTo = z.infer;
+
+export const PersonTo = z.object({
+ uuid: z.string(),
+ username: z.string(),
+ fullName: z.string(),
+});
+export type TPersonTo = z.infer;
+
+export const PersonToResp = CreateAPIResponse(PersonTo);
+export type TPersonToResp = z.infer;
+
+export const TokenRequestTo = z.object({
+ username: z.string(),
+ password: z.string(),
+});
+export type TTokenRequestTo = z.infer;
+
+export const TokenTo = z.object({
+ token: z.string(),
+});
+export type TTokenTo = z.infer;
+
+export const TokenToResp = CreateAPIResponse(TokenTo);
+export type TTokenToResp = z.infer;
+
+export function isError(value: unknown): value is TErrorTo {
+ return ErrorTo.safeParse(value).success;
+}
diff --git a/client/src/api/utils.ts b/client/src/api/utils.ts
new file mode 100644
index 0000000..0ad738f
--- /dev/null
+++ b/client/src/api/utils.ts
@@ -0,0 +1,68 @@
+// import { apiRoot } from "~src/env";
+
+const apiRoot: string = "http://localhost:8080";
+
+let token: string | null;
+
+export function setToken(_token: string): void {
+ token = _token;
+ localStorage.setItem("jwt_token", token);
+}
+
+export function getToken(): string | null {
+ if (!token && localStorage.getItem("jwt_token") != null) {
+ token = localStorage.getItem("jwt_token");
+ }
+ return token;
+}
+
+export function deleteToken(): void {
+ token = null;
+ localStorage.removeItem("jwt_token");
+}
+
+export async function fetchJSON T }>(
+ path: string,
+ method: string,
+ parser: P,
+ body?: string | Record | File,
+ headers?: Record,
+): Promise {
+ const reqBody = () =>
+ body instanceof File
+ ? (() => {
+ const fd = new FormData();
+ fd.append("file", body);
+ return fd;
+ })()
+ : JSON.stringify(body);
+
+ const reqHeaders = () =>
+ body instanceof File
+ ? headers
+ : { ...headers, "Content-Type": "application/json" };
+
+ const response = await fetch(apiRoot + path, {
+ method,
+ headers: reqHeaders(),
+ body: reqBody(),
+ });
+ return parser.parse(await response.json());
+}
+
+export async function fetchJSONAuth T }>(
+ path: string,
+ method: string,
+ parser: P,
+ body?: string | Record | File,
+ headers?: Record,
+): Promise {
+ if (token) {
+ return fetchJSON(path, method, parser, body, {
+ ...headers,
+ Authorization: `Bearer ${token}`,
+ });
+ } else {
+ throw new Error("Not logged in");
+ }
+}
diff --git a/client/src/index.html b/client/src/index.html
index ab51a94..b3b7946 100644
--- a/client/src/index.html
+++ b/client/src/index.html
@@ -2,7 +2,7 @@
- Hello world
+ Y
diff --git a/client/src/loaders.ts b/client/src/loaders.ts
new file mode 100644
index 0000000..4c3645e
--- /dev/null
+++ b/client/src/loaders.ts
@@ -0,0 +1,5 @@
+import { getSelf } from "./api/Person";
+
+export async function homeLoader() {
+ return await getSelf();
+}
diff --git a/server/build.gradle b/server/build.gradle
index 110af72..3caf017 100644
--- a/server/build.gradle
+++ b/server/build.gradle
@@ -27,6 +27,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
+ implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.security:spring-security-test'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
@@ -34,6 +35,8 @@ dependencies {
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
+ // For TestRestTemplate as default client can't handle UNAUTHORIZED response
+ testImplementation 'org.apache.httpcomponents.client5:httpclient5:5.3'
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
diff --git a/server/src/main/java/com/usatiuk/tjv/y/server/controller/ApiExceptionHandler.java b/server/src/main/java/com/usatiuk/tjv/y/server/controller/ApiExceptionHandler.java
new file mode 100644
index 0000000..0669041
--- /dev/null
+++ b/server/src/main/java/com/usatiuk/tjv/y/server/controller/ApiExceptionHandler.java
@@ -0,0 +1,22 @@
+package com.usatiuk.tjv.y.server.controller;
+
+import com.usatiuk.tjv.y.server.dto.ErrorTo;
+import jakarta.validation.ConstraintViolation;
+import jakarta.validation.ConstraintViolationException;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ControllerAdvice;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.context.request.WebRequest;
+import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
+
+@ControllerAdvice
+public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
+ @ExceptionHandler(value = {ConstraintViolationException.class})
+ protected ResponseEntity