Backend (Rust): - Create proto/auth.proto with Login and RefreshToken RPCs - Implement AuthGrpcService in services/api with JWT issuance - Register as unauthenticated service in main.rs (pre-auth) - Update JWT issuer from foxhunt-api-gateway to foxhunt-api Dashboard (TypeScript): - Install @connectrpc/connect-web + @bufbuild/protobuf - Generate TypeScript proto clients via buf (src/gen/) - Create grpc.ts transport with JWT interceptor - Rewrite all useApi hooks to typed gRPC calls - Replace WebSocket with useGrpcStream server-streaming hooks - Migrate all 6 pages + 6 components to grpc-web - Delete api.ts, websocket.ts, useWebSocket.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
81 lines
2.8 KiB
TypeScript
81 lines
2.8 KiB
TypeScript
import { createGrpcWebTransport } from "@connectrpc/connect-web";
|
|
import { createClient, type Interceptor } from "@connectrpc/connect";
|
|
import {
|
|
getToken,
|
|
isTokenExpired,
|
|
clearTokens,
|
|
SESSION_EXPIRED_KEY,
|
|
} from "./auth";
|
|
|
|
// Generated service definitions (protoc-gen-es v2 — services live in *_pb.ts)
|
|
import { TradingService } from "../gen/trading_pb";
|
|
import { RiskService } from "../gen/risk_pb";
|
|
import { MLService } from "../gen/ml_pb";
|
|
import { MLTrainingService } from "../gen/ml_training_pb";
|
|
import { ConfigService } from "../gen/config_pb";
|
|
import { MonitoringService } from "../gen/monitoring_pb";
|
|
import { BacktestingService } from "../gen/fxt_trading_pb";
|
|
import { AuthService } from "../gen/auth_pb";
|
|
|
|
const API_URL = import.meta.env.VITE_API_URL ?? "http://localhost:50051";
|
|
|
|
/** Margin in seconds before actual expiry to treat the token as expired */
|
|
const EXPIRY_MARGIN_SECONDS = 60;
|
|
|
|
/**
|
|
* Interceptor that attaches the JWT Authorization header to every request
|
|
* and handles session expiry / UNAUTHENTICATED responses.
|
|
*/
|
|
const authInterceptor: Interceptor = (next) => async (req) => {
|
|
const token = getToken();
|
|
if (token) {
|
|
if (isTokenExpired(token, EXPIRY_MARGIN_SECONDS)) {
|
|
sessionStorage.setItem(SESSION_EXPIRED_KEY, "1");
|
|
clearTokens();
|
|
window.location.href = "/login";
|
|
throw new Error("Session expired");
|
|
}
|
|
req.header.set("authorization", `Bearer ${token}`);
|
|
}
|
|
try {
|
|
return await next(req);
|
|
} catch (err: unknown) {
|
|
// gRPC status 16 = UNAUTHENTICATED
|
|
if (
|
|
err &&
|
|
typeof err === "object" &&
|
|
"code" in err &&
|
|
(err as { code: number }).code === 16
|
|
) {
|
|
sessionStorage.setItem(SESSION_EXPIRED_KEY, "1");
|
|
clearTokens();
|
|
window.location.href = "/login";
|
|
}
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
/** Authenticated transport — used for all services except login */
|
|
const transport = createGrpcWebTransport({
|
|
baseUrl: API_URL,
|
|
interceptors: [authInterceptor],
|
|
});
|
|
|
|
/** Public transport — no auth header, used for login/refresh */
|
|
const publicTransport = createGrpcWebTransport({
|
|
baseUrl: API_URL,
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Service clients
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const authClient = createClient(AuthService, publicTransport);
|
|
export const tradingClient = createClient(TradingService, transport);
|
|
export const riskClient = createClient(RiskService, transport);
|
|
export const mlClient = createClient(MLService, transport);
|
|
export const mlTrainingClient = createClient(MLTrainingService, transport);
|
|
export const configClient = createClient(ConfigService, transport);
|
|
export const monitoringClient = createClient(MonitoringService, transport);
|
|
export const backtestingClient = createClient(BacktestingService, transport);
|