fix: retain masterkey on non-logout error scenarios and refresh on db hit miss

This commit is contained in:
ramvignesh-b
2026-04-15 16:57:03 +05:30
parent ef545e9e2b
commit fd64578a17
4 changed files with 89 additions and 16 deletions
+2 -2
View File
@@ -191,7 +191,7 @@ describe("initialize", () => {
expect(useKeyStore.getState().masterKey).not.toBeNull(); expect(useKeyStore.getState().masterKey).not.toBeNull();
}); });
it("should clear both stores if the refresh attempt fails", async () => { it("should preserve the master key even if the refresh attempt fails", async () => {
server.use( server.use(
http.post( http.post(
`${API_URL}/api/auth/refresh/`, `${API_URL}/api/auth/refresh/`,
@@ -206,6 +206,6 @@ describe("initialize", () => {
expect(useAuthStore.getState().accessToken).toBeNull(); expect(useAuthStore.getState().accessToken).toBeNull();
expect(useAuthStore.getState().user).toBeNull(); expect(useAuthStore.getState().user).toBeNull();
expect(useKeyStore.getState().masterKey).toBeNull(); expect(useKeyStore.getState().masterKey).not.toBeNull();
}); });
}); });
+30 -9
View File
@@ -4,6 +4,7 @@ import { endpoints } from "../config/endpoints";
import type { UserProfile } from "../store/useAuthStore"; import type { UserProfile } from "../store/useAuthStore";
import { useAuthStore } from "../store/useAuthStore"; import { useAuthStore } from "../store/useAuthStore";
import { useKeyStore } from "../store/useKeyStore"; import { useKeyStore } from "../store/useKeyStore";
import { CryptoUtils } from "../utils/crypto";
import { import {
clearMasterKey, clearMasterKey,
loadMasterKey, loadMasterKey,
@@ -40,9 +41,17 @@ export const useAuth = () => {
}; };
const initialize = useCallback(async () => { const initialize = useCallback(async () => {
const { accessToken, user, setAuth, clearAuth, setInitializing } = const { accessToken, user, setAuth, setInitializing } =
useAuthStore.getState(); useAuthStore.getState();
// Restore master key from IndexedDB
try {
const masterKey = await loadMasterKey();
if (masterKey) setMasterKey(masterKey);
} catch {
console.error("Master key restoration failed");
}
// If session in memory, don't trigger refresh/me again // If session in memory, don't trigger refresh/me again
if (accessToken && user) { if (accessToken && user) {
setInitializing(false); setInitializing(false);
@@ -50,23 +59,34 @@ export const useAuth = () => {
} }
try { try {
// try refresh // try session refresh
const { data: refreshData } = await publicApi.post(endpoints.REFRESH); const { data: refreshData } = await publicApi.post(endpoints.REFRESH);
const { data: userData } = await api.get(endpoints.ME, { const { data: userData } = await api.get(endpoints.ME, {
headers: { Authorization: `Bearer ${refreshData.access}` }, headers: { Authorization: `Bearer ${refreshData.access}` },
}); });
setAuth(refreshData.access, userData); setAuth(refreshData.access, userData);
// restore master key from IndexedDB
const masterKey = await loadMasterKey();
if (masterKey) setMasterKey(masterKey);
} catch { } catch {
clearAuth(); // grace for temporary network errors
setMasterKey(null); } finally {
await clearMasterKey(); setInitializing(false);
} }
}, [setMasterKey]); }, [setMasterKey]);
const unlock = async (password: string) => {
if (!user) return;
try {
const { masterKey } = await CryptoUtils.deriveKeyBundle(
password,
user.email,
);
await saveMasterKey(masterKey);
setMasterKey(masterKey);
} catch {
console.error("Master key restoration failed");
}
};
return { return {
isAuthenticated, isAuthenticated,
user, user,
@@ -74,5 +94,6 @@ export const useAuth = () => {
setAuthStore, setAuthStore,
logout, logout,
initialize, initialize,
unlock,
}; };
}; };
+7 -2
View File
@@ -56,11 +56,15 @@ async function decryptLetters(
export function useLetters() { export function useLetters() {
const [letters, setLetters] = useState<ProcessedLetter[]>([]); const [letters, setLetters] = useState<ProcessedLetter[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isAuthRequired, setIsAuthRequired] = useState<boolean>(false);
const { masterKey } = useKeyStore(); const { masterKey } = useKeyStore();
useEffect(() => { useEffect(() => {
if (!masterKey) return; if (!masterKey) {
setIsAuthRequired(true);
return;
}
setIsAuthRequired(false);
setLoading(true); setLoading(true);
api api
.get(endpoints.LETTERS) .get(endpoints.LETTERS)
@@ -83,5 +87,6 @@ export function useLetters() {
...drawerItems, ...drawerItems,
loading, loading,
refreshLetters: () => setLoading(true), refreshLetters: () => setLoading(true),
isAuthRequired,
}; };
} }
+50 -3
View File
@@ -1,4 +1,4 @@
import { FeatherIcon } from "@phosphor-icons/react"; import { FeatherIcon, LockKeyIcon } from "@phosphor-icons/react";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import Logo from "../components/Logo"; import Logo from "../components/Logo";
@@ -9,10 +9,11 @@ import { useAuth } from "../hooks/useAuth";
import { useLetters } from "../hooks/useLetters"; import { useLetters } from "../hooks/useLetters";
export default function Drawer() { export default function Drawer() {
const { user, logout } = useAuth(); const { user, logout, unlock } = useAuth();
const [openSection, setOpenSection] = useState<string | null>(); const [openSection, setOpenSection] = useState<string | null>();
const navigate = useNavigate(); const navigate = useNavigate();
const { drafts, kept, sent, vault, loading } = useLetters(); const { drafts, kept, sent, vault, loading, isAuthRequired } = useLetters();
if (!user) return null; if (!user) return null;
@@ -23,6 +24,52 @@ export default function Drawer() {
<div className="min-h-screen w-full bg-base-100 text-base-content flex flex-col items-center py-12 px-5 pb-32 font-serif transition-colors"> <div className="min-h-screen w-full bg-base-100 text-base-content flex flex-col items-center py-12 px-5 pb-32 font-serif transition-colors">
<div className="fixed inset-0 bg-[radial-gradient(circle_at_center,transparent_0%,rgba(0,0,0,0.5)_100%)] pointer-events-none z-0" /> <div className="fixed inset-0 bg-[radial-gradient(circle_at_center,transparent_0%,rgba(0,0,0,0.5)_100%)] pointer-events-none z-0" />
{isAuthRequired && (
<div className="modal modal-open bg-base-100/20 backdrop-blur-md">
<div className="modal-box p-12 flex flex-col items-center">
<LockKeyIcon
size={48}
className="text-primary mx-auto mb-8 animate-pulse"
/>
<h3 className="font-bold text-lg font-display text-primary">
Authentication Required
</h3>
<p className="py-4 font-sans">
We need your passkey to open your letters
</p>
<div className="divider w-1/2 mx-auto text-xs text-neutral-content/30 mt-0"></div>
<p className="text-xs text-neutral-content/30 font-mono italic">
P.S. We don't validate your input at the moment.
</p>
<div className="modal-action items-center gap-4">
<form
className="form-control w-full inline-flex"
onSubmit={async (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
const password = e.target.password.value;
if (!password) return;
unlock(password);
}}
>
<input
name="password"
required
type="password"
placeholder="password"
className="font-sans validator input input-bordered rounded-r-none"
/>
<div className="validator-message text-xs text-error"></div>
<button
type="submit"
className="btn btn-primary rounded-l-none"
>
Unlock
</button>
</form>
</div>
</div>
</div>
)}
<header className="text-center mb-12 z-10 animate-in fade-in slide-in-from-top-4 duration-1000"> <header className="text-center mb-12 z-10 animate-in fade-in slide-in-from-top-4 duration-1000">
<Logo /> <Logo />
<div className="font-sans text-xs tracking-[0.3em] uppercase text-base-content/40 mt-2"> <div className="font-sans text-xs tracking-[0.3em] uppercase text-base-content/40 mt-2">