Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 77 additions & 10 deletions src/api/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand All @@ -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 () => {
Expand Down Expand Up @@ -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<string, string>)['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 () => {
Expand All @@ -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<string, string>)['X-Account-ID'], 'master');
} finally {
setDmndAccountId(null);
}
});

test('getSubaccountWorkers follows pagination on the live per-subaccount endpoint', async () => {
Expand Down Expand Up @@ -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<string, string>)['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<string, string>)['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' }),
Expand Down Expand Up @@ -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<string, string>)['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;
Expand Down
5 changes: 4 additions & 1 deletion src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,10 @@ async function request<T>(
if (req.signal?.aborted) throw new DmndApiError(API_ERROR_MESSAGES.cancelled, 'network');

const headers: Record<string, string> = { '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}`, {
Expand Down
13 changes: 8 additions & 5 deletions src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, unknown> | string[];
bitcoin_addresses: Record<string, boolean>;
language?: string;
active?: boolean;
api_token?: string;
fpps_token?: string;
api_token?: string | null;
fpps_token?: string | null;
selling_hash_rate?: boolean;
}

Expand Down
74 changes: 64 additions & 10 deletions src/auth/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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<AuthContextValue | null>(null);
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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<AuthContextValue>(
Expand All @@ -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,
Expand Down
Loading
Loading