working on sensors and rules

This commit is contained in:
2026-02-19 20:37:17 -06:00
parent a1b9b8d2b1
commit f9cfcf8f40
31 changed files with 1316 additions and 586 deletions

View File

@@ -32,12 +32,76 @@ interface ParamSchemaFormProps {
errors?: Record<string, string>;
disabled?: boolean;
className?: string;
/**
* When true, all inputs render as text fields that accept template expressions
* like {{ event.payload.field }}, {{ pack.config.key }}, {{ system.timestamp }}.
* Used in rule configuration where parameters may be dynamically resolved
* at enforcement time rather than set to literal values.
*/
allowTemplates?: boolean;
}
/**
* Check if a string value contains a template expression ({{ ... }})
*/
function isTemplateExpression(value: any): boolean {
return typeof value === "string" && /\{\{.*\}\}/.test(value);
}
/**
* Format a value for display in a text input.
* Non-string values (booleans, numbers, objects, arrays) are JSON-stringified
* so the user can edit them as text.
*/
function valueToString(value: any): string {
if (value === undefined || value === null) return "";
if (typeof value === "string") return value;
return JSON.stringify(value);
}
/**
* Attempt to parse a text input value back to the appropriate JS type.
* Template expressions are always kept as strings.
* Plain values are coerced to the schema type when possible.
*/
function parseTemplateValue(raw: string, type: string): any {
if (raw === "") return "";
// Template expressions stay as strings - resolved server-side
if (isTemplateExpression(raw)) return raw;
switch (type) {
case "boolean":
if (raw === "true") return true;
if (raw === "false") return false;
return raw; // keep as string if not a recognised literal
case "number":
if (!isNaN(Number(raw))) return parseFloat(raw);
return raw;
case "integer":
if (!isNaN(Number(raw)) && Number.isInteger(Number(raw)))
return parseInt(raw, 10);
return raw;
case "array":
case "object":
try {
return JSON.parse(raw);
} catch {
return raw;
}
default:
return raw;
}
}
/**
* Dynamic form component that renders inputs based on a parameter schema.
* Supports standard JSON Schema format with properties and required array.
* Supports string, number, integer, boolean, array, object, and enum types.
*
* When `allowTemplates` is enabled, every field renders as a text input that
* accepts Jinja2-style template expressions (e.g. {{ event.payload.x }}).
* This is essential for rule configuration, where parameter values may reference
* event payloads, pack configs, keys, or system variables.
*/
export default function ParamSchemaForm({
schema,
@@ -46,6 +110,7 @@ export default function ParamSchemaForm({
errors = {},
disabled = false,
className = "",
allowTemplates = false,
}: ParamSchemaFormProps) {
const [localErrors, setLocalErrors] = useState<Record<string, string>>({});
@@ -98,7 +163,71 @@ export default function ParamSchemaForm({
};
/**
* Render input field based on parameter type
* Get a placeholder hint for template-mode inputs
*/
const getTemplatePlaceholder = (key: string, param: any): string => {
const type = param?.type || "string";
switch (type) {
case "boolean":
return `true, false, or {{ event.payload.${key} }}`;
case "number":
case "integer":
return `${type} value or {{ event.payload.${key} }}`;
case "array":
return `["a","b"] or {{ event.payload.${key} }}`;
case "object":
return `{"k":"v"} or {{ event.payload.${key} }}`;
default:
if (param?.enum && param.enum.length > 0) {
const options = param.enum.slice(0, 3).join(", ");
const suffix = param.enum.length > 3 ? ", ..." : "";
return `${options}${suffix} or {{ event.payload.${key} }}`;
}
return param?.description || `{{ event.payload.${key} }}`;
}
};
/**
* Render a template-mode text input for any parameter type
*/
const renderTemplateInput = (key: string, param: any) => {
const type = param?.type || "string";
const rawValue = values[key] ?? param?.default ?? "";
const isDisabled = disabled;
const displayValue = valueToString(rawValue);
// Use a textarea for complex types (array/object) to give more room
if (type === "array" || type === "object") {
return (
<textarea
value={displayValue}
onChange={(e) =>
handleInputChange(key, parseTemplateValue(e.target.value, type))
}
disabled={isDisabled}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm disabled:bg-gray-100 disabled:cursor-not-allowed"
placeholder={getTemplatePlaceholder(key, param)}
/>
);
}
return (
<input
type="text"
value={displayValue}
onChange={(e) =>
handleInputChange(key, parseTemplateValue(e.target.value, type))
}
disabled={isDisabled}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
placeholder={getTemplatePlaceholder(key, param)}
/>
);
};
/**
* Render input field based on parameter type (standard mode)
*/
const renderInput = (key: string, param: any) => {
const type = param?.type || "string";
@@ -249,6 +378,38 @@ export default function ParamSchemaForm({
}
};
/**
* Render type hint badge and additional context for template-mode fields
*/
const renderTemplateHints = (_key: string, param: any) => {
const type = param?.type || "string";
const hints: string[] = [];
if (type === "boolean") {
hints.push("Accepts: true, false, or a template expression");
} else if (type === "number" || type === "integer") {
const parts = [`Accepts: ${type} value`];
if (param?.minimum !== undefined) parts.push(`min: ${param.minimum}`);
if (param?.maximum !== undefined) parts.push(`max: ${param.maximum}`);
hints.push(parts.join(", ") + ", or a template expression");
} else if (param?.enum && param.enum.length > 0) {
hints.push(`Options: ${param.enum.join(", ")}`);
hints.push("Also accepts a template expression");
}
if (hints.length === 0) return null;
return (
<div className="mt-1 space-y-0.5">
{hints.map((hint, i) => (
<p key={i} className="text-xs text-gray-500">
{hint}
</p>
))}
</div>
);
};
const paramEntries = Object.entries(properties);
if (paramEntries.length === 0) {
@@ -261,6 +422,26 @@ export default function ParamSchemaForm({
return (
<div className={`space-y-4 ${className}`}>
{allowTemplates && (
<div className="px-3 py-2 bg-amber-50 border border-amber-200 rounded-lg">
<p className="text-xs text-amber-800">
<span className="font-semibold">Template expressions</span> are
supported. Use{" "}
<code className="px-1 py-0.5 bg-amber-100 rounded text-[11px]">
{"{{ event.payload.field }}"}
</code>
,{" "}
<code className="px-1 py-0.5 bg-amber-100 rounded text-[11px]">
{"{{ pack.config.key }}"}
</code>
, or{" "}
<code className="px-1 py-0.5 bg-amber-100 rounded text-[11px]">
{"{{ system.timestamp }}"}
</code>{" "}
to dynamically resolve values when the rule fires.
</p>
</div>
)}
{paramEntries.map(([key, param]) => (
<div key={key}>
<label className="block mb-2">
@@ -283,8 +464,19 @@ export default function ParamSchemaForm({
{param?.description && param?.type !== "boolean" && (
<p className="text-xs text-gray-600 mb-2">{param.description}</p>
)}
{/* For boolean in template mode, show description since there's no checkbox label */}
{param?.description &&
param?.type === "boolean" &&
allowTemplates && (
<p className="text-xs text-gray-600 mb-2">
{param.description}
</p>
)}
</label>
{renderInput(key, param)}
{allowTemplates
? renderTemplateInput(key, param)
: renderInput(key, param)}
{allowTemplates && renderTemplateHints(key, param)}
{allErrors[key] && (
<p className="text-xs text-red-600 mt-1">{allErrors[key]}</p>
)}
@@ -302,12 +494,16 @@ export default function ParamSchemaForm({
}
/**
* Utility function to validate parameter values against a schema
* Supports standard JSON Schema format
* Utility function to validate parameter values against a schema.
* Supports standard JSON Schema format.
*
* When `allowTemplates` is true, template expressions ({{ ... }}) are
* accepted for any field type and skip type-specific validation.
*/
export function validateParamSchema(
schema: ParamSchema,
values: Record<string, any>,
allowTemplates: boolean = false,
): Record<string, string> {
const errors: Record<string, string> = {};
const properties = schema.properties || {};
@@ -333,12 +529,23 @@ export function validateParamSchema(
return;
}
// Template expressions are always valid in template mode
if (allowTemplates && isTemplateExpression(value)) {
return;
}
const type = param?.type || "string";
switch (type) {
case "number":
case "integer":
if (typeof value !== "number" && isNaN(Number(value))) {
if (allowTemplates) {
// In template mode, non-numeric strings that aren't templates
// are still allowed — the user might be mid-edit or using a
// non-standard expression format. Only warn on submission.
break;
}
errors[key] = `Must be a valid ${type}`;
} else {
const numValue = typeof value === "number" ? value : Number(value);
@@ -351,8 +558,23 @@ export function validateParamSchema(
}
break;
case "boolean":
// In template mode, string values like "true"/"false" are fine
if (
allowTemplates &&
typeof value === "string" &&
(value === "true" || value === "false")
) {
break;
}
break;
case "array":
if (!Array.isArray(value)) {
if (allowTemplates && typeof value === "string") {
// In template mode, strings are acceptable (could be template or JSON)
break;
}
try {
JSON.parse(value);
} catch {
@@ -363,6 +585,9 @@ export function validateParamSchema(
case "object":
if (typeof value !== "object" || Array.isArray(value)) {
if (allowTemplates && typeof value === "string") {
break;
}
try {
const parsed = JSON.parse(value);
if (typeof parsed !== "object" || Array.isArray(parsed)) {
@@ -392,8 +617,9 @@ export function validateParamSchema(
break;
}
// Enum validation
if (param?.enum && param.enum.length > 0) {
// Enum validation — skip in template mode (value may be a template expression
// or a string that will be resolved at runtime)
if (!allowTemplates && param?.enum && param.enum.length > 0) {
if (!param.enum.includes(value)) {
errors[key] = `Must be one of: ${param.enum.join(", ")}`;
}

View File

@@ -144,17 +144,19 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
}
}
// Validate trigger parameters
// Validate trigger parameters (allow templates in rule context)
const triggerErrors = validateParamSchema(
triggerParamSchema,
triggerParameters,
true,
);
setTriggerParamErrors(triggerErrors);
// Validate action parameters
// Validate action parameters (allow templates in rule context)
const actionErrors = validateParamSchema(
actionParamSchema,
actionParameters,
true,
);
setActionParamErrors(actionErrors);
@@ -428,6 +430,7 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
values={triggerParameters}
onChange={setTriggerParameters}
errors={triggerParamErrors}
allowTemplates
/>
</div>
)}
@@ -517,6 +520,7 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
values={actionParameters}
onChange={setActionParameters}
errors={actionParamErrors}
allowTemplates
/>
</div>
)}

View File

@@ -1,51 +1,5 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
// Temporary types until API client is regenerated
interface PackTestResult {
pack_ref: string;
pack_version: string;
execution_time: string;
status: string;
total_tests: number;
passed: number;
failed: number;
skipped: number;
pass_rate: number;
duration_ms: number;
test_suites: any[];
}
interface PackTestExecution {
id: number;
pack_id: number;
pack_version: string;
execution_time: string;
trigger_reason: string;
total_tests: number;
passed: number;
failed: number;
skipped: number;
pass_rate: number;
duration_ms: number;
result: PackTestResult;
created: string;
}
interface PackTestHistoryResponse {
data: {
items: PackTestExecution[];
meta: {
page: number;
page_size: number;
total_items: number;
total_pages: number;
};
};
}
interface PackTestLatestResponse {
data: PackTestExecution | null;
}
import { PacksService, ApiError } from "@/api";
// Fetch test history for a pack
export function usePackTestHistory(
@@ -54,27 +8,12 @@ export function usePackTestHistory(
) {
return useQuery({
queryKey: ["pack-tests", packRef, params],
queryFn: async (): Promise<PackTestHistoryResponse> => {
const queryParams = new URLSearchParams();
if (params?.page) queryParams.append("page", params.page.toString());
if (params?.pageSize)
queryParams.append("page_size", params.pageSize.toString());
const token = localStorage.getItem("access_token");
const response = await fetch(
`http://localhost:8080/api/v1/packs/${packRef}/tests?${queryParams}`,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
if (!response.ok) {
throw new Error(`Failed to fetch test history: ${response.statusText}`);
}
return response.json();
queryFn: async () => {
return PacksService.getPackTestHistory({
ref: packRef,
page: params?.page,
pageSize: params?.pageSize,
});
},
enabled: !!packRef,
staleTime: 30000, // 30 seconds
@@ -85,25 +24,15 @@ export function usePackTestHistory(
export function usePackLatestTest(packRef: string) {
return useQuery({
queryKey: ["pack-tests", packRef, "latest"],
queryFn: async (): Promise<PackTestLatestResponse> => {
const token = localStorage.getItem("access_token");
const response = await fetch(
`http://localhost:8080/api/v1/packs/${packRef}/tests/latest`,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
if (!response.ok) {
if (response.status === 404) {
queryFn: async () => {
try {
return await PacksService.getPackLatestTest({ ref: packRef });
} catch (error) {
if (error instanceof ApiError && error.status === 404) {
return { data: null };
}
throw new Error(`Failed to fetch latest test: ${response.statusText}`);
throw error;
}
return response.json();
},
enabled: !!packRef,
staleTime: 30000,
@@ -115,27 +44,8 @@ export function useExecutePackTests() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (packRef: string): Promise<{ data: PackTestResult }> => {
const token = localStorage.getItem("access_token");
const response = await fetch(
`http://localhost:8080/api/v1/packs/${packRef}/test`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
},
);
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(
error.error || `Failed to execute tests: ${response.statusText}`,
);
}
return response.json();
mutationFn: async (packRef: string) => {
return PacksService.testPack({ ref: packRef });
},
onSuccess: (_, packRef) => {
// Invalidate test history and latest test queries
@@ -157,38 +67,14 @@ export function useRegisterPack() {
path: string;
force?: boolean;
skipTests?: boolean;
}): Promise<{
data: {
pack: any;
test_result: PackTestResult | null;
tests_skipped: boolean;
};
}> => {
const token = localStorage.getItem("access_token");
const response = await fetch(
"http://localhost:8080/api/v1/packs/register",
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
path,
force,
skip_tests: skipTests,
}),
}) => {
return PacksService.registerPack({
requestBody: {
path,
force,
skip_tests: skipTests,
},
);
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(
error.error || `Failed to register pack: ${response.statusText}`,
);
}
return response.json();
});
},
onSuccess: (data) => {
// Invalidate packs list and test queries
@@ -219,40 +105,16 @@ export function useInstallPack() {
force?: boolean;
skipTests?: boolean;
skipDeps?: boolean;
}): Promise<{
data: {
pack: any;
test_result: PackTestResult | null;
tests_skipped: boolean;
};
}> => {
const token = localStorage.getItem("access_token");
const response = await fetch(
"http://localhost:8080/api/v1/packs/install",
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
source,
ref_spec: refSpec,
force,
skip_tests: skipTests,
skip_deps: skipDeps,
}),
}) => {
return PacksService.installPack({
requestBody: {
source,
ref_spec: refSpec,
force,
skip_tests: skipTests,
skip_deps: skipDeps,
},
);
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(
error.error || `Failed to install pack: ${response.statusText}`,
);
}
return response.json();
});
},
onSuccess: (data) => {
// Invalidate packs list and test queries

View File

@@ -6,6 +6,30 @@ import axios, {
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "";
// A bare axios instance with NO interceptors, used exclusively for token refresh
// requests. This prevents infinite loops when the refresh endpoint returns 401.
const refreshClient = axios.create({
baseURL: API_BASE_URL || undefined,
timeout: 10000,
headers: { "Content-Type": "application/json" },
});
function getRefreshUrl(): string {
return API_BASE_URL ? `${API_BASE_URL}/auth/refresh` : "/auth/refresh";
}
// Clear auth state and redirect to the login page.
function clearSessionAndRedirect(): void {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
const currentPath = window.location.pathname;
if (currentPath !== "/login") {
sessionStorage.setItem("redirect_after_login", currentPath);
window.location.href = "/login";
}
}
// Create axios instance
export const apiClient: AxiosInstance = axios.create({
baseURL: API_BASE_URL,
@@ -37,7 +61,7 @@ apiClient.interceptors.response.use(
_retry?: boolean;
};
// Handle 401 Unauthorized - token expired or invalid
// Handle 401 Unauthorized token expired or invalid
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
@@ -48,11 +72,8 @@ apiClient.interceptors.response.use(
throw new Error("No refresh token available");
}
// Attempt token refresh
const refreshUrl = API_BASE_URL
? `${API_BASE_URL}/auth/refresh`
: "/auth/refresh";
const response = await axios.post(refreshUrl, {
// Use the bare refreshClient (no interceptors) to avoid infinite loops
const response = await refreshClient.post(getRefreshUrl(), {
refresh_token: refreshToken,
});
@@ -75,23 +96,13 @@ apiClient.interceptors.response.use(
console.error(
"Token refresh failed, clearing session and redirecting to login",
);
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
// Store the current path so we can redirect back after login
const currentPath = window.location.pathname;
if (currentPath !== "/login") {
sessionStorage.setItem("redirect_after_login", currentPath);
}
window.location.href = "/login";
clearSessionAndRedirect();
return Promise.reject(refreshError);
}
}
// Handle 403 Forbidden - valid token but insufficient permissions
if (error.response?.status === 403) {
// Enhance error message to distinguish from 401
const enhancedError = error as AxiosError & {
isAuthorizationError?: boolean;
};

View File

@@ -13,8 +13,39 @@ import axios from "axios";
* Strategy:
* Since the generated API client creates its own axios instances, we configure
* axios defaults globally and ensure the OpenAPI client uses our configured instance.
*
* IMPORTANT: All refresh calls use `refreshClient` — a bare axios instance with
* NO interceptors — to prevent infinite 401 retry loops when the refresh token
* itself is expired or invalid.
*/
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "";
// A bare axios instance with NO interceptors, used exclusively for token refresh
// requests. This prevents infinite loops when the refresh endpoint returns 401.
const refreshClient = axios.create({
baseURL: API_BASE_URL || undefined,
timeout: 10000,
headers: { "Content-Type": "application/json" },
});
function getRefreshUrl(): string {
return API_BASE_URL ? `${API_BASE_URL}/auth/refresh` : "/auth/refresh";
}
// Clear auth state and redirect to the login page.
// Safe to call multiple times — only the first redirect takes effect.
function clearSessionAndRedirect(): void {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
const currentPath = window.location.pathname;
if (currentPath !== "/login") {
sessionStorage.setItem("redirect_after_login", currentPath);
window.location.href = "/login";
}
}
// Helper to decode JWT and check if it's expired or about to expire
export function isTokenExpiringSoon(
token: string,
@@ -59,6 +90,39 @@ export function isTokenExpired(token: string): boolean {
}
}
// Attempt to refresh the access token using the refresh token.
// Returns true on success, false on failure.
// On failure, clears session and redirects to login.
async function attemptTokenRefresh(): Promise<boolean> {
const currentRefreshToken = localStorage.getItem("refresh_token");
if (!currentRefreshToken) {
console.warn("No refresh token available, redirecting to login");
clearSessionAndRedirect();
return false;
}
try {
const response = await refreshClient.post(getRefreshUrl(), {
refresh_token: currentRefreshToken,
});
const { access_token, refresh_token: newRefreshToken } = response.data.data;
localStorage.setItem("access_token", access_token);
if (newRefreshToken) {
localStorage.setItem("refresh_token", newRefreshToken);
}
return true;
} catch (error) {
console.error(
"Token refresh failed, clearing session and redirecting to login",
);
clearSessionAndRedirect();
return false;
}
}
// Helper to proactively refresh token if needed
export async function ensureValidToken(): Promise<void> {
const token = localStorage.getItem("access_token");
@@ -70,30 +134,7 @@ export async function ensureValidToken(): Promise<void> {
// Check if token is expiring soon (within 5 minutes)
if (isTokenExpiringSoon(token, 300)) {
try {
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "";
const refreshUrl = API_BASE_URL
? `${API_BASE_URL}/auth/refresh`
: "/auth/refresh";
// Use base axios to avoid circular refresh attempts
const response = await axios.post(refreshUrl, {
refresh_token: refreshToken,
});
const { access_token, refresh_token: newRefreshToken } =
response.data.data;
localStorage.setItem("access_token", access_token);
if (newRefreshToken) {
localStorage.setItem("refresh_token", newRefreshToken);
}
// Token proactively refreshed
} catch (error) {
console.error("Proactive token refresh failed:", error);
// Don't throw - let the interceptor handle it on the next request
}
await attemptTokenRefresh();
}
}
@@ -105,8 +146,6 @@ export function startTokenRefreshMonitor(): void {
return; // Already running
}
// Starting token refresh monitor
// Check token every 60 seconds
tokenCheckInterval = setInterval(async () => {
const token = localStorage.getItem("access_token");
@@ -121,7 +160,6 @@ export function startTokenRefreshMonitor(): void {
export function stopTokenRefreshMonitor(): void {
if (tokenCheckInterval) {
// Stopping token refresh monitor
clearInterval(tokenCheckInterval);
tokenCheckInterval = null;
}
@@ -130,7 +168,6 @@ export function stopTokenRefreshMonitor(): void {
// Configure axios defaults to apply to all instances
export function configureAxiosDefaults(): void {
// Set default base URL
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "";
if (API_BASE_URL) {
axios.defaults.baseURL = API_BASE_URL;
}
@@ -138,8 +175,7 @@ export function configureAxiosDefaults(): void {
// Set default headers
axios.defaults.headers.common["Content-Type"] = "application/json";
// Copy our interceptors to the default axios instance
// This ensures that even new axios instances inherit the behavior
// Request interceptor — attach JWT to outgoing requests
axios.interceptors.request.use(
(config) => {
const token = localStorage.getItem("access_token");
@@ -153,66 +189,31 @@ export function configureAxiosDefaults(): void {
},
);
// Response interceptor — handle 401 with a single refresh attempt
axios.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config as any;
// Handle 401 Unauthorized - token expired or invalid
// Handle 401 Unauthorized token expired or invalid
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const refreshToken = localStorage.getItem("refresh_token");
if (!refreshToken) {
console.warn("No refresh token available, redirecting to login");
throw new Error("No refresh token available");
}
// Access token expired, attempting refresh
const refreshUrl = API_BASE_URL
? `${API_BASE_URL}/auth/refresh`
: "/auth/refresh";
const response = await axios.post(refreshUrl, {
refresh_token: refreshToken,
});
const { access_token, refresh_token: newRefreshToken } =
response.data.data;
localStorage.setItem("access_token", access_token);
if (newRefreshToken) {
localStorage.setItem("refresh_token", newRefreshToken);
}
// Token refreshed successfully
const refreshed = await attemptTokenRefresh();
if (refreshed) {
// Retry original request with new token
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${access_token}`;
const newToken = localStorage.getItem("access_token");
if (originalRequest.headers && newToken) {
originalRequest.headers.Authorization = `Bearer ${newToken}`;
}
return axios(originalRequest);
} catch (refreshError) {
console.error(
"Token refresh failed, clearing session and redirecting to login",
);
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
// Store the current path for redirect after login
const currentPath = window.location.pathname;
if (currentPath !== "/login") {
sessionStorage.setItem("redirect_after_login", currentPath);
}
window.location.href = "/login";
return Promise.reject(refreshError);
}
// attemptTokenRefresh already cleared session and redirected
return Promise.reject(error);
}
// Handle 403 Forbidden - valid token but insufficient permissions
// Handle 403 Forbidden valid token but insufficient permissions
if (error.response?.status === 403) {
const enhancedError = error as any;
enhancedError.isAuthorizationError = true;
@@ -226,18 +227,9 @@ export function configureAxiosDefaults(): void {
return Promise.reject(error);
},
);
// Axios defaults configured with interceptors
}
// Initialize the API wrapper
export function initializeApiWrapper(): void {
// Initializing API wrapper
// Configure axios defaults so all instances get the interceptors
configureAxiosDefaults();
// The generated API client will now inherit these interceptors
// API wrapper initialized
}

View File

@@ -53,7 +53,7 @@ export default function PackInstallPage() {
result.data.tests_skipped
? "Tests were skipped."
: result.data.test_result
? `Tests ${result.data.test_result.status}: ${result.data.test_result.passed}/${result.data.test_result.total_tests} passed.`
? `Tests ${result.data.test_result.status}: ${result.data.test_result.passed}/${result.data.test_result.totalTests} passed.`
: ""
}`,
);
@@ -138,8 +138,7 @@ export default function PackInstallPage() {
any git server
</li>
<li>
<strong>Archive URL</strong> - Download from .zip or .tar.gz
URL
<strong>Archive URL</strong> - Download from .zip or .tar.gz URL
</li>
<li>
<strong>Pack Registry</strong> - Install from configured
@@ -192,8 +191,7 @@ export default function PackInstallPage() {
{/* Source Type Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Installation Source Type{" "}
<span className="text-red-500">*</span>
Installation Source Type <span className="text-red-500">*</span>
</label>
<div className="grid grid-cols-3 gap-3">
<button

View File

@@ -39,7 +39,7 @@ export default function PackRegisterPage() {
result.data.tests_skipped
? "Tests were skipped."
: result.data.test_result
? `Tests ${result.data.test_result.status}: ${result.data.test_result.passed}/${result.data.test_result.total_tests} passed.`
? `Tests ${result.data.test_result.status}: ${result.data.test_result.passed}/${result.data.test_result.totalTests} passed.`
: ""
}`,
);