refactor: implement authentication flow using authHash in unlock hook and update PasskeyModal UI
This commit is contained in:
@@ -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";
|
import { Modal } from "../ui/Modal";
|
||||||
|
|
||||||
interface PasskeyModalProps {
|
export function PasskeyModal() {
|
||||||
onUnlock: (password: string) => Promise<void>;
|
const { unlock } = useAuth();
|
||||||
}
|
|
||||||
|
|
||||||
export function PasskeyModal({ onUnlock }: PasskeyModalProps) {
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={true}>
|
<Modal isOpen={true}>
|
||||||
<LockKeyIcon
|
<HourglassSimpleMediumIcon
|
||||||
size={48}
|
size={48}
|
||||||
className="text-primary mx-auto mb-8 animate-pulse"
|
className="text-primary mx-auto mb-8 animate-pulse"
|
||||||
|
weight="duotone"
|
||||||
/>
|
/>
|
||||||
<h3 className="font-bold text-lg font-display text-primary">
|
<h3 className="font-bold text-lg font-display text-primary">
|
||||||
Authentication Required
|
You've been away a while.
|
||||||
</h3>
|
</h3>
|
||||||
<p className="py-4 font-sans">
|
<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>
|
</p>
|
||||||
<div className="divider w-1/2 mx-auto text-xs text-neutral-content/30 mt-0"></div>
|
<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 className="text-xs text-neutral-content/30 font-mono italic">
|
||||||
Your passkey is used to decrypt your data locally.
|
Nothing was lost.
|
||||||
</p>
|
</p>
|
||||||
<div className="modal-action items-center gap-4">
|
<div className="modal-action items-center gap-4">
|
||||||
<form
|
<form
|
||||||
@@ -30,7 +30,7 @@ export function PasskeyModal({ onUnlock }: PasskeyModalProps) {
|
|||||||
const formData = new FormData(e.currentTarget);
|
const formData = new FormData(e.currentTarget);
|
||||||
const password = formData.get("password") as string;
|
const password = formData.get("password") as string;
|
||||||
if (!password) return;
|
if (!password) return;
|
||||||
await onUnlock(password);
|
await unlock(password);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { mockUser } from "../../test/fixtures/user.fixture";
|
|||||||
import { server } from "../../test/mocks/server";
|
import { server } from "../../test/mocks/server";
|
||||||
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,
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
import { useAuth } from "./useAuth";
|
import { useAuth } from "./useAuth";
|
||||||
|
|
||||||
vi.mock("../utils/keystore");
|
vi.mock("../utils/keystore");
|
||||||
|
vi.mock("../utils/crypto");
|
||||||
|
|
||||||
const VITE_API_URL = "http://piku-server";
|
const VITE_API_URL = "http://piku-server";
|
||||||
|
|
||||||
@@ -30,6 +32,11 @@ beforeEach(() => {
|
|||||||
isInitializing: true,
|
isInitializing: true,
|
||||||
});
|
});
|
||||||
useKeyStore.setState({ masterKey: null });
|
useKeyStore.setState({ masterKey: null });
|
||||||
|
|
||||||
|
vi.mocked(CryptoUtils.deriveKeyBundle).mockResolvedValue({
|
||||||
|
masterKey: mockMasterKey,
|
||||||
|
authHash: "mock-hash",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("isAuthenticated", () => {
|
describe("isAuthenticated", () => {
|
||||||
@@ -201,3 +208,68 @@ describe("initialize", () => {
|
|||||||
expect(useKeyStore.getState().masterKey).not.toBeNull();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ export const useAuth = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 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}` },
|
||||||
@@ -71,16 +70,24 @@ export const useAuth = () => {
|
|||||||
}, [setMasterKey]);
|
}, [setMasterKey]);
|
||||||
|
|
||||||
const unlock = async (password: string) => {
|
const unlock = async (password: string) => {
|
||||||
if (!user) return;
|
if (!user) {
|
||||||
|
await logout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
const { masterKey, authHash } = await CryptoUtils.deriveKeyBundle(
|
||||||
const { masterKey } = await CryptoUtils.deriveKeyBundle(
|
|
||||||
password,
|
password,
|
||||||
user.email,
|
user.email,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Validate password by calling login endpoint
|
||||||
|
await api.post(endpoints.LOGIN, {
|
||||||
|
email: user.email,
|
||||||
|
password: authHash,
|
||||||
|
});
|
||||||
|
|
||||||
await saveMasterKey(masterKey);
|
await saveMasterKey(masterKey);
|
||||||
setMasterKey(masterKey);
|
setMasterKey(masterKey);
|
||||||
} catch {}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -75,7 +75,9 @@ describe("Drawer Page", () => {
|
|||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText(/Authentication Required/i)).toBeInTheDocument();
|
expect(
|
||||||
|
screen.getByText(/You've been away a while./i),
|
||||||
|
).toBeInTheDocument();
|
||||||
expect(screen.getByPlaceholderText(/password/i)).toBeInTheDocument();
|
expect(screen.getByPlaceholderText(/password/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
} from "../utils/dateFormat.ts";
|
} from "../utils/dateFormat.ts";
|
||||||
|
|
||||||
export default function Drawer() {
|
export default function Drawer() {
|
||||||
const { user, logout, unlock } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
|
||||||
const [openSection, setOpenSection] = useState<string | null>(null);
|
const [openSection, setOpenSection] = useState<string | null>(null);
|
||||||
const navigate = useNavigate();
|
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="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" />
|
<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">
|
<header className="text-center mb-12 z-10 animate-in fade-in slide-in-from-top-4 duration-500">
|
||||||
<Logo />
|
<Logo />
|
||||||
<div className="font-sans text-xs tracking-widester uppercase text-base-content/40 mt-2">
|
<div className="font-sans text-xs tracking-widester uppercase text-base-content/40 mt-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user