Compare commits

...

2 Commits

Author SHA1 Message Date
eb42b61b2c feat: store logged account 2025-05-24 12:05:57 +02:00
06e0e90677 feat: return id 2025-05-24 12:05:49 +02:00
9 changed files with 179 additions and 36 deletions

View File

@ -126,6 +126,7 @@ func (h *AuthHandler) login(w http.ResponseWriter, r *http.Request) {
// fields required for UI in account selector, e.g. email, full name and avatar
FullName string `json:"full_name"`
Email string `json:"email"`
Id string `json:"id"`
// Avatar
}
@ -134,6 +135,7 @@ func (h *AuthHandler) login(w http.ResponseWriter, r *http.Request) {
RefreshToken: refreshToken,
FullName: user.FullName,
Email: user.Email,
Id: user.ID.String(),
// Avatar
}); err != nil {
web.Error(w, "failed to encode response", http.StatusInternalServerError)

View File

@ -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 (
<DbProvider>
<RouterProvider router={router} />
</DbProvider>
);
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 <RouterProvider router={router} />;
};
export default App;

20
web/src/api/index.ts Normal file
View File

@ -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");
};

34
web/src/api/login.ts Normal file
View File

@ -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;
};

View File

@ -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<DbContextValues>({

View File

@ -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<IDBProvider> = ({ children }) => {
const [db, _setDb] = useState<IDBDatabase | null>(null);
const [db, _setDb] = useState<IDBPDatabase | null>(null);
const setDb = useCallback((db: IDBDatabase) => _setDb(db), []);
const setDb = useCallback((db: IDBPDatabase) => _setDb(db), []);
return (
<DbContext.Provider

View File

@ -2,7 +2,12 @@ import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";
import { DbProvider } from "./context/db/provider";
const root = document.getElementById("root")!;
createRoot(root).render(<App />);
createRoot(root).render(
<DbProvider>
<App />
</DbProvider>
);

View File

@ -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<LoginForm> = 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 (

View File

@ -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 };
};