import { useDbStore } from "@/store/db"; import { deriveDeviceKey, getDeviceId } from "@/util/deviceId"; import type { IDBPDatabase } from "idb"; import { useCallback } from "react"; export interface RawLocalAccount { accountId: string; label: string; email: string; profilePicture: string; access: { data: number[]; iv: number[] }; refresh: { data: number[]; iv: number[] }; updatedAt: string; } export interface LocalAccount { accountId: string; label: string; email: string; profilePicture: string; access: string; refresh: string; updatedAt: string; } export interface CreateAccountRequest { accountId: string; label: string; email: string; profilePicture: string; access: string; refresh: string; } export interface UpdateAccountTokensRequest { accountId: string; access: string; refresh: string; } export interface UpdateAccountInfoRequest { accountId: string; label?: string; profilePicture?: string; } export const getDeviceKey = async () => { const deviceId = await getDeviceId(); const deviceKey = await deriveDeviceKey(deviceId); return deviceKey; }; export const encryptToken = async (token: string) => { const encoder = new TextEncoder(); const iv = crypto.getRandomValues(new Uint8Array(12)); const deviceKey = await getDeviceKey(); const cipherText = await crypto.subtle.encrypt( { name: "AES-GCM", iv }, deviceKey, encoder.encode(token) ); return { data: Array.from(new Uint8Array(cipherText)), iv: Array.from(iv), }; }; export const decryptToken = async (encrypted: { data: number[]; iv: number[]; }) => { const decoder = new TextDecoder(); const deviceKey = await getDeviceKey(); const decrypted = await crypto.subtle.decrypt( { name: "AES-GCM", iv: new Uint8Array(encrypted.iv), }, deviceKey, new Uint8Array(encrypted.data) ); return decoder.decode(decrypted); }; export const saveAccount = async ( db: IDBPDatabase, req: CreateAccountRequest ): Promise => { const access = await encryptToken(req.access); const refresh = await encryptToken(req.refresh); const tx = db.transaction("accounts", "readwrite"); const store = tx.objectStore("accounts"); await store.put({ accountId: req.accountId, label: req.label, email: req.email, profilePicture: req.profilePicture, access, refresh, updatedAt: new Date().toISOString(), }); await tx.done; const account = await getAccount(db, req.accountId); return account as LocalAccount; }; export const getAllAccounts = async (db: IDBPDatabase) => { const tx = db.transaction("accounts", "readonly"); const store = tx.objectStore("accounts"); const accounts: RawLocalAccount[] = await store.getAll(); const results: LocalAccount[] = ( await Promise.all( accounts.map(async (account) => { try { const accessToken = await decryptToken(account.access); const refreshToken = await decryptToken(account.refresh); return { accountId: account.accountId, label: account.label, email: account.email, profilePicture: account.profilePicture, access: accessToken, refresh: refreshToken, updatedAt: account.updatedAt, }; } catch (err) { console.warn(`Failed to decrypt account ${account.label}:`, err); } }) ) ).filter((acc) => acc !== undefined); return results; }; export const getAccount = async (db: IDBPDatabase, accountId: string) => { const tx = db.transaction("accounts", "readonly"); const store = tx.objectStore("accounts"); const account: RawLocalAccount = await store.get(accountId); await tx.done; if (!account) return null; const accessToken = await decryptToken(account.access); const refreshToken = await decryptToken(account.refresh); return { accountId: account.accountId, label: account.label, email: account.email, profilePicture: account.profilePicture, access: accessToken, refresh: refreshToken, updatedAt: account.updatedAt, }; }; export const getAccountRaw = async (db: IDBPDatabase, accountId: string) => { const tx = db.transaction("accounts", "readonly"); const store = tx.objectStore("accounts"); const account: RawLocalAccount = await store.get(accountId); await tx.done; if (!account) return null; return account; }; export const updateAccountTokens = async ( db: IDBPDatabase, req: UpdateAccountTokensRequest ) => { const account = await getAccountRaw(db, req.accountId); const access = await encryptToken(req.access); const refresh = await encryptToken(req.refresh); await db?.put?.("accounts", { ...account, accountId: req.accountId, access, refresh, updatedAt: new Date().toISOString(), }); }; export const updateAccountInfo = async ( db: IDBPDatabase, req: UpdateAccountInfoRequest ) => { const account = await getAccountRaw(db, req.accountId); await db?.put?.("accounts", { ...account, ...req, }); }; export const deleteAccount = async (db: IDBPDatabase, accountId: string) => { const tx = db.transaction("accounts", "readwrite"); const store = tx.objectStore("accounts"); await store.delete(accountId); await tx.done; }; export const useAccountRepo = () => { const db = useDbStore((state) => state.db); const save = useCallback( async (req: CreateAccountRequest) => { if (!db) throw new Error("No database connection"); return saveAccount(db, req); }, [db] ); const loadAll = useCallback(async () => { if (!db) throw new Error("No database connection"); return getAllAccounts(db); }, [db]); const getOne = useCallback( async (accountId: string) => { if (!db) throw new Error("No database connection"); return getAccount(db, accountId); }, [db] ); return { save, loadAll, getOne }; };