From 20463b2b0b4ea21b833b4bb51f4c69659c8862ba Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Tue, 20 Jan 2026 04:48:24 +0700 Subject: [PATCH 1/4] refactor: remove redundant tenant_id from portal redirect URL The tenant_id is already embedded in the JWT token's sub claim, so it's unnecessary in the URL query parameter. Co-Authored-By: Claude Opus 4.5 --- internal/apirouter/tenant_handlers.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/apirouter/tenant_handlers.go b/internal/apirouter/tenant_handlers.go index 9fc52983d..db225d4e5 100644 --- a/internal/apirouter/tenant_handlers.go +++ b/internal/apirouter/tenant_handlers.go @@ -209,7 +209,7 @@ func (h *TenantHandlers) RetrievePortal(c *gin.Context) { theme = "" } - portalURL := scheme + "://" + c.Request.Host + "?token=" + jwtToken + "&tenant_id=" + tenant.ID + portalURL := scheme + "://" + c.Request.Host + "?token=" + jwtToken if theme != "" { portalURL += "&theme=" + theme } From 63206054931eb031f765ff0ef868cb075238ab01 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Tue, 20 Jan 2026 04:48:32 +0700 Subject: [PATCH 2/4] fix(portal): handle mixed flat and nested topics in TopicPicker Topics are now parsed individually for separators instead of requiring all topics to share the same separator. This allows flat topics (job, document) to render without nesting while hierarchical topics (user.created, user.updated) are grouped. Co-Authored-By: Claude Opus 4.5 --- .../src/common/TopicPicker/TopicPicker.scss | 6 ++ .../src/common/TopicPicker/TopicPicker.tsx | 63 ++++++++++++------- 2 files changed, 45 insertions(+), 24 deletions(-) diff --git a/internal/portal/src/common/TopicPicker/TopicPicker.scss b/internal/portal/src/common/TopicPicker/TopicPicker.scss index 70b635ada..983b22423 100644 --- a/internal/portal/src/common/TopicPicker/TopicPicker.scss +++ b/internal/portal/src/common/TopicPicker/TopicPicker.scss @@ -83,4 +83,10 @@ font-size: var(--font-size-m); line-height: var(--line-height-m); } + + &__flat-topic { + padding-left: var(--spacing-6); + margin-top: var(--spacing-3); + margin-bottom: var(--spacing-3); + } } diff --git a/internal/portal/src/common/TopicPicker/TopicPicker.tsx b/internal/portal/src/common/TopicPicker/TopicPicker.tsx index a2449af3d..e1cb71a34 100644 --- a/internal/portal/src/common/TopicPicker/TopicPicker.tsx +++ b/internal/portal/src/common/TopicPicker/TopicPicker.tsx @@ -17,39 +17,32 @@ interface TopicPickerProps { onTopicsChange: (topics: string[]) => void; } -const detectSeparator = (topics: string[]): string => { - // Common separators to check - const possibleSeparators = ["/", ".", "-"]; +const possibleSeparators = ["/", ".", "-"]; - // Find the first separator that appears in all topics - // and is the first occurring separator in each topic - return ( - possibleSeparators.find((sep) => - topics.every((topic) => { - const sepIndex = topic.indexOf(sep); - if (sepIndex === -1) return false; - - // Check if any other separator appears before this one - const otherSepsIndex = possibleSeparators - .filter((s) => s !== sep) - .map((s) => topic.indexOf(s)) - .filter((idx) => idx !== -1); - - return otherSepsIndex.every((idx) => idx === -1 || idx > sepIndex); - }), - ) || "-" - ); // Fallback to '-' if no consistent separator is found +const findFirstSeparator = (topic: string): string | null => { + let firstIndex = -1; + let firstSep: string | null = null; + + for (const sep of possibleSeparators) { + const idx = topic.indexOf(sep); + if (idx !== -1 && (firstIndex === -1 || idx < firstIndex)) { + firstIndex = idx; + firstSep = sep; + } + } + + return firstSep; }; const topics: Topic[] = (() => { const topicsList = CONFIGS.TOPICS.split(","); - const separator = detectSeparator(topicsList); return topicsList.map((topic) => { - const parts = topic.split(separator); + const separator = findFirstSeparator(topic); + return { id: topic, - category: parts[0], + category: separator ? topic.split(separator)[0] : topic, }; }); })(); @@ -162,6 +155,28 @@ const TopicPicker = ({ const areAllSelected = selectedCount === categoryTopics.length; const isIndeterminate = selectedCount > 0 && !areAllSelected; + // Check if this is a flat topic (no actual nesting) + const isFlatTopic = + categoryTopics.length === 1 && categoryTopics[0].id === category; + + if (isFlatTopic) { + const topic = categoryTopics[0]; + return ( +
+ toggleTopic(topic.id)} + label={topic.id} + monospace + disabled={isEverythingSelected} + /> +
+ ); + } + return (
From 9481acff3382c2e74bb7e2b122cc2ce797982515 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Tue, 20 Jan 2026 04:54:50 +0700 Subject: [PATCH 3/4] fix: portal vite reload --- internal/portal/vite.config.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/internal/portal/vite.config.ts b/internal/portal/vite.config.ts index 07865a8dd..665caa011 100644 --- a/internal/portal/vite.config.ts +++ b/internal/portal/vite.config.ts @@ -3,7 +3,7 @@ import react from "@vitejs/plugin-react-swc"; import { sentryVitePlugin } from "@sentry/vite-plugin"; export default defineConfig(({ mode }) => { - let plugins: UserConfig["plugins"] = [react()]; + const plugins: UserConfig["plugins"] = [react()]; if (process.env.SENTRY_AUTH_TOKEN && mode === "production") { plugins.push( @@ -28,9 +28,13 @@ export default defineConfig(({ mode }) => { plugins, server: { port: 3334, - // hmr: { - // port: 3334, - // }, + host: true, + watch: { + usePolling: true, // Required for Docker volume mounts + }, + hmr: { + clientPort: 3334, // Ensure HMR WebSocket connects to the right port + }, }, build: { sourcemap: true, From bdbc535140cb0dcd737c05bdb8f1bc56cb8f763a Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Tue, 20 Jan 2026 05:06:03 +0700 Subject: [PATCH 4/4] test: update tests for tenant topics and portal URL changes - Update e2e test to expect literal ["*"] for tenant topics with wildcard destination (matches 78f162e behavior change) - Update portal handler tests to check for token= instead of tenant_id= in redirect URL (matches 20463b2 refactor) Co-Authored-By: Claude Opus 4.5 --- cmd/e2e/api_test.go | 2 +- internal/apirouter/tenant_handlers_test.go | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/e2e/api_test.go b/cmd/e2e/api_test.go index 6b02c54f4..33fc719aa 100644 --- a/cmd/e2e/api_test.go +++ b/cmd/e2e/api_test.go @@ -168,7 +168,7 @@ func (suite *basicSuite) TestTenantsAPI() { Body: map[string]interface{}{ "id": tenantID, "destinations_count": 1, - "topics": suite.config.Topics, + "topics": []string{"*"}, }, }, }, diff --git a/internal/apirouter/tenant_handlers_test.go b/internal/apirouter/tenant_handlers_test.go index 9d1e740bf..5291068eb 100644 --- a/internal/apirouter/tenant_handlers_test.go +++ b/internal/apirouter/tenant_handlers_test.go @@ -240,7 +240,7 @@ func TestTenantRetrievePortalHandler(t *testing.T) { router, _, redisClient := setupTestRouter(t, apiKey, jwtSecret) entityStore := setupTestEntityStore(t, redisClient, nil) - t.Run("should return redirect_url with tenant_id and tenant_id in body", func(t *testing.T) { + t.Run("should return redirect_url with token and tenant_id in body", func(t *testing.T) { t.Parallel() // Setup @@ -261,7 +261,7 @@ func TestTenantRetrievePortalHandler(t *testing.T) { // Test assert.Equal(t, http.StatusOK, w.Code) assert.NotEmpty(t, response["redirect_url"]) - assert.Contains(t, response["redirect_url"], "tenant_id="+existingResource.ID) + assert.Contains(t, response["redirect_url"], "token=") assert.Equal(t, existingResource.ID, response["tenant_id"]) // Cleanup @@ -288,7 +288,7 @@ func TestTenantRetrievePortalHandler(t *testing.T) { // Test assert.Equal(t, http.StatusOK, w.Code) - assert.Contains(t, response["redirect_url"], "tenant_id="+existingResource.ID) + assert.Contains(t, response["redirect_url"], "token=") assert.Contains(t, response["redirect_url"], "theme=dark") assert.Equal(t, existingResource.ID, response["tenant_id"])