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
9 changes: 5 additions & 4 deletions ui-react/apps/console/src/hooks/useAdminDevices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import { paginatedQueryFn, type PaginatedResult } from "../api/pagination";
import { useAuthStore } from "../stores/authStore";
import { isSdkError } from "../api/errors";
import { toBase64Json } from "@/utils/encoding";

export type NormalizedDevice = Omit<Device, "tags"> & { tags: string[] };

Expand All @@ -21,9 +22,9 @@ function normalizeDevice(device: Device): NormalizedDevice {
...device,
tags: Array.isArray(device.tags)
? device.tags.map((t) => {
if (typeof t === "object" && t !== null && "name" in t) return t.name;
return String(t);
})
if (typeof t === "object" && t !== null && "name" in t) return t.name;
return String(t);
})
: [],
};
}
Expand All @@ -35,7 +36,7 @@ function buildNameFilter(search: string): string {
params: { name: "name", operator: "contains", value: search },
},
];
return btoa(JSON.stringify(filter));
return toBase64Json(filter);
}

interface UseAdminDevicesParams {
Expand Down
3 changes: 2 additions & 1 deletion ui-react/apps/console/src/hooks/useAdminNamespaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import { paginatedQueryFn, type PaginatedResult } from "../api/pagination";
import { useAuthStore } from "../stores/authStore";
import { isSdkError } from "../api/errors";
import { toBase64Json } from "@/utils/encoding";

function buildNameFilter(search: string): string {
const filter = [
Expand All @@ -19,7 +20,7 @@ function buildNameFilter(search: string): string {
params: { name: "name", operator: "contains", value: search },
},
];
return btoa(JSON.stringify(filter));
return toBase64Json(filter);
}

interface UseAdminNamespacesParams {
Expand Down
3 changes: 2 additions & 1 deletion ui-react/apps/console/src/hooks/useAdminUsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import { paginatedQueryFn, type PaginatedResult } from "../api/pagination";
import { useAuthStore } from "../stores/authStore";
import { isSdkError } from "../api/errors";
import { toBase64Json } from "@/utils/encoding";

function buildUsernameFilter(search: string): string {
const filter = [
Expand All @@ -19,7 +20,7 @@ function buildUsernameFilter(search: string): string {
params: { name: "username", operator: "contains", value: search },
},
];
return btoa(JSON.stringify(filter));
return toBase64Json(filter);
}

interface UseAdminUsersParams {
Expand Down
3 changes: 2 additions & 1 deletion ui-react/apps/console/src/hooks/useContainers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
import type { Device, DeviceStatus } from "../client";
import { getContainersQueryKey } from "../client/@tanstack/react-query.gen";
import { paginatedQueryFn, type PaginatedResult } from "../api/pagination";
import { toBase64Json } from "@/utils/encoding";

export type NormalizedContainer = Omit<Device, "tags"> & { tags: string[] };

Expand All @@ -24,7 +25,7 @@ function buildFilter(search: string, tags: string[]): string {
params: { name: "tags.name", operator: "contains", value: tags },
});
}
return btoa(JSON.stringify(filters));
return toBase64Json(filters);
}

export function normalizeContainer(container: Device): NormalizedContainer {
Expand Down
3 changes: 2 additions & 1 deletion ui-react/apps/console/src/hooks/useDevices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { getDevicesQueryKey } from "../client/@tanstack/react-query.gen";
import { paginatedQueryFn, type PaginatedResult } from "../api/pagination";
import type { Device as GeneratedDevice } from "../client";
import { toBase64Json } from "@/utils/encoding";

export type NormalizedDevice = Omit<GeneratedDevice, "tags"> & {
tags: string[];
Expand Down Expand Up @@ -35,7 +36,7 @@ export function buildFilter(search: string, tags: string[]): string {
params: { name: "tags.name", operator: "contains", value: tags },
});
}
return btoa(JSON.stringify(filters));
return toBase64Json(filters);
}

