working on sensors and rules
This commit is contained in:
@@ -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(", ")}`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.`
|
||||
: ""
|
||||
}`,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user