From 7c9127d7153256fa6823afea3feb9b39fbfa4dd5 Mon Sep 17 00:00:00 2001 From: Prisca Chidimma Maduka Date: Thu, 13 Aug 2026 11:28:42 +0100 Subject: [PATCH] Scope requests, cached data to the selected account and fetch Company info and KYB data from session * Each request can now be pinned to a specific account instead of relying only on the current global account header. * Cached data is now separated by account, so switching accounts cannot show data from the previous account. Account-specific cache entries are cleared on switch, while shared master account data stays available. * The selected subaccount is saved per tab and restored after refresh. The account header is also set before the first request runs, so initial data loads for the correct account. * Company details and KYB status now come from the session instead of placeholder values. * Included KYB link * User, KYB status, and Bitcoin address types match the API response directly, removing unused fallback logic. *Show user's active Bitcoin address instead of simply using the first saved address. * Show pending KYB status in the home page. Updates proxy port to 32768 and prod url to proxy-mining.dmnd.work. --- src/api/__tests__/client.test.ts | 87 ++++++++-- src/api/client.ts | 5 +- src/api/types.ts | 13 +- src/auth/AuthProvider.tsx | 74 +++++++-- src/auth/__tests__/authStore.test.ts | 109 ++++++++++-- src/auth/__tests__/session.test.ts | 55 ++++++- src/auth/__tests__/sessionValidation.test.ts | 12 ++ src/auth/authStore.ts | 91 ++++++++-- src/auth/index.ts | 3 +- src/auth/session.ts | 81 +++++++-- src/auth/sessionValidation.ts | 6 + src/components/home/GettingStartedCard.tsx | 7 +- src/components/home/KybNotice.tsx | 39 +++++ src/components/settings/AccountTab.tsx | 164 +++++++++++-------- src/hooks/useAccountData.ts | 84 ++++++---- src/hooks/useAccountSwitcher.ts | 79 ++++----- src/hooks/useActiveAccountId.ts | 7 + src/hooks/useGeneratedBtc.ts | 24 ++- src/hooks/usePayouts.ts | 20 ++- src/hooks/useSubaccounts.ts | 25 +-- src/hooks/useWatcherLinks.ts | 6 +- src/lib/poolConnection.ts | 6 +- src/pages/account-setup/AccountSetup.tsx | 7 +- src/pages/auth/SignIn.tsx | 10 +- src/pages/dashboard/DashboardHome.tsx | 3 + 25 files changed, 760 insertions(+), 257 deletions(-) create mode 100644 src/auth/__tests__/sessionValidation.test.ts create mode 100644 src/auth/sessionValidation.ts create mode 100644 src/components/home/KybNotice.tsx create mode 100644 src/hooks/useActiveAccountId.ts diff --git a/src/api/__tests__/client.test.ts b/src/api/__tests__/client.test.ts index 02a39ab1..50e1d572 100644 --- a/src/api/__tests__/client.test.ts +++ b/src/api/__tests__/client.test.ts @@ -151,7 +151,15 @@ test('signup posts the full account body, defaulting company fields and referral }); test('checkAuth GETs check_auth and returns the session', async () => { - const session = { token: 'x', id: '42', email: 'm@x.io', two_factor_secret: null }; + const session = { + token: 'x', + id: '42', + email: 'm@x.io', + company_name: 'DMND Mining', + company_primary_location: 'Lagos, NG', + kyb_status: 'Approved', + two_factor_secret: null, + }; const { fetchImpl, calls } = fakeFetch(() => jsonResponse(session)); const client = createUser({ fetchImpl, backoffMs: 0 }); @@ -160,6 +168,8 @@ test('checkAuth GETs check_auth and returns the session', async () => { assert.equal(calls[0].init.method, 'GET'); assert.ok(calls[0].url.endsWith('/api/check_auth')); assert.deepEqual(result, session); + assert.equal(result.company_name, 'DMND Mining'); + assert.equal(result.company_primary_location, 'Lagos, NG'); }); test('signup forwards company fields and referral when provided', async () => { @@ -233,19 +243,36 @@ test('createSubaccount POSTs sub_account and bitcoin_address', async () => { }); test('logSubaccount POSTs owner_token and subaccount_token and returns the new session', async () => { - const session = { token: 'sub-tok', id: '7', email: 'm@x.io', two_factor_secret: null }; + const session = { + token: 'sub-tok', + id: 'returned-sub-id', + email: 'm@x.io', + company_name: 'DMND Mining', + company_primary_location: 'Lagos, NG', + kyb_status: 'Approved', + two_factor_secret: null, + }; const { fetchImpl, calls } = fakeFetch(() => jsonResponse(session)); const client = createUser({ fetchImpl, backoffMs: 0 }); - const result = await client.logSubaccount('owner-tok', 'subacct-tok'); + setDmndAccountId('unrelated-active-account'); + const result = await (async () => { + try { + return await client.logSubaccount('owner-tok', 'subacct-tok', { accountId: 'master' }); + } finally { + setDmndAccountId(null); + } + })(); assert.ok(calls[0].url.endsWith('/api/log_subaccount')); assert.equal(calls[0].init.method, 'POST'); + assert.equal((calls[0].init.headers as Record)['X-Account-ID'], 'master'); assert.deepEqual(JSON.parse(calls[0].init.body as string), { owner_token: 'owner-tok', subaccount_token: 'subacct-tok', }); assert.deepEqual(result, session); + assert.equal(result.id, 'returned-sub-id'); }); test('getSubaccountSummary GETs the per-subaccount summary with a token and the X-Account-ID header', async () => { @@ -267,13 +294,17 @@ test('getSubaccountSummary GETs the per-subaccount summary with a token and the test('getSubaccountWorkers GETs the per-subaccount live workers endpoint', async () => { const { fetchImpl, calls } = fakeFetch(() => jsonResponse({ workers: [], next_cursor: null })); const client = createUser({ fetchImpl, backoffMs: 0 }); - - await client.getSubaccountWorkers('-77', 'sub-tok'); - - assert.equal(calls[0].init.method, 'GET'); - assert.ok(calls[0].url.includes('/api/user/sub_account/-77/workers')); - assert.ok(calls[0].url.includes('token=sub-tok')); - assert.equal(calls[0].init.credentials, 'include'); + setDmndAccountId('currently-viewed-subaccount'); + try { + await client.getSubaccountWorkers('-77', 'sub-tok', { accountId: 'master' }); + assert.equal(calls[0].init.method, 'GET'); + assert.ok(calls[0].url.includes('/api/user/sub_account/-77/workers')); + assert.ok(calls[0].url.includes('token=sub-tok')); + assert.equal(calls[0].init.credentials, 'include'); + assert.equal((calls[0].init.headers as Record)['X-Account-ID'], 'master'); + } finally { + setDmndAccountId(null); + } }); test('getSubaccountWorkers follows pagination on the live per-subaccount endpoint', async () => { @@ -471,6 +502,18 @@ test('miner requests send the X-Account-ID header when an account id is set', as assert.equal((calls[0].init.headers as Record)['X-Account-ID'], '42'); }); +test('a request-scoped account id cannot be changed by a later dashboard switch', async () => { + const { fetchImpl, calls } = fakeFetch(() => jsonResponse({ token: 't' })); + const client = createUser({ fetchImpl, backoffMs: 0 }); + setDmndAccountId('sub-2'); + try { + await client.checkAuth({ accountId: 'master' }); + assert.equal((calls[0].init.headers as Record)['X-Account-ID'], 'master'); + } finally { + setDmndAccountId(null); + } +}); + test('broker requests never send the miner X-Account-ID header', async () => { const { fetchImpl, calls } = fakeFetch(() => jsonResponse({ id: 7, email: 'b@x.io', referenceCode: 'RC-1' }), @@ -534,6 +577,30 @@ test('getAllWorkers follows next_cursor across pages and concatenates the roster assert.deepEqual(workers.map((w) => w.name), ['w1', 'w2']); }); +test('getAllWorkers keeps every page pinned to the requested account', async () => { + let page = 0; + const { fetchImpl, calls } = fakeFetch(() => { + page += 1; + // Simulate the account switcher changing the ambient account while pagination is + // in progress. The roster request must remain on the account it started for. + if (page === 1) setDmndAccountId('subaccount'); + return jsonResponse({ workers: [{ name: `w${page}` }], next_cursor: page === 1 ? 'next' : null }); + }); + const client = createUser({ fetchImpl, backoffMs: 0 }); + setDmndAccountId('master'); + try { + await client.getAllWorkers({ accountId: 'master' }); + } finally { + setDmndAccountId(null); + } + + assert.equal(calls.length, 2); + assert.deepEqual( + calls.map((call) => (call.init.headers as Record)['X-Account-ID']), + ['master', 'master'], + ); +}); + test('getAllWorkers keeps paging past 50 pages, stopping only when next_cursor is null', async () => { const TOTAL = 60; let i = 0; diff --git a/src/api/client.ts b/src/api/client.ts index e3050835..e665a9fb 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -149,7 +149,10 @@ async function request( if (req.signal?.aborted) throw new DmndApiError(API_ERROR_MESSAGES.cancelled, 'network'); const headers: Record = { 'Content-Type': 'application/json' }; - if (accountId && !spec.omitAccountId) headers['X-Account-ID'] = accountId; + const requestAccountId = req.accountId ?? accountId; + if (requestAccountId && !spec.omitAccountId) { + headers['X-Account-ID'] = requestAccountId; + } try { const response = await opts.fetchImpl(`${API_BASE}${spec.path}`, { diff --git a/src/api/types.ts b/src/api/types.ts index c52f647d..f09a6fa1 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -12,25 +12,28 @@ export class DmndApiError extends Error { export interface RequestOptions { signal?: AbortSignal; + accountId?: string; } /** * The user/session object returned by /api/log_user (verified live). `token` is * the SV2 pool credential and the auth carrier; `api_token` is for the public - * API. The remaining fields model the full login response; only `token`, `id`, - * and `email` are used today. + * API. The remaining fields model the full login response. */ export interface DmndSession { token: string; /** Account id; sent back as X-Account-ID on authed calls. Always present in real responses. */ id: string; email: string; + company_name: string | null; + company_primary_location: string | null; + kyb_status: 'NotStarted' | 'InReview' | 'Approved' | 'Rejected'; two_factor_secret: string | null; - bitcoin_addresses?: Record | string[]; + bitcoin_addresses: Record; language?: string; active?: boolean; - api_token?: string; - fpps_token?: string; + api_token?: string | null; + fpps_token?: string | null; selling_hash_rate?: boolean; } diff --git a/src/auth/AuthProvider.tsx b/src/auth/AuthProvider.tsx index f4666d4b..2573c933 100644 --- a/src/auth/AuthProvider.tsx +++ b/src/auth/AuthProvider.tsx @@ -10,7 +10,8 @@ import { import { getUser } from '@/api'; import { queryClient } from '@/lib/queryClient'; import { createAuthStore, type AuthStore, type SignOutReason } from './authStore'; -import type { Session } from './session'; +import { viewingAccountFromAuth, type Session, type ViewingAccountSession } from './session'; +import { shouldEndSessionAfterValidation } from './sessionValidation'; export type AuthStatus = 'authenticated' | 'anonymous'; @@ -20,9 +21,11 @@ export interface AuthContextValue { status: AuthStatus; /** The subaccount being viewed via the switcher, or null for the master account. */ viewingAccountId: string | null; + /** AuthResponse-derived identity for the selected subaccount. */ + viewingAccount: ViewingAccountSession | null; signIn: (session: Session) => void; signOut: (reason?: SignOutReason) => void; - setViewingAccount: (accountId: string | null) => void; + setViewingAccount: (account: ViewingAccountSession | null) => void; } export const AuthContext = createContext(null); @@ -76,11 +79,10 @@ export function AuthProvider({ children, store: injectedStore }: AuthProviderPro }; }, [store]); - // Drop all cached account data when the account signs out (user logout, idle - // expiry, or a duplicate-tab claim) or switches. Otherwise the previous account's - // figures -- payouts, hashrate, subaccount earnings -- linger in the query cache - // and could be shown to the next account on a shared browser. Keyed on the account - // id so it fires on the transition, not on every idle-activity session bump. + // Drop all cached account data when the master session changes or signs out. Queries + // within a session are keyed by the active account id, so master/subaccount switches + // remain isolated without discarding useful cached data. Keyed on the master id so + // this does not run on every idle-activity session bump. const accountId = state.session?.accountId ?? null; const prevAccountIdRef = useRef(accountId); useEffect(() => { @@ -97,10 +99,61 @@ export function AuthProvider({ children, store: injectedStore }: AuthProviderPro useEffect(() => { if (validatedRef.current) return; validatedRef.current = true; - if (!store.getSnapshot().session) return; + const restored = store.getSnapshot().session; + if (!restored) return; + const restoredViewing = store.getSnapshot().viewingAccount; + const stillValidatingRestoredSession = () => { + const current = store.getSnapshot().session; + return current?.accountId === restored.accountId && current.expiresAt === restored.expiresAt; + }; getUser() - .checkAuth() - .catch(() => store.signOut('expired')); + .checkAuth({ accountId: restored.accountId }) + .then((account) => { + if (!stillValidatingRestoredSession()) return; + store.updateSessionProfile({ + email: account.email, + company_name: account.company_name, + company_primary_location: account.company_primary_location, + kyb_status: account.kyb_status, + }); + }) + .catch((error: unknown) => { + // A temporary network or service failure should leave the local session intact; + // only an explicit authentication rejection proves that it has expired. + if (stillValidatingRestoredSession() && shouldEndSessionAfterValidation(error)) { + store.signOut('expired'); + } + }); + + if (restoredViewing) { + getUser() + .checkAuth({ accountId: restoredViewing.accountId }) + .then((account) => { + const current = store.getSnapshot(); + if ( + current.session?.accountId !== restored.accountId || + current.session.expiresAt !== restored.expiresAt || + current.viewingAccountId !== restoredViewing.accountId + ) { + return; + } + queryClient.setQueryData(['account', 'profile', String(account.id)], account); + store.setViewingAccount(viewingAccountFromAuth(account)); + }) + .catch((error: unknown) => { + const current = store.getSnapshot(); + if ( + current.session?.accountId === restored.accountId && + current.session.expiresAt === restored.expiresAt && + current.viewingAccountId === restoredViewing.accountId && + shouldEndSessionAfterValidation(error) + ) { + // The master login is still valid; only the selected subaccount cookie is + // gone, so return to main instead of logging the user out entirely. + store.setViewingAccount(null); + } + }); + } }, [store]); const value = useMemo( @@ -109,6 +162,7 @@ export function AuthProvider({ children, store: injectedStore }: AuthProviderPro signOutReason: state.signOutReason, status: state.session ? 'authenticated' : 'anonymous', viewingAccountId: state.viewingAccountId, + viewingAccount: state.viewingAccount, signIn: store.signIn, signOut: store.signOut, setViewingAccount: store.setViewingAccount, diff --git a/src/auth/__tests__/authStore.test.ts b/src/auth/__tests__/authStore.test.ts index f4fe3d4e..e4df3efd 100644 --- a/src/auth/__tests__/authStore.test.ts +++ b/src/auth/__tests__/authStore.test.ts @@ -3,6 +3,7 @@ import test from 'node:test'; import { createAuthStore } from '../authStore'; import { createSession } from '../session'; +import { createUser, setDmndAccountId } from '@/api/client'; function memoryStorage(): Storage { const map = new Map(); @@ -18,6 +19,24 @@ function memoryStorage(): Storage { } as Storage; } +const minerSession = (over: Partial[0]> = {}) => + createSession({ + accountId: 'master', + email: 'm@x.io', + company_name: 'DMND Mining', + company_primary_location: 'Lagos, NG', + kyb_status: 'Approved', + ...over, + }); + +const subaccountSession = (accountId: string) => ({ + accountId, + email: 'sub@x.io', + company_name: 'DMND Mining', + company_primary_location: 'Lagos, NG', + kyb_status: 'Approved' as const, +}); + /** * An in-process stand-in for BroadcastChannel: every channel built from the * same bus delivers postMessage to the others, like tabs of one browser. @@ -45,7 +64,7 @@ function channelBus() { test('a second tab claiming the same account signs the first tab out', () => { const bus = channelBus(); - const session = createSession({ accountId: '1', email: 'm@x.io' }); + const session = minerSession({ accountId: '1' }); const tabA = createAuthStore({ tabId: 'A', storage: memoryStorage(), channelFactory: bus.make }); tabA.connect(); @@ -66,11 +85,11 @@ test('a tab with a different account is left alone', () => { const tabA = createAuthStore({ tabId: 'A', storage: memoryStorage(), channelFactory: bus.make }); tabA.connect(); - tabA.signIn(createSession({ accountId: '1', email: 'a@x.io' })); + tabA.signIn(minerSession({ accountId: '1', email: 'a@x.io' })); const tabB = createAuthStore({ tabId: 'B', storage: memoryStorage(), channelFactory: bus.make }); tabB.connect(); - tabB.signIn(createSession({ accountId: '2', email: 'b@x.io' })); + tabB.signIn(minerSession({ accountId: '2', email: 'b@x.io' })); assert.equal(tabA.getSnapshot().session?.accountId, '1'); }); @@ -80,7 +99,7 @@ test('a store that never connected is not cleared by another tab claiming the sa // initializer is never mounted, so connect() never runs. It must not listen, // or a plain refresh would clear the session (the dev refresh bug). const bus = channelBus(); - const session = createSession({ accountId: '1', email: 'm@x.io' }); + const session = minerSession({ accountId: '1' }); const ghost = createAuthStore({ tabId: 'ghost', storage: memoryStorage(), channelFactory: bus.make }); ghost.signIn(session); // has the session, but never connect()ed @@ -93,21 +112,66 @@ test('a store that never connected is not cleared by another tab claiming the sa assert.equal(ghost.getSnapshot().session?.accountId, '1'); }); +test('reconnecting a restored session during refresh does not claim or clear itself', () => { + const bus = channelBus(); + const storage = memoryStorage(); + const beforeRefresh = createAuthStore({ tabId: 'before', storage, channelFactory: bus.make }); + beforeRefresh.connect(); + beforeRefresh.signIn(minerSession()); + + const afterRefresh = createAuthStore({ tabId: 'after', storage, channelFactory: bus.make }); + afterRefresh.connect(); + + assert.equal(beforeRefresh.getSnapshot().session?.accountId, 'master'); + assert.equal(afterRefresh.getSnapshot().session?.accountId, 'master'); + assert.equal(afterRefresh.getSnapshot().signOutReason, null); +}); + test('setViewingAccount scopes to a subaccount and back to the master account', () => { - const store = createAuthStore({ tabId: 'A', storage: memoryStorage(), channel: null }); - store.signIn(createSession({ accountId: 'master', email: 'm@x.io' })); + const storage = memoryStorage(); + const store = createAuthStore({ tabId: 'A', storage, channel: null }); + store.signIn(minerSession()); - store.setViewingAccount('sub-1'); + store.setViewingAccount(subaccountSession('sub-1')); assert.equal(store.getSnapshot().viewingAccountId, 'sub-1'); + assert.equal(store.getSnapshot().viewingAccount?.email, 'sub@x.io'); + + const restored = createAuthStore({ tabId: 'B', storage, channel: null }); + assert.equal(restored.getSnapshot().viewingAccountId, 'sub-1', 'refresh keeps the selected subaccount'); store.setViewingAccount(null); assert.equal(store.getSnapshot().viewingAccountId, null); + const restoredMain = createAuthStore({ tabId: 'C', storage, channel: null }); + assert.equal(restoredMain.getSnapshot().viewingAccountId, null); +}); + +test('restoring a selected subaccount immediately scopes the first API request to it', async () => { + const storage = memoryStorage(); + const first = createAuthStore({ tabId: 'A', storage, channel: null }); + first.signIn(minerSession()); + first.setViewingAccount(subaccountSession('sub-1')); + + createAuthStore({ tabId: 'B', storage, channel: null }); + const calls: RequestInit[] = []; + const client = createUser({ + fetchImpl: (async (_url: unknown, init: RequestInit) => { + calls.push(init); + return new Response('{}', { status: 200 }); + }) as typeof fetch, + backoffMs: 0, + }); + try { + await client.checkAuth(); + assert.equal((calls[0].headers as Record)['X-Account-ID'], 'sub-1'); + } finally { + setDmndAccountId(null); + } }); test('an idle bump keeps the viewed subaccount, but signing in or out resets it', () => { const store = createAuthStore({ tabId: 'A', storage: memoryStorage(), channel: null }); - store.signIn(createSession({ accountId: 'master', email: 'm@x.io' })); - store.setViewingAccount('sub-1'); + store.signIn(minerSession()); + store.setViewingAccount(subaccountSession('sub-1')); // An activity refresh must not kick the miner back to the master account. store.bumpActivity(); @@ -118,7 +182,30 @@ test('an idle bump keeps the viewed subaccount, but signing in or out resets it' assert.equal(store.getSnapshot().viewingAccountId, null); // A fresh sign-in starts on the master account, never a stale subaccount. - store.setViewingAccount('sub-2'); - store.signIn(createSession({ accountId: 'master', email: 'm@x.io' })); + store.setViewingAccount(subaccountSession('sub-2')); + store.signIn(minerSession()); assert.equal(store.getSnapshot().viewingAccountId, null); }); + +test('refreshing session profile fields preserves the selected account and expiry deadlines', () => { + const storage = memoryStorage(); + const store = createAuthStore({ tabId: 'A', storage, channel: null }); + store.signIn(minerSession({ company_name: null, company_primary_location: null, kyb_status: 'NotStarted' })); + store.setViewingAccount(subaccountSession('sub-1')); + const before = store.getSnapshot().session; + + store.updateSessionProfile({ + email: 'updated@x.io', + company_name: 'Updated Mining', + company_primary_location: 'Abuja, NG', + kyb_status: 'Approved', + }); + + const after = store.getSnapshot(); + assert.equal(after.viewingAccountId, 'sub-1'); + assert.equal(after.session?.email, 'updated@x.io'); + assert.equal(after.session?.company_name, 'Updated Mining'); + assert.equal(after.session?.expiresAt, before?.expiresAt); + assert.equal(after.session?.idleExpiresAt, before?.idleExpiresAt); + assert.equal(JSON.parse(storage.getItem('dmnd_session') ?? '').company_name, 'Updated Mining'); +}); diff --git a/src/auth/__tests__/session.test.ts b/src/auth/__tests__/session.test.ts index 80df6bcd..4f2e05ab 100644 --- a/src/auth/__tests__/session.test.ts +++ b/src/auth/__tests__/session.test.ts @@ -9,6 +9,7 @@ import { isExpired, readSession, refreshIdle, + viewingAccountFromAuth, writeSession, } from '../session'; @@ -26,14 +27,23 @@ function memoryStorage(): Storage { } as Storage; } +const sessionInput = (over: Partial[0]> = {}) => ({ + accountId: 'a1', + email: 'm@x.io', + company_name: 'DMND Mining', + company_primary_location: 'Lagos, NG', + kyb_status: 'Approved' as const, + ...over, +}); + test('createSession sets the fixed and idle deadlines from now', () => { - const s = createSession({ accountId: 'a1', email: 'm@x.io', now: 1_000 }); + const s = createSession(sessionInput({ now: 1_000 })); assert.equal(s.expiresAt, 1_000 + FIXED_TTL_MS); assert.equal(s.idleExpiresAt, 1_000 + IDLE_TTL_MS); }); test('isExpired trips on either the idle or the fixed deadline', () => { - const s = createSession({ accountId: 'a1', email: 'm@x.io', now: 0 }); + const s = createSession(sessionInput({ now: 0 })); assert.equal(isExpired(s, IDLE_TTL_MS - 1), false); assert.equal(isExpired(s, IDLE_TTL_MS), true); // idle hits first const active = refreshIdle(s, FIXED_TTL_MS - 1); @@ -41,7 +51,7 @@ test('isExpired trips on either the idle or the fixed deadline', () => { }); test('refreshIdle extends only the idle deadline', () => { - const s = createSession({ accountId: 'a1', email: 'm@x.io', now: 0 }); + const s = createSession(sessionInput({ now: 0 })); const r = refreshIdle(s, 5_000); assert.equal(r.expiresAt, s.expiresAt); assert.equal(r.idleExpiresAt, 5_000 + IDLE_TTL_MS); @@ -49,12 +59,15 @@ test('refreshIdle extends only the idle deadline', () => { test('readSession round-trips a written session', () => { const storage = memoryStorage(); - const s = createSession({ accountId: 'a1', email: 'm@x.io' }); + const s = createSession(sessionInput()); writeSession(s, storage); assert.deepEqual(readSession(storage), s); + assert.equal(readSession(storage)?.company_name, 'DMND Mining'); + assert.equal(readSession(storage)?.company_primary_location, 'Lagos, NG'); + assert.equal(readSession(storage)?.kyb_status, 'Approved'); }); -test('readSession returns null for missing, malformed, or wrong-shaped data', () => { +test('readSession returns null for missing, malformed, or invalid core data', () => { const storage = memoryStorage(); assert.equal(readSession(storage), null); @@ -66,14 +79,42 @@ test('readSession returns null for missing, malformed, or wrong-shaped data', () storage.setItem(STORAGE_KEY, JSON.stringify({ email: 'm@x.io', expiresAt: 1, idleExpiresAt: 1 })); assert.equal(readSession(storage), null); // no accountId + + storage.setItem( + STORAGE_KEY, + JSON.stringify({ accountId: 'a1', email: 'm@x.io', expiresAt: 'later', idleExpiresAt: Number.MAX_SAFE_INTEGER }), + ); + assert.equal(readSession(storage), null); // invalid deadline }); test('readSession discards and clears an expired session', () => { const storage = memoryStorage(); - writeSession(createSession({ accountId: 'a1', email: 'm@x.io', now: 0 }), storage); + writeSession(createSession(sessionInput({ now: 0 })), storage); // far past both deadlines: stub Date.now via an expired write - const expired = { accountId: 'a1', email: 'm@x.io', expiresAt: 1, idleExpiresAt: 1 }; + const expired = { ...sessionInput(), expiresAt: 1, idleExpiresAt: 1 }; storage.setItem(STORAGE_KEY, JSON.stringify(expired)); assert.equal(readSession(storage), null); assert.equal(storage.getItem(STORAGE_KEY), null); }); + +test('viewingAccountFromAuth uses log_subaccount AuthResponse.id as the active account', () => { + const viewing = viewingAccountFromAuth({ + id: 'returned-subaccount-id', + email: 'sub@x.io', + company_name: 'DMND Mining', + company_primary_location: 'Lagos, NG', + kyb_status: 'Approved', + token: 'pplns-token', + fpps_token: 'fpps-token', + two_factor_secret: null, + bitcoin_addresses: {}, + }); + + assert.deepEqual(viewing, { + accountId: 'returned-subaccount-id', + email: 'sub@x.io', + company_name: 'DMND Mining', + company_primary_location: 'Lagos, NG', + kyb_status: 'Approved', + }); +}); diff --git a/src/auth/__tests__/sessionValidation.test.ts b/src/auth/__tests__/sessionValidation.test.ts new file mode 100644 index 00000000..80320a2b --- /dev/null +++ b/src/auth/__tests__/sessionValidation.test.ts @@ -0,0 +1,12 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { DmndApiError } from '@/api'; +import { shouldEndSessionAfterValidation } from '../sessionValidation'; + +test('startup validation ends a session only for an authentication rejection', () => { + assert.equal(shouldEndSessionAfterValidation(new DmndApiError('expired', 'unauthorized')), true); + assert.equal(shouldEndSessionAfterValidation(new DmndApiError('temporarily unavailable', 'server')), false); + assert.equal(shouldEndSessionAfterValidation(new DmndApiError('offline', 'network')), false); + assert.equal(shouldEndSessionAfterValidation(new Error('unexpected response')), false); +}); diff --git a/src/auth/authStore.ts b/src/auth/authStore.ts index d2418929..3806f2ba 100644 --- a/src/auth/authStore.ts +++ b/src/auth/authStore.ts @@ -5,6 +5,8 @@ import { clearSession, refreshIdle, isExpired, + isKybStatus, + type ViewingAccountSession, } from './session'; import { setDmndAccountId } from '@/api'; @@ -14,9 +16,9 @@ export interface AuthState { session: Session | null; signOutReason: SignOutReason | null; // The subaccount currently being viewed via the account switcher, or null for the - // master account. Kept in memory only (never written to storage) so a reload always - // returns to the master account rather than silently staying scoped to a subaccount. + // master account. Stored per tab so refreshing keeps the account the user selected. viewingAccountId: string | null; + viewingAccount: ViewingAccountSession | null; } export interface AuthStore { @@ -26,8 +28,10 @@ export interface AuthStore { connect: () => void; signIn: (session: Session) => void; signOut: (reason?: SignOutReason) => void; - /** Scope the dashboard to a subaccount (id) or back to the master account (null). */ - setViewingAccount: (accountId: string | null) => void; + /** Refresh display fields from check_auth without resetting account scope or deadlines. */ + updateSessionProfile: (profile: Pick) => void; + /** Scope the dashboard to an authenticated subaccount or back to the master. */ + setViewingAccount: (account: ViewingAccountSession | null) => void; bumpActivity: (now?: number) => void; checkExpiry: (now?: number) => void; tabId: string; @@ -43,6 +47,7 @@ export interface AuthStoreOptions { const CHANNEL_NAME = 'dmnd_auth'; const CLAIM_MSG = 'CLAIM_SESSION'; +const VIEWING_ACCOUNT_KEY = 'dmnd_viewing_account'; interface ClaimMessage { type: typeof CLAIM_MSG; @@ -80,18 +85,56 @@ export function createAuthStore(options: AuthStoreOptions = {}): AuthStore { throw new Error('createAuthStore: no Storage available'); } + // A stored selection either matches what log_subaccount returns today or it is + // dropped, which simply puts the tab back on the master account. + const readViewingAccount = (): ViewingAccountSession | null => { + const raw = storage.getItem(VIEWING_ACCOUNT_KEY); + if (!raw) return null; + try { + const value: unknown = JSON.parse(raw); + if (typeof value !== 'object' || value === null) return null; + const account = value as Record; + if ( + typeof account.accountId !== 'string' || + !account.accountId || + typeof account.email !== 'string' || + !isKybStatus(account.kyb_status) + ) { + return null; + } + return { + accountId: account.accountId, + email: account.email, + company_name: typeof account.company_name === 'string' ? account.company_name : null, + company_primary_location: + typeof account.company_primary_location === 'string' ? account.company_primary_location : null, + kyb_status: account.kyb_status, + }; + } catch { + return null; + } + }; + const writeViewingAccount = (value: ViewingAccountSession | null) => { + if (value) storage.setItem(VIEWING_ACCOUNT_KEY, JSON.stringify(value)); + else storage.removeItem(VIEWING_ACCOUNT_KEY); + }; + const listeners = new Set<() => void>(); + const restoredSession = readSession(storage); + if (!restoredSession) writeViewingAccount(null); + const restoredViewingAccount = restoredSession ? readViewingAccount() : null; + if (restoredViewingAccount) writeViewingAccount(restoredViewingAccount); + else writeViewingAccount(null); let state: AuthState = { - session: readSession(storage), + session: restoredSession, signOutReason: null, - // A restored session always starts on the master account: the view scope is never - // persisted, so a reload cannot land the miner inside a subaccount. - viewingAccountId: null, + viewingAccountId: restoredViewingAccount?.accountId ?? null, + viewingAccount: restoredViewingAccount, }; // Keep the cloud client's X-Account-ID in lockstep with the session, set // synchronously here (not in a React effect) so a restored session has it // before the first authed call fires. - setDmndAccountId(state.session?.accountId ?? null); + setDmndAccountId(state.viewingAccountId ?? state.session?.accountId ?? null); const emit = () => { for (const l of listeners) l(); @@ -118,7 +161,8 @@ export function createAuthStore(options: AuthStoreOptions = {}): AuthStore { if (!state.session) return; if (m.accountId !== state.session.accountId) return; clearSession(storage); - setState({ session: null, signOutReason: 'duplicate_tab', viewingAccountId: null }); + writeViewingAccount(null); + setState({ session: null, signOutReason: 'duplicate_tab', viewingAccountId: null, viewingAccount: null }); }; // Best-effort cross-tab claim. The channel can be closed (e.g. a StrictMode @@ -134,8 +178,10 @@ export function createAuthStore(options: AuthStoreOptions = {}): AuthStore { return { tabId, connect() { - // Subscribe to the cross-tab channel and claim the current session. Run - // from a React effect (not the constructor) so a store that is built but + // Subscribe to the cross-tab channel. A restored session does not broadcast a + // claim: doing so makes a reload race the document being replaced and can clear + // its own session. Only an explicit sign-in claims the account in another tab. + // Subscribe from a React effect (not the constructor) so a store that is built but // never mounted -- e.g. StrictMode double-invoking the useState // initializer in dev -- never listens, and so can't clear another // instance's session on a refresh. @@ -143,7 +189,6 @@ export function createAuthStore(options: AuthStoreOptions = {}): AuthStore { channel = resolveChannel(); if (channel) { channel.onmessage = (ev: MessageEvent) => handleMessage(ev.data); - if (state.session) postClaim(state.session.accountId); } }, subscribe(cb) { @@ -159,18 +204,27 @@ export function createAuthStore(options: AuthStoreOptions = {}): AuthStore { // A fresh sign-in always starts on the master account, clearing any stale view // scope from a previous session. writeSession(session, storage); - setState({ session, signOutReason: null, viewingAccountId: null }); + writeViewingAccount(null); + setState({ session, signOutReason: null, viewingAccountId: null, viewingAccount: null }); postClaim(session.accountId); }, signOut(reason: SignOutReason = 'user') { clearSession(storage); - setState({ session: null, signOutReason: reason, viewingAccountId: null }); + writeViewingAccount(null); + setState({ session: null, signOutReason: reason, viewingAccountId: null, viewingAccount: null }); + }, + updateSessionProfile(profile) { + if (!state.session) return; + const session = { ...state.session, ...profile }; + writeSession(session, storage); + setState({ ...state, session }); }, - setViewingAccount(accountId: string | null) { + setViewingAccount(account: ViewingAccountSession | null) { if (!state.session) return; // Only re-scope the view; the master session is untouched, so switching back is // just clearing this to null. - setState({ ...state, viewingAccountId: accountId }); + writeViewingAccount(account); + setState({ ...state, viewingAccountId: account?.accountId ?? null, viewingAccount: account }); }, bumpActivity(now?: number) { if (!state.session) return; @@ -184,7 +238,8 @@ export function createAuthStore(options: AuthStoreOptions = {}): AuthStore { if (!state.session) return; if (isExpired(state.session, now)) { clearSession(storage); - setState({ session: null, signOutReason: 'expired', viewingAccountId: null }); + writeViewingAccount(null); + setState({ session: null, signOutReason: 'expired', viewingAccountId: null, viewingAccount: null }); } }, teardown() { diff --git a/src/auth/index.ts b/src/auth/index.ts index ba2ea28b..3444fc3a 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -14,8 +14,9 @@ export { FIXED_TTL_MS, IDLE_TTL_MS, STORAGE_KEY, + viewingAccountFromAuth, } from './session'; -export type { Session, CreateSessionInput } from './session'; +export type { Session, CreateSessionInput, KybStatus, ViewingAccountSession } from './session'; export { readNextParam } from './nextParam'; export { BrokerAuthProvider, useBrokerAuth } from './BrokerAuthProvider'; export type { BrokerAuthContextValue } from './BrokerAuthProvider'; diff --git a/src/auth/session.ts b/src/auth/session.ts index c8361ade..947a35eb 100644 --- a/src/auth/session.ts +++ b/src/auth/session.ts @@ -1,11 +1,34 @@ +import type { DmndSession } from '@/api/types'; + export const FIXED_TTL_MS = 8 * 60 * 60 * 1000; export const IDLE_TTL_MS = 30 * 60 * 1000; export const STORAGE_KEY = 'dmnd_session'; +export type KybStatus = 'NotStarted' | 'InReview' | 'Approved' | 'Rejected'; + +export interface ViewingAccountSession { + accountId: string; + email: string; + company_name: string | null; + company_primary_location: string | null; + kyb_status: KybStatus; +} + +/** Preserve the identity/profile returned by log_subaccount; its id selects its cookie. */ +export function viewingAccountFromAuth(account: DmndSession): ViewingAccountSession { + return { + accountId: String(account.id), + email: account.email, + company_name: account.company_name, + company_primary_location: account.company_primary_location, + kyb_status: account.kyb_status, + }; +} + /** * The browser-side session. Auth itself lives in the backend's HttpOnly cookie, - * which JS can't read, so we keep only lightweight, non-sensitive data: the - * account id (sent as X-Account-ID on authed calls) and the email for display. + * which JS can't read, so we keep only lightweight, non-sensitive data from the + * auth response: account/company display fields and the id sent as X-Account-ID. * Lives in sessionStorage (per tab, gone on tab close). The two timestamps are a * UX convenience; real expiry is enforced server-side (the cookie + check_auth), * so the user is sent back to sign-in promptly rather than discovering a dead @@ -14,6 +37,9 @@ export const STORAGE_KEY = 'dmnd_session'; export interface Session { accountId: string; email: string; + company_name: string | null; + company_primary_location: string | null; + kyb_status: KybStatus; expiresAt: number; idleExpiresAt: number; } @@ -21,6 +47,9 @@ export interface Session { export interface CreateSessionInput { accountId: string; email: string; + company_name: string | null; + company_primary_location: string | null; + kyb_status: KybStatus; now?: number; } @@ -29,6 +58,9 @@ export function createSession(input: CreateSessionInput): Session { return { accountId: input.accountId, email: input.email, + company_name: input.company_name, + company_primary_location: input.company_primary_location, + kyb_status: input.kyb_status, expiresAt: now + FIXED_TTL_MS, idleExpiresAt: now + IDLE_TTL_MS, }; @@ -47,12 +79,12 @@ export function readSession(storage: Storage = sessionStorage): Session | null { const raw = storage.getItem(STORAGE_KEY); if (!raw) return null; const parsed: unknown = JSON.parse(raw); - if (!isValidSession(parsed)) return null; - if (isExpired(parsed)) { + const session = parseSession(parsed); + if (!session || isExpired(session)) { storage.removeItem(STORAGE_KEY); return null; } - return parsed; + return session; } catch { return null; } @@ -66,14 +98,35 @@ export function clearSession(storage: Storage = sessionStorage): void { storage.removeItem(STORAGE_KEY); } -function isValidSession(v: unknown): v is Session { - if (typeof v !== 'object' || v === null) return false; +export const KYB_STATUSES: KybStatus[] = ['NotStarted', 'InReview', 'Approved', 'Rejected']; + +export function isKybStatus(v: unknown): v is KybStatus { + return typeof v === 'string' && KYB_STATUSES.includes(v as KybStatus); +} + +/** A stored value is a session only if it matches the shape the backend sends today. */ +function parseSession(v: unknown): Session | null { + if (typeof v !== 'object' || v === null) return null; const s = v as Record; - return ( - typeof s.accountId === 'string' && - s.accountId.length > 0 && - typeof s.email === 'string' && - typeof s.expiresAt === 'number' && - typeof s.idleExpiresAt === 'number' - ); + if ( + typeof s.accountId !== 'string' || + s.accountId.length === 0 || + typeof s.email !== 'string' || + !isKybStatus(s.kyb_status) || + typeof s.expiresAt !== 'number' || + !Number.isFinite(s.expiresAt) || + typeof s.idleExpiresAt !== 'number' || + !Number.isFinite(s.idleExpiresAt) + ) { + return null; + } + return { + accountId: s.accountId, + email: s.email, + company_name: typeof s.company_name === 'string' ? s.company_name : null, + company_primary_location: typeof s.company_primary_location === 'string' ? s.company_primary_location : null, + kyb_status: s.kyb_status, + expiresAt: s.expiresAt, + idleExpiresAt: s.idleExpiresAt, + }; } diff --git a/src/auth/sessionValidation.ts b/src/auth/sessionValidation.ts new file mode 100644 index 00000000..944443c6 --- /dev/null +++ b/src/auth/sessionValidation.ts @@ -0,0 +1,6 @@ +import { DmndApiError } from '@/api'; + +/** Only a 401/403 response proves the stored login is no longer valid. */ +export function shouldEndSessionAfterValidation(error: unknown): boolean { + return error instanceof DmndApiError && error.code === 'unauthorized'; +} diff --git a/src/components/home/GettingStartedCard.tsx b/src/components/home/GettingStartedCard.tsx index d4ef7d52..bed68eb4 100644 --- a/src/components/home/GettingStartedCard.tsx +++ b/src/components/home/GettingStartedCard.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { Link } from 'wouter'; import { LiCheckCircle, LiAltArrowDown, LiAltArrowUp, LiCloseCircle } from 'solar-icon-react/li'; -import { useAccountAllWorkers, useAccountProfile } from '@/hooks/useAccountData'; +import { activeBitcoinAddress, useAccountAllWorkers, useAccountProfile } from '@/hooks/useAccountData'; import { cn } from '@/lib/utils'; import type { DmndSession } from '@/api/types'; @@ -10,10 +10,7 @@ import type { DmndSession } from '@/api/types'; const DISMISS_KEY = 'dmnd.gettingStarted.dismissed'; function hasBitcoinAddress(account: DmndSession | undefined): boolean { - const addrs = account?.bitcoin_addresses; - if (Array.isArray(addrs)) return addrs.length > 0; - if (addrs && typeof addrs === 'object') return Object.keys(addrs).length > 0; - return false; + return activeBitcoinAddress(account) !== null; } /** diff --git a/src/components/home/KybNotice.tsx b/src/components/home/KybNotice.tsx new file mode 100644 index 00000000..359b870f --- /dev/null +++ b/src/components/home/KybNotice.tsx @@ -0,0 +1,39 @@ +import { BdClockCircle, BdShieldWarning } from 'solar-icon-react/bd'; +import type { KybStatus } from '@/auth'; +import { useAccountProfile } from '@/hooks/useAccountData'; + +export const KYB_VERIFICATION_URL = 'https://in.sumsub.com/websdk/p/uni_MFAZElUyajMzWfft'; + +const LINK = 'font-medium underline underline-offset-2'; + +/** + * The home page's KYB reminder. Only the two states before approval + * appear here, so the strip disappears once KYB passes. + */ +export function KybNotice() { + const { data: profile } = useAccountProfile(); + const status: KybStatus | undefined = profile?.kyb_status; + if (status !== 'NotStarted' && status !== 'InReview') return null; + + const Icon = status === 'InReview' ? BdClockCircle : BdShieldWarning; + + return ( +
+ {/* 2px low so it lines up with the copy's cap height, matching AggregatedBanner. */} + +

+ {status === 'NotStarted' ? ( + <> + You're currently in test mode. You can still mine, but payouts are paused until{' '} + + KYB verification + {' '} + is complete. Once verified, you'll be paid for all submitted hashrate. + + ) : ( + "Your KYB verification is under review." + )} +

+
+ ); +} diff --git a/src/components/settings/AccountTab.tsx b/src/components/settings/AccountTab.tsx index fcae736a..dc6d1376 100644 --- a/src/components/settings/AccountTab.tsx +++ b/src/components/settings/AccountTab.tsx @@ -1,24 +1,14 @@ import { useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { LiCopy, LiCheckCircle } from 'solar-icon-react/li'; -import { BdClockCircle } from 'solar-icon-react/bd'; -import { useAccountProfile, userBitcoinAddresses } from '@/hooks/useAccountData'; +import { BdCheckCircle, BdClockCircle, BdShieldWarning } from 'solar-icon-react/bd'; +import { useAuth, type Session } from '@/auth'; +import { activeBitcoinAddress, useAccountProfile } from '@/hooks/useAccountData'; +import { KYB_VERIFICATION_URL } from '@/components/home/KybNotice'; import { useAccountScope } from '@/hooks/useAccountScope'; import { truncateMiddle } from '@/lib/payoutsTable'; import { ChangeBitcoinAddressModal } from './ChangeBitcoinAddressModal'; -// The session does not yet carry the miner's name or company (tracked server-side by -// issue #14); until it does, the profile fields show placeholder values so the section -// keeps its designed shape. Editing profile info is not allowed for now, so the fields -// are read-only (no Save button); swap these for the real values, and the KYB status, -// once the account endpoint returns them. -const PROFILE_PLACEHOLDER = { - firstName: 'John', - lastName: 'Doe', - companyName: 'DMND Mining Ltd', - companyLocation: 'Lisbon, PT', -}; - /** A labelled read-only field styled like the other settings inputs. */ function ReadonlyField({ label, value, children }: { label: string; value: string; children?: React.ReactNode }) { return ( @@ -32,6 +22,45 @@ function ReadonlyField({ label, value, children }: { label: string; value: strin ); } +function KybStatus({ status }: { status: Session['kyb_status'] }) { + if (status === 'NotStarted') { + return ( + + + + You're currently in test mode. You can still mine, but payouts are paused until{' '} + + KYB verification + {' '} + is complete. Once verified, you'll be paid for all submitted hashrate. + + + ); + } + if (status === 'InReview') { + return ( + + + Your KYB verification is under review. + + ); + } + if (status === 'Approved') { + return ( + + + KYB approved. All good! + + ); + } + return null; +} + function CopyAddressButton({ value }: { value: string }) { const [copied, setCopied] = useState(false); return ( @@ -50,88 +79,85 @@ function CopyAddressButton({ value }: { value: string }) { ); } -/** - * The Account tab. For now it surfaces the payout Bitcoin address (the one account - * detail the API lets a miner change); profile name/company are read-only server-side - * and not yet returned by the session, so that block is intentionally omitted until - * the backend exposes it. - */ export function AccountTab() { + const { session, viewingAccount } = useAuth(); const { data: profile, isLoading, isError } = useAccountProfile(); const queryClient = useQueryClient(); const [changing, setChanging] = useState(false); // The payout address of a subaccount is the master's to set; the pool reports this as // `edit_btc_address: false` on the subaccount's own permissions, so the control stays // visible (it is part of the design) but cannot be used. - const { canEditBitcoinAddress } = useAccountScope(); + const { canEditBitcoinAddress, viewingSubaccount } = useAccountScope(); - const addresses = profile ? [...userBitcoinAddresses(profile)] : []; + const payoutAddress = activeBitcoinAddress(profile); + const accountDetails = viewingAccount ?? session; return (

Profile

-

Manage your personal information and company details

+

Review your account and company details

-
- - -
- - - - KYB verification is in review - - - + {accountDetails && ( + <> + {!viewingSubaccount && } + + + + + + )}
-
-

Bitcoin address

-

This is the address you receive your mining payouts.

-
-
- - {isLoading ? ( -
- ) : isError ? ( -

Couldn't load your account details. Please try again.

- ) : addresses.length === 0 ? ( -
-

You haven't set a payout address yet.

- +
+

Bitcoin address

+

This is the address you receive your mining payouts.

- ) : ( -
- Bitcoin address -
-
- - {truncateMiddle(addresses[0], 10, 8)} - - -
+
+ + {isLoading ? ( +
+ ) : isError ? ( +

Couldn't load your account details. Please try again.

+ ) : !payoutAddress ? ( +
+

You haven't set a payout address yet.

-
- )} + ) : ( +
+ Bitcoin address +
+
+ + {truncateMiddle(payoutAddress, 10, 8)} + + +
+ +
+
+ )}
{changing && ( diff --git a/src/hooks/useAccountData.ts b/src/hooks/useAccountData.ts index 2c5f2347..92aaac01 100644 --- a/src/hooks/useAccountData.ts +++ b/src/hooks/useAccountData.ts @@ -6,10 +6,9 @@ import { downsampleHashrate, rangeToWindow } from '@/lib/hashrateHistory'; import { subaccountSeriesToPoints, sumHashrateSeries } from '@/lib/aggregatedHashrate'; import { useSubaccountList } from './useSubaccounts'; import { fetchConfirmedTxsSince, startOfUtcDaySec, sumOutputsTo } from '@/lib/blockstream'; +import { useActiveAccountId } from './useActiveAccountId'; -// The DMND API server data refreshes every 5 minutes (spec cadence), unlike the -// 3s local telemetry. The client already retries transient failures, so the -// queries don't retry on top of it. +// The UI checks account data every five minutes. const CLOUD_POLL_MS = 5 * 60 * 1000; // The historical series is dense (~one sample every two minutes); cap the points @@ -23,9 +22,10 @@ const EARNINGS_POLL_MS = 15 * 60 * 1000; /** Live hashrate snapshot for the signed-in account (home live-hashrate card). */ export function useAccountHashrate() { const { session } = useAuth(); + const accountId = useActiveAccountId(); return useQuery({ - queryKey: ['account', 'hashrate'], - queryFn: ({ signal }) => getUser().getHashrate({ signal }), + queryKey: ['account', 'hashrate', accountId], + queryFn: ({ signal }) => getUser().getHashrate({ signal, accountId: accountId ?? undefined }), enabled: !!session, refetchInterval: CLOUD_POLL_MS, staleTime: CLOUD_POLL_MS, @@ -41,14 +41,18 @@ export function useAccountHashrate() { */ export function useAccountHashrateHistory(range: HashrateRange, custom?: { from: string; to: string } | null) { const { session } = useAuth(); + const accountId = useActiveAccountId(); // A custom window is a fixed span, so it does not slide with "now" and its key is // the explicit from/to; a preset recomputes its window on each fetch. const key = custom ? `custom:${custom.from}:${custom.to}` : range; return useQuery({ - queryKey: ['account', 'hashrate-history', key], + queryKey: ['account', 'hashrate-history', key, accountId], queryFn: async ({ signal }) => { const window = custom ?? rangeToWindow(range, Date.now()); - const points = await getUser().getHashrateHistory(window.from, window.to, { signal }); + const points = await getUser().getHashrateHistory(window.from, window.to, { + signal, + accountId: accountId ?? undefined, + }); return downsampleHashrate(points, MAX_CHART_POINTS); }, enabled: !!session, @@ -63,15 +67,18 @@ export function useAccountHashrateHistory(range: HashrateRange, custom?: { from: /** * Full account profile (checkAuth): the pool tokens for the connect-workers card * and the 2FA / payout state for the getting-started checklist. These values are - * stable, so it's fetched once and not polled. + * mostly stable. While KYB is under review, refresh it with the rest of the account + * data so the status can update without requiring a reload. */ export function useAccountProfile() { const { session } = useAuth(); + const accountId = useActiveAccountId(); return useQuery({ - queryKey: ['account', 'profile'], - queryFn: ({ signal }) => getUser().checkAuth({ signal }), + queryKey: ['account', 'profile', accountId], + queryFn: ({ signal }) => getUser().checkAuth({ signal, accountId: accountId ?? undefined }), enabled: !!session, - staleTime: Infinity, + refetchInterval: (query) => (query.state.data?.kyb_status === 'InReview' ? CLOUD_POLL_MS : false), + staleTime: CLOUD_POLL_MS, refetchOnWindowFocus: false, retry: false, }); @@ -80,9 +87,10 @@ export function useAccountProfile() { /** Per-worker roster for a date range; used by the workers page. */ export function useAccountWorkers(from: string, to: string) { const { session } = useAuth(); + const accountId = useActiveAccountId(); return useQuery({ - queryKey: ['account', 'workers', from, to], - queryFn: ({ signal }) => getUser().getWorkers(from, to, { signal }), + queryKey: ['account', 'workers', from, to, accountId], + queryFn: ({ signal }) => getUser().getWorkers(from, to, { signal, accountId: accountId ?? undefined }), enabled: !!session && !!from && !!to, refetchInterval: CLOUD_POLL_MS, staleTime: CLOUD_POLL_MS, @@ -91,7 +99,6 @@ export function useAccountWorkers(from: string, to: string) { }); } -/** The full worker roster (every page) for the home's Active / Offline counts. */ /** * The combined hashrate series across the main account and every subaccount, for the * chart in aggregated mode. Each account is fetched over the same window and the @@ -106,20 +113,24 @@ export function useAggregatedHashrateHistory( enabled = true, ) { const { session } = useAuth(); + const ownerAccountId = session?.accountId ?? null; const { data: subs } = useSubaccountList(); const key = custom ? `custom:${custom.from}:${custom.to}` : range; return useQuery({ - queryKey: ['account', 'hashrate-history', 'aggregated', key], + queryKey: ['account', 'hashrate-history', 'aggregated', key, ownerAccountId], queryFn: async ({ signal }) => { const client = getUser(); const owners = subs ?? []; const window = custom ?? rangeToWindow(range, Date.now()); const [mainPoints, subSeries] = await Promise.all([ - client.getHashrateHistory(window.from, window.to, { signal }), + client.getHashrateHistory(window.from, window.to, { signal, accountId: ownerAccountId ?? undefined }), Promise.all( owners.map((s) => client - .getSubaccountHashrateHistory(s.id, s.token, window.from, window.to, { signal }) + .getSubaccountHashrateHistory(s.id, s.token, window.from, window.to, { + signal, + accountId: ownerAccountId ?? undefined, + }) .then(subaccountSeriesToPoints), ), ), @@ -135,15 +146,15 @@ export function useAggregatedHashrateHistory( } /** - * The account's own 24h share counts. Only fetched for the aggregated roll-up, which - * needs the main account's accepted/rejected on the same basis as each subaccount's - * `summary.share_stats` so the combined rejection rate covers one consistent window. + * The account's own 24h share counts. The single-account home and aggregated roll-up + * both use this endpoint so rejection rate always has the same explicit time window. */ export function useAccountShareStats(enabled = true) { const { session } = useAuth(); + const accountId = useActiveAccountId(); return useQuery({ - queryKey: ['account', 'share-stats'], - queryFn: ({ signal }) => getUser().getShareStats({ signal }), + queryKey: ['account', 'share-stats', accountId], + queryFn: ({ signal }) => getUser().getShareStats({ signal, accountId: accountId ?? undefined }), enabled: !!session && enabled, refetchInterval: CLOUD_POLL_MS, staleTime: CLOUD_POLL_MS, @@ -152,11 +163,13 @@ export function useAccountShareStats(enabled = true) { }); } +/** The full worker roster (every page) for the home's Active / Offline counts. */ export function useAccountAllWorkers() { const { session } = useAuth(); + const accountId = useActiveAccountId(); return useQuery({ - queryKey: ['account', 'workers-all'], - queryFn: ({ signal }) => getUser().getAllWorkers({ signal }), + queryKey: ['account', 'workers-all', accountId], + queryFn: ({ signal }) => getUser().getAllWorkers({ signal, accountId: accountId ?? undefined }), enabled: !!session, refetchInterval: CLOUD_POLL_MS, staleTime: CLOUD_POLL_MS, @@ -165,16 +178,16 @@ export function useAccountAllWorkers() { }); } -/** The user's own bitcoin (receiving) addresses from the profile (check_auth). */ +/** + * All the user's bitcoin addresses + */ export function userBitcoinAddresses(profile: DmndSession | undefined): Set { - const out = new Set(); - const addrs = profile?.bitcoin_addresses; - if (Array.isArray(addrs)) { - for (const a of addrs) if (typeof a === 'string' && a) out.add(a); - } else if (addrs && typeof addrs === 'object') { - for (const key of Object.keys(addrs)) if (key) out.add(key); - } - return out; + return new Set(Object.keys(profile?.bitcoin_addresses ?? {}).filter(Boolean)); +} + +export function activeBitcoinAddress(profile: DmndSession | undefined): string | null { + const active = Object.entries(profile?.bitcoin_addresses ?? {}).find(([address, isActive]) => isActive && address); + return active?.[0] ?? null; } /** @@ -189,13 +202,14 @@ export function userBitcoinAddresses(profile: DmndSession | undefined): Set { const userAddrs = userBitcoinAddresses(profile); if (userAddrs.size === 0) return 0; // no receiving address set -> nothing to receive - const payout = await getUser().getPayoutAddresses({ signal }); + const payout = await getUser().getPayoutAddresses({ signal, accountId: accountId ?? undefined }); const wallets = [...new Set([payout.fpps_payout_address, payout.pplns_payout_address].filter(Boolean))]; if (wallets.length === 0) return 0; const since = startOfUtcDaySec(Date.now()); diff --git a/src/hooks/useAccountSwitcher.ts b/src/hooks/useAccountSwitcher.ts index 8c67df76..72fb4b29 100644 --- a/src/hooks/useAccountSwitcher.ts +++ b/src/hooks/useAccountSwitcher.ts @@ -1,38 +1,41 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useState } from 'react'; import { useLocation } from 'wouter'; -import { useQueryClient, type QueryKey } from '@tanstack/react-query'; +import { useQuery, useQueryClient, type QueryKey } from '@tanstack/react-query'; import { getUser } from '@/api'; -import { useAuth } from '@/auth'; +import { useAuth, viewingAccountFromAuth } from '@/auth'; import { isSubaccountRestrictedRoute } from '@/components/dashboard/nav'; -import { useAccountProfile } from './useAccountData'; -// The subaccount list belongs to the master account, not to whichever account is -// currently being viewed -- `getSubaccounts()` returns an empty list once -// authenticated as a subaccount (verified live), so this entry must survive a switch -// or the account switcher and the aggregated-mode gating (useHasSubaccounts) would -// both read "no subaccounts" the moment a miner drills into one, with no way back -// except a reload. -const SUBACCOUNT_LIST_KEY = ['account', 'subaccounts', 'list']; +// The subaccount list belongs to the master and is required to keep the switcher +// usable while a subaccount is selected. +function isMasterSubaccountListKey(key: QueryKey): boolean { + return key[0] === 'account' && key[1] === 'subaccounts' && key[2] === 'list'; +} -function isSubaccountListKey(key: QueryKey): boolean { - return key.length === SUBACCOUNT_LIST_KEY.length && key.every((part, i) => part === SUBACCOUNT_LIST_KEY[i]); +function shouldClearOnAccountSwitch(key: QueryKey, masterAccountId: string): boolean { + const isMasterProfile = key[0] === 'account' && key[1] === 'profile' && key[2] === masterAccountId; + return key[0] === 'account' && !isMasterSubaccountListKey(key) && !isMasterProfile; } /** * Switching which account the dashboard reads. Selecting a subaccount issues a * subaccount session first (the pool scopes reads by that cookie plus the account * header), then points the client at it; returning to the main account just drops the - * override, since the master session was never replaced. Every OTHER cached query is - * cleared on each switch so one account's figures can never render under another's - * name; the subaccount list is the one exception (see SUBACCOUNT_LIST_KEY). + * override, since the master session was never replaced. Account-specific queries are cleared after each switch so + * prior-account results cannot remain on screen. */ export function useAccountSwitcher() { const { session, viewingAccountId, setViewingAccount } = useAuth(); - // The master account's own token, which the pool requires to issue a subaccount - // session. It comes from the account profile, not the browser session (which only - // tracks the account id and expiry). - const { data: profile } = useAccountProfile(); const queryClient = useQueryClient(); + // Always read the owner's profile using the owner's cookie, even after a refresh + // inside a subaccount. This keeps direct subaccount-to-subaccount switching working. + const { data: ownerProfile } = useQuery({ + queryKey: ['account', 'profile', session?.accountId ?? null], + queryFn: ({ signal }) => getUser().checkAuth({ signal, accountId: session?.accountId }), + enabled: !!session, + staleTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + retry: false, + }); const [location, navigate] = useLocation(); const [switching, setSwitching] = useState(false); const [error, setError] = useState(null); @@ -44,22 +47,12 @@ export function useAccountSwitcher() { if (isSubaccountRestrictedRoute(location)) navigate('/home'); }, [location, navigate]); - // Remember the master account's token while it is the one on screen. Switching - // re-reads the profile as the subaccount, so without this the next switch would - // send a subaccount's token where the pool expects the owner's. - const ownerTokenRef = useRef(null); - useEffect(() => { - if (viewingAccountId === null && profile?.token) { - ownerTokenRef.current = profile.token; - } - }, [viewingAccountId, profile?.token]); - const switchToSubaccount = useCallback( async (subaccount: { id: string; token: string }) => { // Ignore a second pick while one is in flight, so two rapid clicks cannot leave // the client pointed at one account while the cache holds another's data. if (!session || switching) return; - const ownerToken = ownerTokenRef.current; + const ownerToken = ownerProfile?.token; if (!ownerToken) { setError("Couldn't open that subaccount"); return; @@ -67,9 +60,18 @@ export function useAccountSwitcher() { setSwitching(true); setError(null); try { - await getUser().logSubaccount(ownerToken, subaccount.token); - setViewingAccount(subaccount.id); - queryClient.removeQueries({ predicate: (q) => !isSubaccountListKey(q.queryKey) }); + const account = await getUser().logSubaccount(ownerToken, subaccount.token, { + accountId: session.accountId, + }); + const viewingAccount = viewingAccountFromAuth(account); + // log_subaccount is the authentication source of truth. Its AuthResponse.id + // names the cookie that normal account routes must select; the list row id is + // only the requested target and is deliberately not used for request scope. + queryClient.removeQueries({ + predicate: (query) => shouldClearOnAccountSwitch(query.queryKey, session.accountId), + }); + queryClient.setQueryData(['account', 'profile', viewingAccount.accountId], account); + setViewingAccount(viewingAccount); // Only ever narrows access, so this is the direction that can strand the miner // on a page the subaccount is not allowed to open. leaveRestrictedRoute(); @@ -81,13 +83,16 @@ export function useAccountSwitcher() { setSwitching(false); } }, - [session, switching, setViewingAccount, queryClient, leaveRestrictedRoute], + [session, switching, ownerProfile?.token, setViewingAccount, queryClient, leaveRestrictedRoute], ); const switchToMain = useCallback(() => { + if (!session) return; + queryClient.removeQueries({ + predicate: (query) => shouldClearOnAccountSwitch(query.queryKey, session.accountId), + }); setViewingAccount(null); - queryClient.removeQueries({ predicate: (q) => !isSubaccountListKey(q.queryKey) }); - }, [setViewingAccount, queryClient]); + }, [session, setViewingAccount, queryClient]); return { viewingAccountId, switching, error, switchToSubaccount, switchToMain }; } diff --git a/src/hooks/useActiveAccountId.ts b/src/hooks/useActiveAccountId.ts new file mode 100644 index 00000000..ffd18266 --- /dev/null +++ b/src/hooks/useActiveAccountId.ts @@ -0,0 +1,7 @@ +import { useAuth } from '@/auth'; + +/** The account whose data the dashboard is currently displaying. */ +export function useActiveAccountId(): string | null { + const { session, viewingAccountId } = useAuth(); + return viewingAccountId ?? session?.accountId ?? null; +} diff --git a/src/hooks/useGeneratedBtc.ts b/src/hooks/useGeneratedBtc.ts index e957ef65..68ed5220 100644 --- a/src/hooks/useGeneratedBtc.ts +++ b/src/hooks/useGeneratedBtc.ts @@ -6,9 +6,9 @@ import { dedupeGeneratedBtc, sortGeneratedByDateDesc } from '@/lib/generatedBtcT import { MAIN_ACCOUNT_LABEL } from '@/lib/payoutsTable'; import { useSubaccountList } from '@/hooks/useSubaccounts'; import { subaccountName } from '@/lib/subaccountsTable'; +import { useActiveAccountId } from './useActiveAccountId'; -// Cloud data refreshes every 5 minutes (spec cadence); the client already retries -// transient failures, so the query doesn't retry on top of it. +// Daily data is checked every five minutes const CLOUD_POLL_MS = 5 * 60 * 1000; /** @@ -19,9 +19,11 @@ const CLOUD_POLL_MS = 5 * 60 * 1000; */ export function useGeneratedBtc(enabled = true) { const { session } = useAuth(); + const accountId = useActiveAccountId(); return useQuery({ - queryKey: ['account', 'generated-btc'], - queryFn: ({ signal }): Promise => getUser().getGeneratedBtc({ signal }), + queryKey: ['account', 'generated-btc', accountId], + queryFn: ({ signal }): Promise => + getUser().getGeneratedBtc({ signal, accountId: accountId ?? undefined }), enabled: !!session && enabled, refetchInterval: CLOUD_POLL_MS, staleTime: CLOUD_POLL_MS, @@ -48,15 +50,23 @@ export function useGeneratedBtc(enabled = true) { */ export function useAggregatedGeneratedBtc(enabled = true) { const { session } = useAuth(); + const ownerAccountId = session?.accountId ?? null; const { data: subs } = useSubaccountList(); return useQuery({ - queryKey: ['account', 'generated-btc', 'aggregated'], + queryKey: ['account', 'generated-btc', 'aggregated', ownerAccountId], queryFn: async ({ signal }): Promise => { const client = getUser(); const owners = subs ?? []; const [mainRows, subResults] = await Promise.all([ - client.getGeneratedBtc({ signal }), - Promise.all(owners.map((s) => client.getSubaccountGeneratedBtc(s.id, s.token, { signal }))), + client.getGeneratedBtc({ signal, accountId: ownerAccountId ?? undefined }), + Promise.all( + owners.map((s) => + client.getSubaccountGeneratedBtc(s.id, s.token, { + signal, + accountId: ownerAccountId ?? undefined, + }), + ), + ), ]); const tagged: GeneratedBtcEntry[] = [ ...mainRows.map((r) => ({ ...r, account: MAIN_ACCOUNT_LABEL })), diff --git a/src/hooks/usePayouts.ts b/src/hooks/usePayouts.ts index fec3cc0f..4dcb0c01 100644 --- a/src/hooks/usePayouts.ts +++ b/src/hooks/usePayouts.ts @@ -13,6 +13,7 @@ import { import { useAccountProfile, userBitcoinAddresses } from '@/hooks/useAccountData'; import { useSubaccountList } from '@/hooks/useSubaccounts'; import { subaccountName } from '@/lib/subaccountsTable'; +import { useActiveAccountId } from './useActiveAccountId'; const PAYOUTS_POLL_MS = 15 * 60 * 1000; // Cap how far back and how many pages we scan per wallet so a high-volume wallet @@ -26,9 +27,13 @@ const MAX_PAGES = 25; * one row per tx, newest first. Shared by the single-account and aggregated hooks so * both scan the pool wallets the same way; only which addresses count differs. */ -async function fetchPayouts(matchAddrs: Set, signal: AbortSignal | undefined): Promise { +async function fetchPayouts( + matchAddrs: Set, + signal: AbortSignal | undefined, + accountId?: string, +): Promise { if (matchAddrs.size === 0) return []; // no receiving address set -> no payouts to show - const payout = await getUser().getPayoutAddresses({ signal }); + const payout = await getUser().getPayoutAddresses({ signal, accountId }); const wallets: { addr: string; mode: 'fpps' | 'pplns' }[] = []; if (payout.fpps_payout_address) wallets.push({ addr: payout.fpps_payout_address, mode: 'fpps' }); if (payout.pplns_payout_address && payout.pplns_payout_address !== payout.fpps_payout_address) { @@ -53,10 +58,12 @@ async function fetchPayouts(matchAddrs: Set, signal: AbortSignal | undef */ export function usePayouts() { const { session } = useAuth(); + const accountId = useActiveAccountId(); const { data: profile } = useAccountProfile(); return useQuery({ - queryKey: ['account', 'payouts'], - queryFn: ({ signal }): Promise => fetchPayouts(userBitcoinAddresses(profile), signal), + queryKey: ['account', 'payouts', accountId], + queryFn: ({ signal }): Promise => + fetchPayouts(userBitcoinAddresses(profile), signal, accountId ?? undefined), enabled: !!session && !!profile, refetchInterval: PAYOUTS_POLL_MS, staleTime: PAYOUTS_POLL_MS, @@ -75,10 +82,11 @@ export function usePayouts() { */ export function useAggregatedPayouts(enabled = true) { const { session } = useAuth(); + const ownerAccountId = session?.accountId ?? null; const { data: profile } = useAccountProfile(); const { data: subs } = useSubaccountList(); return useQuery({ - queryKey: ['account', 'payouts', 'aggregated'], + queryKey: ['account', 'payouts', 'aggregated', ownerAccountId], queryFn: async ({ signal }): Promise => { const owners: PayoutAccount[] = [ { name: MAIN_ACCOUNT_LABEL, addresses: userBitcoinAddresses(profile) }, @@ -89,7 +97,7 @@ export function useAggregatedPayouts(enabled = true) { ]; const union = new Set(); for (const owner of owners) for (const addr of owner.addresses) union.add(addr); - const rows = await fetchPayouts(union, signal); + const rows = await fetchPayouts(union, signal, ownerAccountId ?? undefined); return rows.map((row) => ({ ...row, account: accountForAddress(row.toAddress, owners) ?? undefined })); }, enabled: enabled && !!session && !!profile && subs !== undefined, diff --git a/src/hooks/useSubaccounts.ts b/src/hooks/useSubaccounts.ts index 17097bee..9831ad1d 100644 --- a/src/hooks/useSubaccounts.ts +++ b/src/hooks/useSubaccounts.ts @@ -3,9 +3,9 @@ import { getUser } from '@/api'; import { useAuth } from '@/auth'; import type { CreateSubaccountInput } from '@/api/types'; import { enrichSubaccount, type EnrichedSubaccount } from '@/lib/subaccountsTable'; +import { useActiveAccountId } from './useActiveAccountId'; -// Cloud data refreshes every 5 minutes (spec cadence); the client already retries -// transient failures, so the queries don't retry on top of it. +// The UI checks every five minutes const CLOUD_POLL_MS = 5 * 60 * 1000; /** @@ -17,18 +17,20 @@ const CLOUD_POLL_MS = 5 * 60 * 1000; */ export function useSubaccounts(enabled = true) { const { session } = useAuth(); + const ownerAccountId = session?.accountId ?? null; return useQuery({ - queryKey: ['account', 'subaccounts'], + queryKey: ['account', 'subaccounts', ownerAccountId], queryFn: async ({ signal }): Promise => { const client = getUser(); - const list = await client.getSubaccounts({ signal }); + const requestOptions = { signal, accountId: ownerAccountId ?? undefined }; + const list = await client.getSubaccounts(requestOptions); const now = Date.now(); return Promise.all( list.map(async (row) => { const token = row.token ?? ''; const [summary, workersRes] = await Promise.all([ - client.getSubaccountSummary(row.id, token, { signal }), - client.getSubaccountWorkers(row.id, token, { signal }), + client.getSubaccountSummary(row.id, token, requestOptions), + client.getSubaccountWorkers(row.id, token, requestOptions), ]); return enrichSubaccount(row, summary, workersRes.workers, now); }), @@ -50,9 +52,11 @@ export function useSubaccounts(enabled = true) { */ export function useSubaccountList() { const { session } = useAuth(); + const ownerAccountId = session?.accountId ?? null; return useQuery({ - queryKey: ['account', 'subaccounts', 'list'], - queryFn: ({ signal }) => getUser().getSubaccounts({ signal }), + queryKey: ['account', 'subaccounts', 'list', ownerAccountId], + queryFn: ({ signal }) => + getUser().getSubaccounts({ signal, accountId: ownerAccountId ?? undefined }), enabled: !!session, staleTime: CLOUD_POLL_MS, refetchOnWindowFocus: false, @@ -77,9 +81,10 @@ export function useHasSubaccounts() { */ export function usePermissions() { const { session } = useAuth(); + const accountId = useActiveAccountId(); return useQuery({ - queryKey: ['account', 'permissions'], - queryFn: ({ signal }) => getUser().getPermissions({ signal }), + queryKey: ['account', 'permissions', accountId], + queryFn: ({ signal }) => getUser().getPermissions({ signal, accountId: accountId ?? undefined }), enabled: !!session, staleTime: Infinity, refetchOnWindowFocus: false, diff --git a/src/hooks/useWatcherLinks.ts b/src/hooks/useWatcherLinks.ts index 3c625cb0..936efbc6 100644 --- a/src/hooks/useWatcherLinks.ts +++ b/src/hooks/useWatcherLinks.ts @@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { getUser } from '@/api'; import { useAuth } from '@/auth'; import type { CreateWatcherLinkInput } from '@/api/types'; +import { useActiveAccountId } from './useActiveAccountId'; // Watcher links change only when the user creates or revokes one, so they are not // polled; the mutations invalidate the list instead. @@ -10,9 +11,10 @@ const WATCHER_STALE_MS = 60 * 1000; /** The account's watcher links (GET /api/api-tokens), newest-first ordering left to the page. */ export function useWatcherLinks() { const { session } = useAuth(); + const accountId = useActiveAccountId(); return useQuery({ - queryKey: ['account', 'watcher-links'], - queryFn: ({ signal }) => getUser().getWatcherLinks({ signal }), + queryKey: ['account', 'watcher-links', accountId], + queryFn: ({ signal }) => getUser().getWatcherLinks({ signal, accountId: accountId ?? undefined }), enabled: !!session, staleTime: WATCHER_STALE_MS, refetchOnWindowFocus: false, diff --git a/src/lib/poolConnection.ts b/src/lib/poolConnection.ts index ba638c13..03ebca14 100644 --- a/src/lib/poolConnection.ts +++ b/src/lib/poolConnection.ts @@ -16,9 +16,9 @@ export const APP_ENV: AppEnv = ((): AppEnv => { * The pool URL to connect to, which varies by environment. */ const POOL_URL_BY_ENV: Record = { - local: 'stratum+tcp://127.0.0.1:32767', - staging: 'stratum+tcp://staging-pool-one.dmnd.work:3456', - production: 'stratum+tcp://proxy.dmnd.work:3456', + local: 'stratum+tcp://127.0.0.1:32768', + staging: 'stratum+tcp://staging-pool-one.dmnd.work:32768', + production: 'stratum+tcp://proxy-mining.dmnd.work:32768', }; /** Shared by the home connect-workers card and the account setup connect step. */ diff --git a/src/pages/account-setup/AccountSetup.tsx b/src/pages/account-setup/AccountSetup.tsx index 5ca7006c..50987b96 100644 --- a/src/pages/account-setup/AccountSetup.tsx +++ b/src/pages/account-setup/AccountSetup.tsx @@ -18,13 +18,10 @@ import type { DmndSession } from '@/api/types'; import { getBitcoinAddressError } from '@/lib/utils'; import { POOL_URL, POOL_USERNAME_HINT } from '@/lib/poolConnection'; import { CredentialRow } from '@/components/home/CredentialRow'; +import { activeBitcoinAddress } from '@/hooks/useAccountData'; -/** A payout address counts as set whether the API returns an array or a map. */ function hasBitcoinAddress(account: DmndSession): boolean { - const addrs = account.bitcoin_addresses; - if (Array.isArray(addrs)) return addrs.length > 0; - if (addrs && typeof addrs === 'object') return Object.keys(addrs).length > 0; - return false; + return activeBitcoinAddress(account) !== null; } /** A non-null `two_factor_secret` means 2FA is provisioned but not yet activated. */ diff --git a/src/pages/auth/SignIn.tsx b/src/pages/auth/SignIn.tsx index 76d025da..84185040 100644 --- a/src/pages/auth/SignIn.tsx +++ b/src/pages/auth/SignIn.tsx @@ -38,7 +38,15 @@ export function SignIn() { try { const account = await getUser().login(values.email, values.password); toast({ type: 'success', message: 'Sign in successful' }); - signIn(createSession({ accountId: String(account.id), email: account.email })); + signIn( + createSession({ + accountId: String(account.id), + email: account.email, + company_name: account.company_name, + company_primary_location: account.company_primary_location, + kyb_status: account.kyb_status, + }), + ); } catch (e) { toast({ type: 'error', message: authErrorMessage(e, 'Incorrect email or password.') }); } diff --git a/src/pages/dashboard/DashboardHome.tsx b/src/pages/dashboard/DashboardHome.tsx index ff8a834f..f919e29f 100644 --- a/src/pages/dashboard/DashboardHome.tsx +++ b/src/pages/dashboard/DashboardHome.tsx @@ -2,6 +2,7 @@ import { useRef, useState, type ReactNode } from 'react'; import { GripVertical } from 'lucide-react'; import { LiTuning4, LiRouting2 } from 'solar-icon-react/li'; import { LiveHashrateCard } from '@/components/home/LiveHashrateCard'; +import { KybNotice } from '@/components/home/KybNotice'; import { ConnectWorkersCard } from '@/components/home/ConnectWorkersCard'; import { WorkerStatCards } from '@/components/home/WorkerStatCards'; import { MiningPerformanceChart } from '@/components/home/MiningPerformanceChart'; @@ -261,6 +262,8 @@ export function DashboardHome() {
+ + {/* The design separates the header from the body by 16px and the body rows by 8px. */}
{rendered}