From 8ee443698ad2470da805e811dcdd641b528a5c3f Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 12 Apr 2026 05:32:43 +0530 Subject: [PATCH] feat: add Playwright test dependency --- frontend/bun.lock | 9 + frontend/package.json | 1 + frontend/repomix-output.xml | 1358 ----------------------------------- 3 files changed, 10 insertions(+), 1358 deletions(-) delete mode 100644 frontend/repomix-output.xml diff --git a/frontend/bun.lock b/frontend/bun.lock index aa94fbe..ac9ca22 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -27,6 +27,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.4.11", + "@playwright/test": "^1.59.1", "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -101,6 +102,8 @@ "@phosphor-icons/react": ["@phosphor-icons/react@2.1.10", "", { "peerDependencies": { "react": ">= 16.8", "react-dom": ">= 16.8" } }, "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA=="], + "@playwright/test": ["@playwright/test@1.59.1", "", { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.15", "", { "os": "android", "cpu": "arm64" }, "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA=="], "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg=="], @@ -347,6 +350,10 @@ "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="], + + "playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="], + "postcss": ["postcss@8.5.9", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="], "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], @@ -463,6 +470,8 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="], } } diff --git a/frontend/package.json b/frontend/package.json index 23ba652..7a7d427 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -34,6 +34,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.4.11", + "@playwright/test": "^1.59.1", "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/frontend/repomix-output.xml b/frontend/repomix-output.xml deleted file mode 100644 index e9e185b..0000000 --- a/frontend/repomix-output.xml +++ /dev/null @@ -1,1358 +0,0 @@ -This file is a merged representation of a subset of the codebase, containing specifically included files, combined into a single document by Repomix. - - -This section contains a summary of this file. - - -This file contains a packed representation of a subset of the repository's contents that is considered the most important context. -It is designed to be easily consumable by AI systems for analysis, code review, -or other automated processes. - - - -The content is organized as follows: -1. This summary section -2. Repository information -3. Directory structure -4. Repository files (if enabled) -5. Multiple file entries, each consisting of: - - File path as an attribute - - Full contents of the file - - - -- This file should be treated as read-only. Any changes should be made to the - original repository files, not this packed version. -- When processing this file, use the file path to distinguish - between different files in the repository. -- Be aware that this file may contain sensitive information. Handle it with - the same level of security as you would the original repository. - - - -- Some files may have been excluded based on .gitignore rules and Repomix's configuration -- Binary files are not included in this packed representation. Please refer to the Repository Structure section for a complete list of file paths, including binary files -- Only files matching these patterns are included: **/*.ts, **/*.tsx -- Files matching patterns in .gitignore are excluded -- Files matching default ignore patterns are excluded -- Files are sorted by Git change count (files with more changes are at the bottom) - - - - - -src/ - api/ - apiClient.ts - components/ - ui/ - ComposeCanvas.tsx - DateDisplay.tsx - FormField.tsx - Logo.tsx - RouteGuards.tsx - SplashScreen.tsx - config/ - endpoints.ts - routes.ts - hooks/ - useAuth.ts - pages/ - Activate.tsx - Drawer.tsx - Editor.tsx - Home.tsx - Login.tsx - Register.tsx - VerifyEmail.tsx - store/ - useAuthStore.ts - utils/ - crypto.ts - App.tsx - main.tsx -vite.config.ts - - - -This section contains the contents of the repository's files. - - -import axios from "axios"; -import { endpoints } from "../config/endpoints"; -import { useAuthStore } from "../store/useAuthStore"; - -// publicApi for endpoints that don't need authentication (login, refresh, register) -export const publicApi = axios.create({ - baseURL: import.meta.env.VITE_API_URL, - withCredentials: true, -}); - -// api for all authenticated requests -export const api = axios.create({ - baseURL: import.meta.env.VITE_API_URL, - withCredentials: true, -}); - -// auto-attach access token to authenticated requests -api.interceptors.request.use((config) => { - const token = useAuthStore.getState().accessToken; - if (token) { - config.headers.Authorization = `Bearer ${token}`; - } - return config; -}); - -// Handle 401 errors by attempting a silent refresh -api.interceptors.response.use( - (response) => response, - async (error) => { - const originalRequest = error.config; - - // If 401 and we haven't tried refreshing yet - if (error.response?.status === 401 && !originalRequest._retry) { - originalRequest._retry = true; - - try { - // Attempt silent refresh - const { data } = await publicApi.post(endpoints.REFRESH); - const newAccessToken = data.access; - - // Update store - const { user, setAuth } = useAuthStore.getState(); - if (user) { - setAuth(newAccessToken, user); - } - - // Retry the original request with the new token - originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; - return api(originalRequest); - } catch (refreshError) { - // Refresh failed, perform logout to clear tokens - console.error("Session expired, logging out..."); - useAuthStore.getState().clearAuth(); - return Promise.reject(refreshError); - } - } - - return Promise.reject(error); - }, -); - - - -import * as fabric from "fabric"; -import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; - -const PAD = 36; - -export type CanvasTools = { - addImage: (url: string) => void; -}; - -export const ComposeCanvas = forwardRef((_props, ref) => { - const wrapperRef = useRef(null); - const canvasRef = useRef(null); - const fabricRef = useRef(null); - const textboxRef = useRef(null); - - useEffect(() => { - let isMounted = true; - let canvas: fabric.Canvas | null = null; - - const init = async () => { - // lazy populate - await document.fonts.ready; - const waitForLayout = (): Promise => { - return new Promise((resolve) => { - const check = () => { - const wrapperWidth = wrapperRef.current?.clientWidth || 0; - if (wrapperWidth > 0) resolve(wrapperWidth); - else requestAnimationFrame(check); - }; - check(); - }); - }; - - const finalWidth = await waitForLayout(); - if (!isMounted || !canvasRef.current || !wrapperRef.current) return; - - const initialHeight = Math.max( - wrapperRef.current.clientHeight || 900, - 600, - ); - - // init canvas - canvas = new fabric.Canvas(canvasRef.current, { - width: finalWidth, - height: initialHeight, - selection: false, - preserveObjectStacking: true, - allowTouchScrolling: true, // for mobile - }); - - fabricRef.current = canvas; - - // transparent background - const wrapperEl = canvas.getElement().parentElement; - if (wrapperEl) wrapperEl.style.background = "transparent"; - - // the core textbox - const textbox = new fabric.Textbox("Take a deep breath...", { - originX: "left", - originY: "top", - left: PAD, - top: PAD, - width: finalWidth - PAD * 2, - fontSize: 16, - fontWeight: 500, - fontFamily: "Playfair Display Variable", - fill: "#000", - lineHeight: 1.5, - editable: true, - hasControls: false, - hasBorders: false, - objectCaching: false, // for font crispness - splitByGrapheme: false, - lockMovementX: true, - lockMovementY: true, - lockScalingX: true, - lockScalingY: true, - }); - - textboxRef.current = textbox; - canvas.add(textbox); - - // automatically adjust height - textbox.on("changed", () => { - if (!canvas || !wrapperRef.current) return; - const neededHeight = textbox.top + textbox.height + PAD; - if (neededHeight > canvas.height) { - const newH = neededHeight + PAD; - canvas.setDimensions({ height: newH }); - wrapperRef.current.style.height = `${newH}px`; - } - }); - - // auto focus - setTimeout(() => { - if (!isMounted) return; - canvas?.setActiveObject(textbox); - textbox.enterEditing(); - canvas?.renderAll(); - - // Accessibility fix for Fabric.js hidden textarea - // searching globally in case it is appended to body - const hiddenTextareas = document.querySelectorAll( - 'textarea[data-fabric="textarea"]', - ); - hiddenTextareas.forEach((ta) => { - if (!ta.getAttribute("aria-label")) { - ta.setAttribute("aria-label", "Canvas text input"); - } - }); - }, 100); - - canvas.on("mouse:down", (opt) => { - if (!opt.target || opt.target === textbox) { - canvas?.setActiveObject(textbox); - textbox.enterEditing(); - canvas?.renderAll(); - } - }); - }; - - init(); - - return () => { - isMounted = false; - canvas?.dispose(); - fabricRef.current = null; - textboxRef.current = null; - }; - }, []); - - useImperativeHandle(ref, () => ({ - addImage: (url: string) => { - if (!fabricRef.current) return; - fabric.FabricImage.fromURL(url).then((img) => { - img.scaleToWidth(300); - img.set({ - left: PAD, - top: PAD, - }); - fabricRef.current?.add(img); - fabricRef.current?.setActiveObject(img); - fabricRef.current?.requestRenderAll(); - - URL.revokeObjectURL(url); // cleanup browser upload - }); - }, - })); - - return ( -
- -
- ); -}); -ComposeCanvas.displayName = "ComposeCanvas"; -
- - -interface DateDisplayProps { - date?: Date; - className?: string; -} - -export default function DateDisplay({ - date = new Date(), - className = "", -}: DateDisplayProps) { - const formattedDate = date.toLocaleDateString("en-US", { - month: "long", - day: "numeric", - year: "numeric", - }); - - return ( -
- - Date - - - {formattedDate} - -
- ); -} -
- - -import type { UseFormRegisterReturn } from "react-hook-form"; - -interface FormFieldProps { - label: string; - type?: string; - placeholder?: string; - registration: UseFormRegisterReturn; - error?: string; -} - -export default function FormField({ - label, - type = "text", - placeholder, - registration, - error, -}: FormFieldProps) { - return ( -
- - - {error &&

{error}

} -
- ); -} -
- - -import { DotIcon } from "@phosphor-icons/react"; -import "@fontsource/knewave/400.css"; - -export default function Logo() { - return ( - - Pi - - Ku - - - ); -} - - - -import { Navigate, useLocation } from "react-router-dom"; -import { ROUTES } from "../config/routes"; -import { useAuth } from "../hooks/useAuth"; -import SplashScreen from "./SplashScreen"; - -/** - * Post-login routes. - * Redirects to /login if not already authenticated. - */ -export function ProtectedRoute({ children }: { children: React.ReactNode }) { - const { isAuthenticated, isInitializing } = useAuth(); - const location = useLocation(); - - if (isInitializing) return ; - - if (!isAuthenticated) { - // Save the intended location to redirect back after login - return ; - } - - return <>{children}; -} - -/** - * Pre-login flows. - * Redirects to /drawer if already authenticated. - */ -export function PublicRoute({ children }: { children: React.ReactNode }) { - const { isAuthenticated, isInitializing } = useAuth(); - - if (isInitializing) return ; - - if (isAuthenticated) { - return ; - } - - return <>{children}; -} - - - -import Logo from "./Logo"; - -export default function SplashScreen() { - return ( -
-
- -
- -

- Initializing Identity -

-
-
-
- ); -} -
- - -export const endpoints = { - LOGIN: "/api/auth/login/", - REGISTER: "/api/auth/register/", - VERIFY_EMAIL: "/api/auth/verify-email/", - ACTIVATE: "/api/auth/activate/:uidb64/:token/", - ME: "/api/auth/me/", - REFRESH: "/api/auth/refresh/", - LOGOUT: "/api/auth/logout/", -}; - -// simple utility to handle path params -export const replacePathParams = ( - url: string, - params: Record, -): string => { - let result = url; - Object.entries(params).forEach(([key, value]) => { - result = result.replace(`:${key}`, value); - }); - return result; -}; - - - -export const ROUTES = { - HOME: "/", - ONBOARD: "/onboard", - VERIFY_EMAIL: "/verify-email", - ACTIVATE: "/activate/:uidb64/:token", - LOGIN: "/login", - DRAWER: "/drawer", - WRITE: "/quill", - READ: "/read", -}; - - - -import { useCallback } from "react"; -import { api, publicApi } from "../api/apiClient"; -import { endpoints } from "../config/endpoints"; -import { type UserProfile, useAuthStore } from "../store/useAuthStore"; - -export const useAuth = () => { - const { accessToken, user, isInitializing, setAuth, clearAuth } = - useAuthStore(); - - const isAuthenticated = !!accessToken; - - const login = (access: string, profile: UserProfile) => { - setAuth(access, profile); - }; - - const logout = async () => { - try { - await api.post(endpoints.LOGOUT); - } finally { - clearAuth(); - } - }; - - const initialize = useCallback(async () => { - const { accessToken, user, setAuth, clearAuth, setInitializing } = - useAuthStore.getState(); - - // If session in memory, don't trigger refresh/me again - if (accessToken && user) { - setInitializing(false); - return; - } - - try { - // try refresh - const { data: refreshData } = await publicApi.post(endpoints.REFRESH); - // fetch user profile with the new access token - const { data: userData } = await api.get(endpoints.ME, { - headers: { Authorization: `Bearer ${refreshData.access}` }, - }); - // update auth details in memory - setAuth(refreshData.access, userData); - } catch (err) { - console.error("Initialization failed:", err); - clearAuth(); - } - }, []); - - return { - isAuthenticated, - user, - isInitializing, - login, - logout, - initialize, - }; -}; - - - -import { CheckCircleIcon, XCircleIcon } from "@phosphor-icons/react"; -import { useEffect, useRef, useState } from "react"; -import { useNavigate, useParams } from "react-router-dom"; -import { publicApi } from "../api/apiClient"; -import Logo from "../components/Logo"; -import { endpoints, replacePathParams } from "../config/endpoints"; -import { ROUTES } from "../config/routes"; - -export default function Activate() { - const { uidb64, token } = useParams(); - const [status, setStatus] = useState<"loading" | "success" | "error">( - "loading", - ); - const hasCalled = useRef(false); - const navigate = useNavigate(); - - useEffect(() => { - if (!uidb64 || !token || hasCalled.current) return; - - // prevent double api calls - hasCalled.current = true; - - const activateAccount = async () => { - try { - const url = replacePathParams(endpoints.ACTIVATE, { - uidb64, - token, - }); - await publicApi.get(url); - setStatus("success"); - } catch (err) { - console.error("Activation error:", err); - setStatus("error"); - } - }; - - activateAccount(); - }, [uidb64, token]); - - return ( -
- {status === "loading" && ( -
- -

Activating your account...

-
- )} - - {status === "success" && ( -
-
- -
-

- Account Activated! -

-

- Welcome to -
- Your identity is now verified and ready for timeless letters. -

- -
- )} - - {status === "error" && ( -
-
- -
-

Activation Failed

-

- The link might be expired or already used. Please try registering - again. -

- -
- )} -
- ); -} -
- - -import { useAuth } from "../hooks/useAuth"; - -export default function Drawer() { - const { user, logout } = useAuth(); - - if (!user) return null; - - return ( -
-
-

- Your Drawer -

-

Welcome back, {user.full_name}

-
- -
- - -
- ); -} - - - -import { ImageIcon, LockIcon, TrayIcon } from "@phosphor-icons/react"; -import { useRef, useState } from "react"; -import { - type CanvasTools, - ComposeCanvas, -} from "../components/ui/ComposeCanvas"; -import DateDisplay from "../components/ui/DateDisplay"; - -export default function Editor() { - const [recipient, setRecipient] = useState(""); - - const canvasRef = useRef(null); - const fileInputRef = useRef(null); - const handleImageUpload = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; // pick one file at a time - if (file) { - const url = URL.createObjectURL(file); - canvasRef.current?.addImage(url); - } - }; - - return ( -
-
-
-
- - setRecipient(e.target.value)} - className="bg-transparent border-none outline-none text-4xl font-serif text-base-content placeholder:text-base-content/10 w-full" - /> -
- -
- -
-
- - -
- -
- - -
- - -
-
- -
-
- ); -} -
- - -import Logo from "../components/Logo"; - -export default function Home() { - return ( -
- -
- ); -} -
- - -import { zodResolver } from "@hookform/resolvers/zod"; -import axios from "axios"; -import { useState } from "react"; -import { useForm } from "react-hook-form"; -import { useNavigate } from "react-router-dom"; -import { z } from "zod"; -import { api, publicApi } from "../api/apiClient"; -import Logo from "../components/Logo"; -import FormField from "../components/ui/FormField"; -import { endpoints } from "../config/endpoints"; -import { ROUTES } from "../config/routes"; -import { useAuth } from "../hooks/useAuth"; - -const loginSchema = z.object({ - email: z.string().email("Please enter a valid email"), - password: z.string().min(1, "Password is required"), -}); - -type LoginInputs = z.infer; - -export default function Login() { - const navigate = useNavigate(); - const [isLoading, setIsLoading] = useState(false); - const [apiError, setApiError] = useState(null); - const { login } = useAuth(); - - const { - register, - handleSubmit, - formState: { errors }, - } = useForm({ - resolver: zodResolver(loginSchema), - }); - - const onSubmit = async (data: LoginInputs) => { - setIsLoading(true); - setApiError(null); - try { - // 1. Authenticate - const { data: authData } = await publicApi.post(endpoints.LOGIN, data); - - // 2. Fetch User Profile with the fresh token - // We pass the header explicitly to avoid any race conditions with interceptors - const { data: userData } = await api.get(endpoints.ME, { - headers: { Authorization: `Bearer ${authData.access}` }, - }); - - // 3. Update store using the hook method - await login(authData.access, userData); - - navigate(ROUTES.DRAWER); - } catch (err) { - console.error("Login error:", err); - let message = "Invalid email or password"; - if (axios.isAxiosError(err)) { - message = - err.response?.data?.detail || err.response?.data?.message || message; - } - setApiError(message); - } finally { - setIsLoading(false); - } - }; - - return ( -
-
-

- Sign in to -

- - {apiError && ( -
- {apiError} -
- )} - - - - - -
- -
- -
- Don't have an account?{" "} - -
- -
- ); -} -
- - -import { zodResolver } from "@hookform/resolvers/zod"; -import { InfoIcon } from "@phosphor-icons/react"; -import axios from "axios"; -import { useState } from "react"; -import { useForm } from "react-hook-form"; -import { useNavigate } from "react-router-dom"; -import { z } from "zod"; -import { publicApi } from "../api/apiClient"; -import Logo from "../components/Logo"; -import FormField from "../components/ui/FormField"; -import { endpoints } from "../config/endpoints"; -import { ROUTES } from "../config/routes"; - -// validation logic -const registerSchema = z - .object({ - full_name: z.string().min(2, "Name must be at least 2 characters"), - email: z.email("Please enter a valid email"), - password: z.string().min(8, "Password must be at least 8 characters"), - confirm_password: z.string(), - }) - .refine((data) => data.password === data.confirm_password, { - message: "Passwords don't match", - path: ["confirm_password"], - }); - -type RegisterInputs = z.infer; - -export default function Register() { - const navigate = useNavigate(); - const [isLoading, setIsLoading] = useState(false); - const [apiError, setApiError] = useState(null); - - const { - register, - handleSubmit, - formState: { errors }, - } = useForm({ - resolver: zodResolver(registerSchema), - }); - - const onSubmit = async (data: RegisterInputs) => { - setIsLoading(true); - setApiError(null); - try { - await publicApi.post(endpoints.REGISTER, { - full_name: data.full_name, - email: data.email, - password: data.password, - }); - navigate(ROUTES.VERIFY_EMAIL); - } catch (err) { - console.error("Registration error:", err); - let message = "Registration failed. Please try again."; - if (axios.isAxiosError(err)) { - message = err.response?.data?.message || message; - } - setApiError(message); - } finally { - setIsLoading(false); - } - }; - - return ( -
-
-

- Create a Account -

- - {apiError && ( -
- {apiError} -
- )} - - - - - - - - - - {/* Warning */} -
- -

- Choose a password you won't forget.
- There is no reset.{" "} - If you lose it, your letters cannot be recovered. -

-
- -
- -
- -
- ); -} -
- - -import { EnvelopeSimpleOpenIcon } from "@phosphor-icons/react"; -import Logo from "../components/Logo"; - -export default function VerifyEmail() { - return ( -
-
- -
- -
-

Check Your Email

-

- We've sent an activation link to your inbox.
- Please click it to verify your account. -

-
- -
- -
-

- Didn't receive it? Check your spam folder or wait for a few minutes. - The link will expire in 24 hours. -

-
- - -
- ); -} -
- - -import { create } from "zustand"; - -export interface UserProfile { - public_id: string; - email: string; - full_name: string; -} - -interface AuthState { - accessToken: string | null; - user: UserProfile | null; - isInitializing: boolean; - setAuth: (accessToken: string, user: UserProfile) => void; - clearAuth: () => void; - setInitializing: (v: boolean) => void; -} - -export const useAuthStore = create((set) => ({ - accessToken: null, - user: null, - isInitializing: true, - setAuth: (accessToken, user) => - set({ accessToken, user, isInitializing: false }), - clearAuth: () => - set({ accessToken: null, user: null, isInitializing: false }), - setInitializing: (isInitializing) => set({ isInitializing }), -})); - - - -/** - * 0 knowledge cryptography. No Server involved in encryption/decryption - */ - -const ITERATIONS = 100000; -const KEY_ALGO = { name: "AES-GCM", length: 256 }; - -/** - * Derives a Master Encryption Key from a password and email (salt). - */ -export async function deriveMasterKey( - password: string, - email: string, -): Promise { - const encoder = new TextEncoder(); - const passwordKey = await crypto.subtle.importKey( - "raw", - encoder.encode(password), - "PBKDF2", - false, - ["deriveKey"], - ); - - return crypto.subtle.deriveKey( - { - name: "PBKDF2", - salt: encoder.encode(email.toLowerCase()), - iterations: ITERATIONS, - hash: "SHA-256", - }, - passwordKey, - KEY_ALGO, - false, - ["encrypt", "decrypt", "wrapKey", "unwrapKey"], - ); -} - -/** - * Encrypts a letter using Envelope Encryption. - */ -export async function encryptLetter(plaintext: string, masterKey: CryptoKey) { - const encoder = new TextEncoder(); - - // Generate random Data Encryption Key (DEK) - const dek = await crypto.subtle.generateKey(KEY_ALGO, true, [ - "encrypt", - "decrypt", - ]); - - // Encrypt the content with the DEK - const iv = crypto.getRandomValues(new Uint8Array(12)); - const ciphertext = await crypto.subtle.encrypt( - { name: "AES-GCM", iv }, - dek, - encoder.encode(plaintext), - ); - - // encrpyt the DEK using the Master Key for the self access - const keyIv = crypto.getRandomValues(new Uint8Array(12)); - const wrappedKey = await crypto.subtle.wrapKey("raw", dek, masterKey, { - name: "AES-GCM", - iv: keyIv, - }); - - // for recipients (link share), export DEK in raw format - const rawKey = await crypto.subtle.exportKey("raw", dek); - - // conversion to base64 for transit - const toBase64 = (buf: Uint8Array) => - btoa(buf.reduce((acc, b) => acc + String.fromCharCode(b), "")); - - return { - // This goes to the server - encryptedPayload: { - ciphertext: toBase64(new Uint8Array(ciphertext)), - iv: toBase64(new Uint8Array(iv)), - wrappedKey: toBase64(new Uint8Array(wrappedKey)), - keyIv: toBase64(new Uint8Array(keyIv)), - }, - // This goes into the url for the recipient - sharingKey: toBase64(new Uint8Array(rawKey)), - }; -} - - - -import { useEffect } from "react"; -import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; -import { ProtectedRoute, PublicRoute } from "./components/RouteGuards"; -import SplashScreen from "./components/SplashScreen"; -import { ROUTES } from "./config/routes"; -import { useAuth } from "./hooks/useAuth"; -import Activate from "./pages/Activate"; -import Drawer from "./pages/Drawer"; -import Editor from "./pages/Editor"; -// Pages -import Home from "./pages/Home"; -import Login from "./pages/Login"; -import Register from "./pages/Register"; -import VerifyEmail from "./pages/VerifyEmail"; - -export default function App() { - const { initialize, isInitializing } = useAuth(); - - useEffect(() => { - initialize(); - }, [initialize]); - - if (isInitializing) { - return ; - } - - return ( - -
- - } /> - - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - - } - /> - - - - } - /> - } /> - -
-
- ); -} -
- - -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -import "./index.css"; -import "@fontsource-variable/playwrite-hr-lijeva/wght.css"; -import "@fontsource-variable/jost/wght.css"; -import "@fontsource-variable/playfair-display/wght.css"; -import App from "./App.tsx"; - -const root = document.getElementById("root"); -if (root) { - createRoot(root).render( - - - , - ); -} - - - -import tailwindcss from "@tailwindcss/vite"; -import react from "@vitejs/plugin-react"; -import { defineConfig, loadEnv } from "vite"; - -// https://vite.dev/config/ -export default defineConfig(({ mode }) => { - const env = loadEnv(mode, "../", ""); - return { - envDir: "../", - plugins: [react(), tailwindcss()], - server: { - port: Number(env.FRONTEND_PORT), - host: env.FRONTEND_DOMAIN, - }, - } -}); - - -