refactor: implement authentication flow using authHash in unlock hook and update PasskeyModal UI
CI / Generate Certificates (push) Successful in 1m52s
CI / Frontend CI (push) Successful in 1m13s
CI / Backend CI (push) Successful in 1m15s
CI / E2E Tests (push) Has been skipped

This commit is contained in:
me
2026-05-06 13:45:30 +05:30
parent 3b5f140d21
commit 8449377b6d
5 changed files with 162 additions and 81 deletions
+10 -10
View File
@@ -1,26 +1,26 @@
import { LockKeyIcon } from "@phosphor-icons/react";
import { HourglassSimpleMediumIcon } from "@phosphor-icons/react";
import { useAuth } from "../../hooks/useAuth";
import { Modal } from "../ui/Modal";
interface PasskeyModalProps {
onUnlock: (password: string) => Promise<void>;
}
export function PasskeyModal() {
const { unlock } = useAuth();
export function PasskeyModal({ onUnlock }: PasskeyModalProps) {
return (
<Modal isOpen={true}>
<LockKeyIcon
<HourglassSimpleMediumIcon
size={48}
className="text-primary mx-auto mb-8 animate-pulse"
weight="duotone"
/>
<h3 className="font-bold text-lg font-display text-primary">
Authentication Required
You've been away a while.
</h3>
<p className="py-4 font-sans">
We need your passkey to open your letters
Your letters are still there. Just need the key once more.
</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">
Your passkey is used to decrypt your data locally.
Nothing was lost.
</p>
<div className="modal-action items-center gap-4">
<form
@@ -30,7 +30,7 @@ export function PasskeyModal({ onUnlock }: PasskeyModalProps) {
const formData = new FormData(e.currentTarget);
const password = formData.get("password") as string;
if (!password) return;
await onUnlock(password);
await unlock(password);
}}
>
<input
+72
View File
@@ -6,6 +6,7 @@ import { mockUser } from "../../test/fixtures/user.fixture";
import { server } from "../../test/mocks/server";
import { useAuthStore } from "../store/useAuthStore";
import { useKeyStore } from "../store/useKeyStore";
import { CryptoUtils } from "../utils/crypto";
import {
clearMasterKey,
loadMasterKey,
@@ -14,6 +15,7 @@ import {
import { useAuth } from "./useAuth";
vi.mock("../utils/keystore");
vi.mock("../utils/crypto");
const VITE_API_URL = "http://piku-server";
@@ -30,6 +32,11 @@ beforeEach(() => {
isInitializing: true,
});
useKeyStore.setState({ masterKey: null });
vi.mocked(CryptoUtils.deriveKeyBundle).mockResolvedValue({
masterKey: mockMasterKey,
authHash: "mock-hash",
});
});
describe("isAuthenticated", () => {
@@ -201,3 +208,68 @@ describe("initialize", () => {
expect(useKeyStore.getState().masterKey).not.toBeNull();
});
});
describe("unlock", () => {
beforeEach(() => {
useAuthStore.setState({
accessToken: "valid-token",
user: mockUser,
isInitializing: false,
});
});
it("should derive the master key from the user password, validate it via API, and persist it", async () => {
let loginCalled = false;
server.use(
http.post(`${VITE_API_URL}/api/auth/login/`, async () => {
loginCalled = true;
return HttpResponse.json({ access: "token", user: mockUser });
}),
);
const { result } = renderHook(() => useAuth());
await act(async () => {
await result.current.unlock("password");
});
expect(CryptoUtils.deriveKeyBundle).toHaveBeenCalledWith(
"password",
mockUser.email,
);
expect(loginCalled).toBe(true);
expect(saveMasterKey).toHaveBeenCalledWith(mockMasterKey);
expect(useKeyStore.getState().masterKey).toEqual(mockMasterKey);
});
it("should logout if user is not present", async () => {
useAuthStore.setState({ user: null });
const { result } = renderHook(() => useAuth());
await act(async () => {
await result.current.unlock("password");
});
expect(CryptoUtils.deriveKeyBundle).not.toHaveBeenCalled();
expect(saveMasterKey).not.toHaveBeenCalled();
expect(useAuthStore.getState().accessToken).toBeNull();
expect(clearMasterKey).toHaveBeenCalled();
});
it("should throw an error and not persist the key if validation fails", async () => {
server.use(
http.post(
`${VITE_API_URL}/api/auth/login/`,
() => new HttpResponse(null, { status: 400 }),
),
);
const { result } = renderHook(() => useAuth());
await act(async () => {
await expect(result.current.unlock("wrong-password")).rejects.toThrow();
});
expect(saveMasterKey).not.toHaveBeenCalled();
expect(useKeyStore.getState().masterKey).toBeNull();
});
});
+17 -10
View File
@@ -57,7 +57,6 @@ export const useAuth = () => {
}
try {
// try session refresh
const { data: refreshData } = await publicApi.post(endpoints.REFRESH);
const { data: userData } = await api.get(endpoints.ME, {
headers: { Authorization: `Bearer ${refreshData.access}` },
@@ -71,16 +70,24 @@ export const useAuth = () => {
}, [setMasterKey]);
const unlock = async (password: string) => {
if (!user) return;
if (!user) {
await logout();
return;
}
try {
const { masterKey } = await CryptoUtils.deriveKeyBundle(
password,
user.email,
);
await saveMasterKey(masterKey);
setMasterKey(masterKey);
} catch {}
const { masterKey, authHash } = await CryptoUtils.deriveKeyBundle(
password,
user.email,
);
// Validate password by calling login endpoint
await api.post(endpoints.LOGIN, {
email: user.email,
password: authHash,
});
await saveMasterKey(masterKey);
setMasterKey(masterKey);
};
return {
+61 -59
View File
@@ -9,73 +9,75 @@ import Drawer from "./Drawer";
vi.mock("../hooks/useLetters");
describe("Drawer Page", () => {
beforeEach(() => {
// Setup authenticated state for the test
useAuthStore.setState({
user: mockUser,
accessToken: "fake-token",
isInitializing: false,
beforeEach(() => {
// Setup authenticated state for the test
useAuthStore.setState({
user: mockUser,
accessToken: "fake-token",
isInitializing: false,
});
vi.mocked(useLetters).mockReturnValue({
drafts: [],
kept: [],
sent: [],
vault: [],
loading: false,
isAuthRequired: false,
});
});
vi.mocked(useLetters).mockReturnValue({
drafts: [],
kept: [],
sent: [],
vault: [],
loading: false,
isAuthRequired: false,
});
});
it("renders the cabinet sections and empty state message", () => {
render(
<MemoryRouter>
<Drawer />
</MemoryRouter>,
);
it("renders the cabinet sections and empty state message", () => {
render(
<MemoryRouter>
<Drawer />
</MemoryRouter>,
);
expect(screen.getByText(/Drafts/i)).toBeInTheDocument();
expect(screen.getAllByText(/Kept/i).length).toBeGreaterThanOrEqual(1);
expect(screen.getByText(/Vault/i)).toBeInTheDocument();
expect(screen.getByText(/This drawer remains silent/i)).toBeInTheDocument();
});
it("renders the loading state", () => {
vi.mocked(useLetters).mockReturnValue({
drafts: [],
kept: [],
sent: [],
vault: [],
loading: true,
isAuthRequired: false,
expect(screen.getByText(/Drafts/i)).toBeInTheDocument();
expect(screen.getAllByText(/Kept/i).length).toBeGreaterThanOrEqual(1);
expect(screen.getByText(/Vault/i)).toBeInTheDocument();
expect(screen.getByText(/This drawer remains silent/i)).toBeInTheDocument();
});
render(
<MemoryRouter>
<Drawer />
</MemoryRouter>,
);
it("renders the loading state", () => {
vi.mocked(useLetters).mockReturnValue({
drafts: [],
kept: [],
sent: [],
vault: [],
loading: true,
isAuthRequired: false,
});
expect(screen.getByText(/Opening your cabinet/i)).toBeInTheDocument();
});
render(
<MemoryRouter>
<Drawer />
</MemoryRouter>,
);
it("renders the authentication required modal when api requires auth", () => {
vi.mocked(useLetters).mockReturnValue({
drafts: [],
kept: [],
sent: [],
vault: [],
loading: false,
isAuthRequired: true,
expect(screen.getByText(/Opening your cabinet/i)).toBeInTheDocument();
});
render(
<MemoryRouter>
<Drawer />
</MemoryRouter>,
);
it("renders the authentication required modal when api requires auth", () => {
vi.mocked(useLetters).mockReturnValue({
drafts: [],
kept: [],
sent: [],
vault: [],
loading: false,
isAuthRequired: true,
});
expect(screen.getByText(/Authentication Required/i)).toBeInTheDocument();
expect(screen.getByPlaceholderText(/password/i)).toBeInTheDocument();
});
render(
<MemoryRouter>
<Drawer />
</MemoryRouter>,
);
expect(
screen.getByText(/You've been away a while./i),
).toBeInTheDocument();
expect(screen.getByPlaceholderText(/password/i)).toBeInTheDocument();
});
});
+2 -2
View File
@@ -15,7 +15,7 @@ import {
} from "../utils/dateFormat.ts";
export default function Drawer() {
const { user, logout, unlock } = useAuth();
const { user, logout } = useAuth();
const [openSection, setOpenSection] = useState<string | null>(null);
const navigate = useNavigate();
@@ -30,7 +30,7 @@ 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="fixed inset-0 bg-vig pointer-events-none z-0" />
{isAuthRequired && <PasskeyModal onUnlock={unlock} />}
{isAuthRequired && <PasskeyModal />}
<header className="text-center mb-12 z-10 animate-in fade-in slide-in-from-top-4 duration-500">
<Logo />
<div className="font-sans text-xs tracking-widester uppercase text-base-content/40 mt-2">