export function normalizeDevice(device: GeneratedDevice): NormalizedDevice {
Expand Down
3 changes: 2 additions & 1 deletion ui-react/apps/console/src/hooks/usePublicKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from "../client";
import { getPublicKeysQueryKey } from "../client/@tanstack/react-query.gen";
import { paginatedQueryFn, type PaginatedResult } from "../api/pagination";
import { toBase64Json } from "@/utils/encoding";

export function buildPublicKeyFilter(search: string): string {
const filters = [
Expand All @@ -21,7 +22,7 @@ export function buildPublicKeyFilter(search: string): string {
params: { name: "fingerprint", operator: "contains", value: search },
},
];
return btoa(JSON.stringify(filters));
return toBase64Json(filters);
}

interface UsePublicKeysParams {
Expand Down
3 changes: 2 additions & 1 deletion ui-react/apps/console/src/hooks/useWebEndpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from "../client";
import { listWebEndpointsQueryKey } from "../client/@tanstack/react-query.gen";
import { paginatedQueryFn, type PaginatedResult } from "../api/pagination";
import { toBase64Json } from "@/utils/encoding";

interface UseWebEndpointsParams {
page?: number;
Expand All @@ -24,7 +25,7 @@ function encodeAddressFilter(value: string): string {
params: { name: "address", operator: "contains", value },
},
];
return btoa(JSON.stringify(clauses));
return toBase64Json(clauses);
}

export function useWebEndpoints({
Expand Down
46 changes: 46 additions & 0 deletions ui-react/apps/console/src/utils/__tests__/encoding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, it, expect } from "vitest";
import { toBase64Json } from "@/utils/encoding";

describe("toBase64Json", () => {
it("matches btoa(JSON.stringify(...)) for ASCII payloads", () => {
// Backward-compat guard: the BE decoder only sees the bytes, so an ASCII
// payload must produce the exact same string the old `btoa` path produced.
const value = [
{
type: "property",
params: { name: "name", operator: "contains", value: "qa-edge" },
},
];
expect(toBase64Json(value)).toBe(btoa(JSON.stringify(value)));
});

it("does not throw on non-Latin-1 characters", () => {
const value = [
{
type: "property",
params: {
name: "tags.name",
operator: "contains",
value: "日本語タグ",
},
},
];
expect(() => toBase64Json(value)).not.toThrow();
});

it("round-trips Unicode through base64 → UTF-8 → JSON", () => {
const value = { tag: "日本語タグ", emoji: "🚀", arabic: "سلام" };
const encoded = toBase64Json(value);
const decoded = JSON.parse(
Buffer.from(encoded, "base64").toString("utf-8"),
) as typeof value;
expect(decoded).toEqual(value);
});

it("produces stable output for the same input (used as a cache key)", () => {
const value = [
{ type: "property", params: { name: "x", operator: "eq", value: 1 } },
];
expect(toBase64Json(value)).toBe(toBase64Json(value));
});
});
8 changes: 8 additions & 0 deletions ui-react/apps/console/src/utils/encoding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Buffer } from "buffer";

// `btoa(JSON.stringify(value))` throws InvalidCharacterError for any character
// above U+00FF (e.g. tag names, hostnames, usernames in non-Latin scripts),
// so the request never leaves the browser. UTF-8-encode first, then base64.
export function toBase64Json(value: unknown): string {
return Buffer.from(JSON.stringify(value), "utf-8").toString("base64");
}
3 changes: 2 additions & 1 deletion ui-react/apps/console/src/utils/invitations.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { MembershipInvitation } from "@/client";
import { toBase64Json } from "@/utils/encoding";

export type InvitationStatus = MembershipInvitation["status"];

Expand All @@ -13,7 +14,7 @@ export function invitationStatusFilter(status: InvitationStatus): string {
params: { name: "status", operator: "eq", value: status },
},
];
return btoa(JSON.stringify(filter));
return toBase64Json(filter);
}

export function isInvitationExpired(expiresAt: string | null): boolean {
Expand Down
Loading