- Add automatic re-hashing of legacy SHA-256 PINs to Argon2id on successful verification, returning new hash to frontend for persistence - Use constant-time comparison (subtle::ConstantTimeEq) for both Argon2id and legacy SHA-256 hash verification - Add unit tests for hash_pin, verify_pin (Argon2id and legacy paths), re-hashing flow, error cases, and hex encoding roundtrip - Update frontend to handle VerifyPinResult struct and save rehashed PIN hash via profile update Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
245 lines
7.7 KiB
TypeScript
245 lines
7.7 KiB
TypeScript
import { createContext, useContext, useEffect, useReducer, useCallback, type ReactNode } from "react";
|
|
import {
|
|
loadProfiles,
|
|
saveProfiles,
|
|
deleteProfileDb,
|
|
getNewProfileInitSql,
|
|
hashPin,
|
|
type Profile,
|
|
type ProfilesConfig,
|
|
} from "../services/profileService";
|
|
import { connectToProfile, initializeNewProfileDb, closeDb } from "../services/db";
|
|
|
|
interface ProfileState {
|
|
config: ProfilesConfig | null;
|
|
isLoading: boolean;
|
|
refreshKey: number;
|
|
error: string | null;
|
|
}
|
|
|
|
type ProfileAction =
|
|
| { type: "SET_CONFIG"; config: ProfilesConfig }
|
|
| { type: "SET_LOADING"; isLoading: boolean }
|
|
| { type: "SET_ERROR"; error: string | null }
|
|
| { type: "INCREMENT_REFRESH" };
|
|
|
|
function reducer(state: ProfileState, action: ProfileAction): ProfileState {
|
|
switch (action.type) {
|
|
case "SET_CONFIG":
|
|
return { ...state, config: action.config, error: null };
|
|
case "SET_LOADING":
|
|
return { ...state, isLoading: action.isLoading };
|
|
case "SET_ERROR":
|
|
return { ...state, error: action.error, isLoading: false };
|
|
case "INCREMENT_REFRESH":
|
|
return { ...state, refreshKey: state.refreshKey + 1 };
|
|
default:
|
|
return state;
|
|
}
|
|
}
|
|
|
|
interface ProfileContextValue {
|
|
profiles: Profile[];
|
|
activeProfile: Profile | null;
|
|
isLoading: boolean;
|
|
refreshKey: number;
|
|
error: string | null;
|
|
switchProfile: (id: string) => Promise<void>;
|
|
createProfile: (name: string, color: string, pin?: string) => Promise<void>;
|
|
updateProfile: (id: string, updates: Partial<Pick<Profile, "name" | "color" | "pin_hash">>) => Promise<void>;
|
|
deleteProfile: (id: string) => Promise<void>;
|
|
setPin: (id: string, pin: string | null) => Promise<void>;
|
|
connectActiveProfile: () => Promise<void>;
|
|
}
|
|
|
|
const ProfileContext = createContext<ProfileContextValue | null>(null);
|
|
|
|
export function ProfileProvider({ children }: { children: ReactNode }) {
|
|
const [state, dispatch] = useReducer(reducer, {
|
|
config: null,
|
|
isLoading: true,
|
|
refreshKey: 0,
|
|
error: null,
|
|
});
|
|
|
|
const activeProfile = state.config?.profiles.find(
|
|
(p) => p.id === state.config?.active_profile_id
|
|
) ?? null;
|
|
|
|
// Load profiles on mount
|
|
useEffect(() => {
|
|
loadProfiles()
|
|
.then((config) => {
|
|
dispatch({ type: "SET_CONFIG", config });
|
|
dispatch({ type: "SET_LOADING", isLoading: false });
|
|
})
|
|
.catch((err) => {
|
|
dispatch({ type: "SET_ERROR", error: String(err) });
|
|
});
|
|
}, []);
|
|
|
|
const connectActiveProfile = useCallback(async () => {
|
|
if (!state.config) return;
|
|
const profile = state.config.profiles.find(
|
|
(p) => p.id === state.config!.active_profile_id
|
|
);
|
|
if (!profile) return;
|
|
await connectToProfile(profile.db_filename);
|
|
}, [state.config]);
|
|
|
|
const switchProfile = useCallback(async (id: string) => {
|
|
if (!state.config) return;
|
|
const profile = state.config.profiles.find((p) => p.id === id);
|
|
if (!profile) return;
|
|
|
|
dispatch({ type: "SET_LOADING", isLoading: true });
|
|
try {
|
|
await closeDb();
|
|
await connectToProfile(profile.db_filename);
|
|
const newConfig = { ...state.config, active_profile_id: id };
|
|
await saveProfiles(newConfig);
|
|
dispatch({ type: "SET_CONFIG", config: newConfig });
|
|
dispatch({ type: "INCREMENT_REFRESH" });
|
|
} catch (err) {
|
|
dispatch({ type: "SET_ERROR", error: String(err) });
|
|
} finally {
|
|
dispatch({ type: "SET_LOADING", isLoading: false });
|
|
}
|
|
}, [state.config]);
|
|
|
|
const createProfile = useCallback(async (name: string, color: string, pin?: string) => {
|
|
if (!state.config) return;
|
|
|
|
dispatch({ type: "SET_LOADING", isLoading: true });
|
|
try {
|
|
const id = crypto.randomUUID();
|
|
const dbFilename = `profile_${id.split("-")[0]}.db`;
|
|
const pinHash = pin ? await hashPin(pin) : null;
|
|
const now = Date.now().toString();
|
|
|
|
const newProfile: Profile = {
|
|
id,
|
|
name,
|
|
color,
|
|
pin_hash: pinHash,
|
|
db_filename: dbFilename,
|
|
created_at: now,
|
|
};
|
|
|
|
// Initialize the new database
|
|
const sqlStatements = await getNewProfileInitSql();
|
|
await initializeNewProfileDb(dbFilename, sqlStatements);
|
|
|
|
// Reconnect to the current active profile's DB
|
|
const currentProfile = state.config.profiles.find(
|
|
(p) => p.id === state.config!.active_profile_id
|
|
);
|
|
if (currentProfile) {
|
|
await connectToProfile(currentProfile.db_filename);
|
|
}
|
|
|
|
const newConfig: ProfilesConfig = {
|
|
...state.config,
|
|
profiles: [...state.config.profiles, newProfile],
|
|
};
|
|
await saveProfiles(newConfig);
|
|
dispatch({ type: "SET_CONFIG", config: newConfig });
|
|
} catch (err) {
|
|
dispatch({ type: "SET_ERROR", error: String(err) });
|
|
} finally {
|
|
dispatch({ type: "SET_LOADING", isLoading: false });
|
|
}
|
|
}, [state.config]);
|
|
|
|
const updateProfile = useCallback(async (id: string, updates: Partial<Pick<Profile, "name" | "color" | "pin_hash">>) => {
|
|
if (!state.config) return;
|
|
|
|
const newProfiles = state.config.profiles.map((p) =>
|
|
p.id === id ? { ...p, ...updates } : p
|
|
);
|
|
const newConfig = { ...state.config, profiles: newProfiles };
|
|
await saveProfiles(newConfig);
|
|
dispatch({ type: "SET_CONFIG", config: newConfig });
|
|
}, [state.config]);
|
|
|
|
const deleteProfile = useCallback(async (id: string) => {
|
|
if (!state.config) return;
|
|
const profile = state.config.profiles.find((p) => p.id === id);
|
|
if (!profile) return;
|
|
if (profile.db_filename === "simpl_resultat.db") return;
|
|
|
|
dispatch({ type: "SET_LOADING", isLoading: true });
|
|
try {
|
|
// If deleting the active profile, switch to default first
|
|
if (state.config.active_profile_id === id) {
|
|
const defaultProfile = state.config.profiles.find(
|
|
(p) => p.db_filename === "simpl_resultat.db"
|
|
);
|
|
if (defaultProfile) {
|
|
await closeDb();
|
|
await connectToProfile(defaultProfile.db_filename);
|
|
}
|
|
}
|
|
|
|
await deleteProfileDb(profile.db_filename);
|
|
|
|
const newProfiles = state.config.profiles.filter((p) => p.id !== id);
|
|
const newActiveId =
|
|
state.config.active_profile_id === id
|
|
? newProfiles[0]?.id ?? "default"
|
|
: state.config.active_profile_id;
|
|
|
|
const newConfig: ProfilesConfig = {
|
|
active_profile_id: newActiveId,
|
|
profiles: newProfiles,
|
|
};
|
|
await saveProfiles(newConfig);
|
|
dispatch({ type: "SET_CONFIG", config: newConfig });
|
|
if (state.config.active_profile_id === id) {
|
|
dispatch({ type: "INCREMENT_REFRESH" });
|
|
}
|
|
} catch (err) {
|
|
dispatch({ type: "SET_ERROR", error: String(err) });
|
|
} finally {
|
|
dispatch({ type: "SET_LOADING", isLoading: false });
|
|
}
|
|
}, [state.config]);
|
|
|
|
const setPin = useCallback(async (id: string, pin: string | null) => {
|
|
if (!state.config) return;
|
|
|
|
const pinHash = pin ? await hashPin(pin) : null;
|
|
const newProfiles = state.config.profiles.map((p) =>
|
|
p.id === id ? { ...p, pin_hash: pinHash } : p
|
|
);
|
|
const newConfig = { ...state.config, profiles: newProfiles };
|
|
await saveProfiles(newConfig);
|
|
dispatch({ type: "SET_CONFIG", config: newConfig });
|
|
}, [state.config]);
|
|
|
|
return (
|
|
<ProfileContext.Provider
|
|
value={{
|
|
profiles: state.config?.profiles ?? [],
|
|
activeProfile,
|
|
isLoading: state.isLoading,
|
|
refreshKey: state.refreshKey,
|
|
error: state.error,
|
|
switchProfile,
|
|
createProfile,
|
|
updateProfile,
|
|
deleteProfile,
|
|
setPin,
|
|
connectActiveProfile,
|
|
}}
|
|
>
|
|
{children}
|
|
</ProfileContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useProfile() {
|
|
const ctx = useContext(ProfileContext);
|
|
if (!ctx) throw new Error("useProfile must be used within ProfileProvider");
|
|
return ctx;
|
|
}
|