diff --git a/web/src/App.tsx b/web/src/App.tsx index 6c8f751..e95ac6a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,10 +1,11 @@ -import { type FC } from "react"; +import { useEffect, type FC } from "react"; import { createBrowserRouter, RouterProvider } from "react-router-dom"; -import { DbProvider } from "./context/db/provider"; import IndexPage from "./pages/Index"; import LoginPage from "./pages/Login"; import RegisterPage from "./pages/Register"; +import { useDbContext } from "./context/db/db"; +import { openDB } from "idb"; const router = createBrowserRouter([ { @@ -22,11 +23,23 @@ const router = createBrowserRouter([ ]); const App: FC = () => { - return ( - - - - ); + const { db, setDb } = useDbContext(); + + useEffect(() => { + const openConnection = async () => { + const conn = await openDB("guard-local", 3); + + if (!conn.objectStoreNames.contains("accounts")) { + conn.createObjectStore("accounts", { keyPath: "accountId" }); + } + + setDb(conn); + }; + + openConnection(); + }, [db, setDb]); + + return ; }; export default App; diff --git a/web/src/api/index.ts b/web/src/api/index.ts new file mode 100644 index 0000000..e8c9123 --- /dev/null +++ b/web/src/api/index.ts @@ -0,0 +1,20 @@ +export const handleApiError = async (response: Response) => { + try { + const json = await response.json(); + console.log({ json }); + const text = json.error ?? "unexpected error happpened"; + return new Error(text[0].toUpperCase() + text.slice(1)); + } catch (err) { + try { + console.log(err); + const text = await response.text(); + if (text.length > 0) { + return new Error(text[0].toUpperCase() + text.slice(1)); + } + } catch (err) { + console.log(err); + } + } + + return new Error("Unexpected error happened"); +}; diff --git a/web/src/api/login.ts b/web/src/api/login.ts new file mode 100644 index 0000000..7539e91 --- /dev/null +++ b/web/src/api/login.ts @@ -0,0 +1,34 @@ +import { handleApiError } from "."; + +export interface LoginRequest { + email: string; + password: string; +} + +export interface LoginResponse { + id: string; + email: string; + full_name: string; + access: string; + refresh: string; +} + +export const loginApi = async (req: LoginRequest) => { + const response = await fetch("/api/v1/login", { + method: "POST", + body: JSON.stringify({ + email: req.email, + password: req.password, + }), + headers: { + "Content-Type": "application/json", + }, + }); + + if (response.status !== 200 && response.status !== 201) + throw await handleApiError(response); + + const data: LoginResponse = await response.json(); + + return data; +}; diff --git a/web/src/context/db/db.ts b/web/src/context/db/db.ts index ae9dc95..89957d8 100644 --- a/web/src/context/db/db.ts +++ b/web/src/context/db/db.ts @@ -1,8 +1,9 @@ +import type { IDBPDatabase } from "idb"; import { createContext, useContext } from "react"; interface DbContextValues { - db: IDBDatabase | null; - setDb: (db: IDBDatabase) => void; + db: IDBPDatabase | null; + setDb: (db: IDBPDatabase) => void; } export const DbContext = createContext({ diff --git a/web/src/context/db/provider.tsx b/web/src/context/db/provider.tsx index f25f499..f6d41bc 100644 --- a/web/src/context/db/provider.tsx +++ b/web/src/context/db/provider.tsx @@ -1,14 +1,15 @@ import { useCallback, useState, type FC, type ReactNode } from "react"; import { DbContext } from "./db"; +import type { IDBPDatabase } from "idb"; interface IDBProvider { children: ReactNode; } export const DbProvider: FC = ({ children }) => { - const [db, _setDb] = useState(null); + const [db, _setDb] = useState(null); - const setDb = useCallback((db: IDBDatabase) => _setDb(db), []); + const setDb = useCallback((db: IDBPDatabase) => _setDb(db), []); return ( ); +createRoot(root).render( + + + +); diff --git a/web/src/pages/Login/index.tsx b/web/src/pages/Login/index.tsx index 56d94c0..316d9e3 100644 --- a/web/src/pages/Login/index.tsx +++ b/web/src/pages/Login/index.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { Card, CardContent } from "@/components/ui/card"; import { Mail, Lock } from "lucide-react"; import { Link } from "react-router-dom"; @@ -7,6 +8,8 @@ import { Button } from "@/components/ui/button"; import overlay from "@/assets/overlay.jpg"; import { useForm, type SubmitHandler } from "react-hook-form"; import { useCallback, useState } from "react"; +import { loginApi } from "@/api/login"; +import { useAccountRepo } from "@/repository/account"; interface LoginForm { email: string; @@ -25,6 +28,8 @@ export default function LoginPage() { const [error, setError] = useState(""); const [success, setSuccess] = useState(""); + const repo = useAccountRepo(); + const onSubmit: SubmitHandler = useCallback( async (data) => { console.log({ data }); @@ -34,36 +39,34 @@ export default function LoginPage() { setSuccess(""); try { - const response = await fetch("/api/v1/login", { - method: "POST", - body: JSON.stringify({ - email: data.email, - password: data.password, - }), - headers: { - "Content-Type": "application/json", - }, + const response = await loginApi({ + email: data.email, + password: data.password, }); - if (response.status != 200) { - const json = await response.json(); - const text = json.error || "Unexpected error happened"; - setError( - `Failed to create an account. ${ - text[0].toUpperCase() + text.slice(1) - }` - ); - } else { - setSuccess("You have successfully logged in"); - reset(); - } - } catch (err) { + + console.log(response); + + await repo.save({ + accountId: response.id, + label: response.full_name, + email: response.email, + access: response.access, + refresh: response.refresh, + }); + + setSuccess("You have successfully logged in"); + reset(); + } catch (err: any) { console.log(err); - setError("Failed to create account. Unexpected error happened"); + setError( + "Failed to create account. " + + (err.message ?? "Unexpected error happened") + ); } finally { setLoading(false); } }, - [reset] + [repo, reset] ); return ( diff --git a/web/src/repository/account.ts b/web/src/repository/account.ts new file mode 100644 index 0000000..1226213 --- /dev/null +++ b/web/src/repository/account.ts @@ -0,0 +1,64 @@ +import { useDbContext } from "@/context/db/db"; +import { deriveDeviceKey, getDeviceId } from "@/util/deviceId"; +import { useCallback } from "react"; + +export interface LocalAccount { + accountId: string; + label: string; + email: string; + access: { data: number[]; iv: number[] }; + refresh: { data: number[]; iv: number[] }; + updatedAt: string; +} + +export interface CreateAccountRequest { + accountId: string; + label: string; + email: string; + access: string; + refresh: string; +} + +export const useAccountRepo = () => { + const { db } = useDbContext(); + + const encryptToken = useCallback(async (token: string) => { + const encoder = new TextEncoder(); + + const iv = crypto.getRandomValues(new Uint8Array(12)); + + const deviceId = await getDeviceId(); + const deviceKey = await deriveDeviceKey(deviceId); + + const cipherText = await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + deviceKey, + encoder.encode(token) + ); + + return { + data: Array.from(new Uint8Array(cipherText)), + iv: Array.from(iv), + }; + }, []); + + const save = useCallback( + async (req: CreateAccountRequest) => { + console.log({ db }); + const access = await encryptToken(req.access); + const refresh = await encryptToken(req.refresh); + + await db?.put?.("accounts", { + accountId: req.accountId, + label: req.label, + email: req.email, + access, + refresh, + updatedAt: new Date().toISOString(), + }); + }, + [db, encryptToken] + ); + + return { save }; +};