[Dashboard][Backend] - Internal support tooling#1135
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Warning Rate limit exceeded
⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. 📝 WalkthroughWalkthroughThis PR introduces a complete internal support dashboard system for Stack Auth. It adds backend API routes for querying projects, teams, users, and events filtered by internal support team membership, along with a comprehensive frontend dashboard for browsing and inspecting this data interactively. Changes
Sequence DiagramsequenceDiagram
actor User
participant Dashboard as Dashboard<br/>(Browser)
participant API as Backend API
participant Auth as Support Auth<br/>Module
participant DB as Tenancy<br/>Prisma
User->>Dashboard: Open Internal Support Dashboard
Dashboard->>API: GET /projects with auth headers
API->>Auth: validateSupportTeamMembership(auth)
Auth->>DB: Lookup user in internal support team
DB-->>Auth: User member status
alt User is support member
Auth-->>API: Return user + tenancy
API->>DB: Query projects with metadata
DB-->>API: Project list + counts
API-->>Dashboard: JSON projects response
Dashboard->>User: Render project list
else User not authorized
Auth-->>API: 403 Forbidden
API-->>Dashboard: Error response
Dashboard->>User: Show error toast
end
User->>Dashboard: Click project → view teams/users/events
Dashboard->>API: GET /projects/[id]/teams|users|events
API->>Auth: Validate membership (cached)
Auth-->>API: Authorized
API->>DB: Query filtered data
DB-->>API: Results + pagination
API-->>Dashboard: Structured response
Dashboard->>User: Render detail panel
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 1 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Greptile OverviewGreptile SummaryImplements internal support tooling to allow Stack Auth support team members to view and manage customer projects. Adds secure backend API endpoints protected by team membership validation and a comprehensive dashboard UI with dual-panel navigation. Key Changes
Implementation Quality
Confidence Score: 5/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant User as Support Agent
participant Dashboard as Internal Support Dashboard
participant Backend as Support API (/api/v1/internal/support)
participant AuthModule as Support Authorization
participant DB as Database (Prisma)
User->>Dashboard: Access /internal-support
Dashboard->>Dashboard: useAuthHeaders() - Get authentication token
Note over Dashboard,Backend: Search for Projects
Dashboard->>Backend: GET /projects?search=query
Backend->>AuthModule: validateSupportTeamMembership()
AuthModule->>DB: Check team membership in "internal" project
DB-->>AuthModule: Return membership status
alt Not a support team member
AuthModule-->>Backend: Throw 403 Error
Backend-->>Dashboard: 403 Forbidden
else Is support team member
AuthModule-->>Backend: Return validated authorization
Backend->>DB: Query projects with filters
DB-->>Backend: Return projects list
Backend->>DB: Fetch additional project details (config, counts)
DB-->>Backend: Return enriched project data
Backend-->>Dashboard: Return projects with metadata
Dashboard->>Dashboard: Display project cards
end
Note over Dashboard,Backend: View Project Details
User->>Dashboard: Click on project
Dashboard->>Backend: GET /projects/{projectId}/users
Backend->>AuthModule: validateSupportTeamMembership()
AuthModule->>DB: Verify team membership
AuthModule-->>Backend: Return validated authorization
Backend->>DB: Get tenancy for projectId
Backend->>DB: Query users with filters
DB-->>Backend: Return users with authentication methods and teams
Backend-->>Dashboard: Return user list
Dashboard->>Backend: GET /projects/{projectId}/teams
Backend->>AuthModule: validateSupportTeamMembership()
AuthModule-->>Backend: Return validated authorization
Backend->>DB: Query teams with member previews
DB-->>Backend: Return teams list
Backend-->>Dashboard: Return team list
Dashboard->>Backend: GET /projects/{projectId}/events
Backend->>AuthModule: validateSupportTeamMembership()
AuthModule-->>Backend: Return validated authorization
Backend->>DB: Query events by projectId and branchId
DB-->>Backend: Return events with IP info
Backend-->>Dashboard: Return events list
Dashboard->>Dashboard: Display project details in tabs
Note over Dashboard,Backend: Navigate to User or Team Details
User->>Dashboard: Click on user or team
Dashboard->>Backend: GET /projects/{projectId}/users?userId={id}
Backend->>AuthModule: validateSupportTeamMembership()
AuthModule-->>Backend: Return validated authorization
Backend->>DB: Query specific user
DB-->>Backend: Return user details
Backend-->>Dashboard: Return user
Dashboard->>Dashboard: Display in right panel
|
There was a problem hiding this comment.
Actionable comments posted: 11
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/backend/.env.development (1)
52-57: Fix dotenv-linter key ordering.
The linter expectsSTACK_INTERNAL_SUPPORT_TEAM_IDbeforeSTACK_OPENAI_API_KEY; reordering keeps env lint clean.🧹 Proposed reorder
-STACK_FREESTYLE_API_KEY=mock_stack_freestyle_key -STACK_OPENAI_API_KEY=mock_openai_api_key -STACK_STRIPE_SECRET_KEY=sk_test_mockstripekey -STACK_STRIPE_WEBHOOK_SECRET=mock_stripe_webhook_secret -STACK_INTERNAL_SUPPORT_TEAM_ID=mock_internal_support_team_id +STACK_FREESTYLE_API_KEY=mock_stack_freestyle_key +STACK_INTERNAL_SUPPORT_TEAM_ID=mock_internal_support_team_id +STACK_OPENAI_API_KEY=mock_openai_api_key +STACK_STRIPE_SECRET_KEY=sk_test_mockstripekey +STACK_STRIPE_WEBHOOK_SECRET=mock_stripe_webhook_secret
🤖 Fix all issues with AI agents
In
`@apps/backend/src/app/api/latest/internal/support/projects/`[projectId]/events/route.tsx:
- Around line 37-38: The current parsing of query params uses parseInt on
req.query.limit and req.query.offset which can return NaN or negative values;
update the logic around limit and offset (the variables `limit` and `offset` in
route.tsx) to validate and clamp results: parse the values with base 10, default
to safe numbers when parseInt yields NaN, clamp `limit` to a min of 1 and max of
100, and clamp `offset` to a min of 0; then pass these validated `limit` (for
Prisma take) and `offset` (for Prisma skip) into the Prisma query to avoid
invalid/skipped queries.
- Around line 33-35: Replace the non-null assertion on fullReq.auth in the
handler with an explicit guard: before calling validateSupportTeamMembership,
check if fullReq.auth is present and if not throw a clear, descriptive error
(e.g., "Missing authentication on request" or an appropriate HTTP error) so
validateSupportTeamMembership(fullReq.auth) is only called with a defined auth
object; update the handler function accordingly to reference fullReq.auth
without using "!".
In
`@apps/backend/src/app/api/latest/internal/support/projects/`[projectId]/teams/route.tsx:
- Around line 35-37: Replace the non-null assertion on fullReq.auth passed to
validateSupportTeamMembership with an explicit defensive guard: assign or inline
using fullReq.auth ?? throwErr('Missing auth in support team route') so that if
auth is undefined an explicit error is thrown; update the call site (handler:
async (req, fullReq) => { await validateSupportTeamMembership(fullReq.auth!); })
to call validateSupportTeamMembership(auth) where auth is the guarded value and
ensure throwErr is imported/available and the error message clearly states the
missing auth and route context.
- Around line 41-42: The current parsing of query params uses parseInt directly
and Math.min which can produce NaN or negative values; update the logic around
limit and offset parsing in route.tsx to: parse each param into a number, check
Number.isFinite and not NaN, clamp limit to the range [1,100] (or default to 25)
and clamp offset to >=0 (default 0); replace the existing const limit and const
offset assignments with these validated/clamped values so downstream pagination
never receives NaN or negative numbers.
In
`@apps/backend/src/app/api/latest/internal/support/projects/`[projectId]/users/route.tsx:
- Around line 41-42: The limit and offset parsing in route.tsx currently uses
parseInt directly which can yield NaN or negative values; change the parsing for
req.query.limit and req.query.offset to validate and clamp results: parse the
raw value, if it is NaN fall back to defaults (limit default 25, offset default
0), ensure limit is clamped to the range [1,100] (e.g., Math.max(1,
Math.min(100, parsed))) and ensure offset is >= 0 (e.g., Math.max(0, parsed));
update the variables named limit and offset (or extract a small helper like
parsePositiveIntOrDefault used by both) so Prisma queries never receive NaN or
negative pagination values.
- Around line 35-37: The handler currently calls
validateSupportTeamMembership(fullReq.auth!) using a non-null assertion; replace
this with an explicit guard that checks fullReq.auth and throws a clear error if
missing before calling validateSupportTeamMembership. Locate the async handler
function in route.tsx and add an auth presence check (e.g., if (!fullReq.auth)
throw new Error("Missing authentication in request") or a typed HttpError), then
pass fullReq.auth to validateSupportTeamMembership(fullReq.auth); ensure
subsequent code relies on the validated/authenticated value rather than assuming
non-null.
In `@apps/backend/src/app/api/latest/internal/support/projects/route.tsx`:
- Around line 33-35: Replace the non-null assertion on fullReq.auth with an
explicit guard: obtain auth via nullish coalescing (e.g. const auth =
fullReq.auth ?? throwErr("...")) and pass that auth into
validateSupportTeamMembership instead of fullReq.auth!; update the handler so it
throws a clear error message when auth is missing (using your existing throwErr
helper) before calling validateSupportTeamMembership.
- Around line 82-97: Replace the bare try-catch blocks that call
getSoleTenancyFromProjectBranch with the function's returnNullIfNotFound
behavior: call getSoleTenancyFromProjectBranch(project.id, DEFAULT_BRANCH_ID,
true) for the config fetch (where fullConfig is set via
renderedOrganizationConfigToProjectCrud) and for the owner team lookup, remove
the surrounding try-catch and check for a null tenancy result before accessing
tenancy.config or creating the prisma client; this avoids swallowing real errors
and preserves existing null-handling logic when tenancy is not found.
- Around line 38-39: The limit/offset parsing in route.tsx can produce NaN or
negative values; update the parsing of req.query.limit and req.query.offset (the
const limit and offset variables) to coerce to integers safely, ensure they are
finite and >= 0, and fall back to defaults (limit=25, offset=0) when invalid;
additionally clamp limit to a maximum of 100 before passing to Prisma's take and
skip (use safe checks like Number.isFinite after Number(...) or parseInt,
Math.max(0, ...) and Math.min(100, ...) to enforce bounds and avoid passing
invalid values to Prisma).
In `@apps/backend/src/app/api/latest/internal/support/support-auth.ts`:
- Line 18: The module-level call to getEnvVariable for SUPPORT_TEAM_ID causes an
import-time throw if the env var is missing; change the declaration of
SUPPORT_TEAM_ID to call getEnvVariable("STACK_INTERNAL_SUPPORT_TEAM_ID", "") (or
another safe default like an empty string) so the module does not crash at
import, and ensure the existing runtime authorization check that performs the
403 validation still verifies that SUPPORT_TEAM_ID is present/valid before
granting access.
In `@apps/dashboard/src/app/`(main)/(protected)/internal-support/page-client.tsx:
- Around line 1042-1046: Remove the debug JSX block that prints internal details
(the <div> containing "Debug - project.id" and "Debug - config") from
page-client.tsx, or wrap it so it only renders in development (e.g., guard with
NODE_ENV or an isDev flag); locate the snippet that references project.id and
config and either delete those two inner <div> elements or conditionally render
them so they are not exposed in production builds.
🧹 Nitpick comments (7)
apps/backend/src/app/api/latest/internal/support/projects/route.tsx (1)
99-120: Cache internal tenancy/prisma for owner team lookup.
getSoleTenancyFromProjectBranch("internal", ...)and its Prisma client are fetched per project; prefetch once to avoid N+1 overhead.⚡️ Prefetch internal tenancy/prisma
@@ - // Fetch full details for each project + const internalTenancy = await getSoleTenancyFromProjectBranch("internal", DEFAULT_BRANCH_ID, true); + const internalPrisma = internalTenancy ? await getPrismaClientForTenancy(internalTenancy) : null; + + // Fetch full details for each project @@ - let ownerTeam = null; - if (project.ownerTeamId) { - try { - const internalTenancy = await getSoleTenancyFromProjectBranch("internal", DEFAULT_BRANCH_ID); - const internalPrisma = await getPrismaClientForTenancy(internalTenancy); - const team = await internalPrisma.team.findFirst({ - where: { - tenancyId: internalTenancy.id, - teamId: project.ownerTeamId, - }, - }); - if (team) { - ownerTeam = { - id: team.teamId, - displayName: team.displayName, - }; - } - } catch { - // Ignore errors - } - } + let ownerTeam = null; + if (project.ownerTeamId && internalPrisma && internalTenancy) { + const team = await internalPrisma.team.findFirst({ + where: { + tenancyId: internalTenancy.id, + teamId: project.ownerTeamId, + }, + }); + if (team) { + ownerTeam = { + id: team.teamId, + displayName: team.displayName, + }; + } + }apps/backend/src/app/api/latest/internal/support/support-auth.ts (1)
38-41: Prefer an interface forSupportAuth.
Project style favorsinterfacefor object shapes. As per coding guidelines, prefer interfaces over type aliases for object shapes.♻️ Suggested refactor
-export type SupportAuth = { - user: UsersCrud["Admin"]["Read"], - tenancy: Tenancy, -}; +export interface SupportAuth { + user: UsersCrud["Admin"]["Read"]; + tenancy: Tenancy; +}apps/dashboard/src/app/(main)/(protected)/internal-support/page-client.tsx (5)
50-97: Preferinterfaceovertypefor object shapes.Per coding guidelines,
SupportProject,SupportUser,SupportTeam, andSupportEventshould be declared as interfaces. ThePanelContentunion type (lines 119-124) is fine as atypesince unions cannot be expressed with interfaces.♻️ Suggested refactor
-type SupportProject = { +interface SupportProject { id: string, // ... rest of properties -}; +} -type SupportUser = { +interface SupportUser { id: string, // ... rest of properties -}; +} -type SupportTeam = { +interface SupportTeam { id: string, // ... rest of properties -}; +} -type SupportEvent = { +interface SupportEvent { id: string, // ... rest of properties -}; +}
136-138: Consider logging or handling localStorage parse errors.The empty catch block silently swallows JSON parsing errors, which could hide corrupted localStorage data. Consider at minimum logging to console or clearing the corrupted value.
♻️ Suggested improvement
} catch { - // If error, use initial value + // Clear corrupted data and use initial value + console.warn(`Failed to parse localStorage key "${key}", using default value`); + window.localStorage.removeItem(key); }
190-193: UnusedsetWidgetConfigvariable.
setWidgetConfigis declared but never used. Either remove it or implement the intended widget configuration feature.♻️ Suggested fix
- const [, setWidgetConfig] = useLocalStorage<string[]>( + const [] = useLocalStorage<string[]>( 'support-dashboard-widgets', ['recent-projects', 'search'] );Or remove the hook entirely if widget configuration is not needed.
479-490: Consider consolidating search effects.The initial load effect (lines 480-482) has an empty dependency array but references
performSearch. Additionally, the debounced effect (lines 485-490) will also trigger a search on mount after 300ms. This causes two searches on initial mount.♻️ Suggested consolidation
Remove the initial load effect and let the debounced effect handle the initial search:
- // Initial load - useEffect(() => { - runAsynchronouslyWithAlert(performSearch()); - }, []); - // Debounced search useEffect(() => { const timer = setTimeout(() => { runAsynchronouslyWithAlert(performSearch()); }, 300); return () => clearTimeout(timer); }, [searchQuery, searchType, performSearch]);This ensures a single search on mount with consistent behavior.
1272-1296: Redundant error handling - letrunAsynchronouslyWithAlerthandle errors.
handleTeamClickhas its own try-catch, but callers also wrap it withrunAsynchronouslyWithAlert(line 1347). This causes double error handling and the internal catch swallows errors beforerunAsynchronouslyWithAlertcan process them. Per guidelines, avoid try-catch-all patterns.♻️ Suggested refactor
const handleTeamClick = async (teamIdToFind: string) => { setIsLoadingTeam(teamIdToFind); - try { - const headers = await getHeaders(); - const response = await fetch( - `${baseApiUrl}/api/v1/internal/support/projects/${projectId}/teams?teamId=${teamIdToFind}`, - { headers } - ); - if (response.ok) { - const data = await response.json(); - const team = data.items[0]; - if (team) { - onSelectTeam(team); - } else { - toast({ variant: "destructive", title: "Team not found" }); - } + const headers = await getHeaders(); + const response = await fetch( + `${baseApiUrl}/api/v1/internal/support/projects/${projectId}/teams?teamId=${teamIdToFind}`, + { headers } + ); + if (response.ok) { + const data = await response.json(); + const team = data.items[0]; + if (team) { + onSelectTeam(team); } else { toast({ variant: "destructive", title: "Failed to fetch team details" }); } - } catch { - toast({ variant: "destructive", title: "Failed to fetch team details" }); - } finally { - setIsLoadingTeam(null); + } else { + throw new Error("Failed to fetch team details"); } + setIsLoadingTeam(null); };Note: You'll need to ensure
setIsLoadingTeam(null)is called even on error - consider using a finally-like pattern withinrunAsynchronouslyWithAlertor restructuring.
| const performSearch = useCallback(async () => { | ||
| setIsLoading(true); | ||
| try { | ||
| const headers = await getHeaders(); | ||
| const params = new URLSearchParams({ limit: '50' }); | ||
|
|
||
| if (searchQuery.trim()) { | ||
| if (searchType === 'id') { | ||
| params.set('projectId', searchQuery.trim()); | ||
| } else { | ||
| params.set('search', searchQuery.trim()); | ||
| } | ||
| } | ||
|
|
||
| const response = await fetch(`${baseApiUrl}/api/v1/internal/support/projects?${params}`, { | ||
| headers, | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| const error = await response.json().catch(() => ({ error: 'Unknown error' })); | ||
| throw new Error(error.error || 'Failed to fetch projects'); | ||
| } | ||
|
|
||
| const data = await response.json(); | ||
| setSearchResults(data.items); | ||
| setTotal(data.total); | ||
| } catch (e) { | ||
| toast({ variant: "destructive", title: e instanceof Error ? e.message : "Search failed" }); | ||
| } finally { | ||
| setIsLoading(false); | ||
| } | ||
| }, [getHeaders, baseApiUrl, searchQuery, searchType]); | ||
|
|
||
| // Initial load | ||
| useEffect(() => { | ||
| runAsynchronouslyWithAlert(performSearch()); | ||
| }, []); | ||
|
|
||
| // Debounced search | ||
| useEffect(() => { | ||
| const timer = setTimeout(() => { | ||
| runAsynchronouslyWithAlert(performSearch()); | ||
| }, 300); | ||
| return () => clearTimeout(timer); | ||
| }, [searchQuery, searchType, performSearch]); |
There was a problem hiding this comment.
Multiple API error handlers don't clear or reset state when fetches fail, causing stale data to be displayed alongside error toasts. This confuses users into thinking the old data is current.
View Details
📝 Patch Details
diff --git a/apps/dashboard/src/app/(main)/(protected)/internal-support/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/internal-support/page-client.tsx
index a7e30d60..e17a7ea6 100644
--- a/apps/dashboard/src/app/(main)/(protected)/internal-support/page-client.tsx
+++ b/apps/dashboard/src/app/(main)/(protected)/internal-support/page-client.tsx
@@ -470,6 +470,8 @@ function SearchPanel({
setSearchResults(data.items);
setTotal(data.total);
} catch (e) {
+ setSearchResults([]);
+ setTotal(0);
toast({ variant: "destructive", title: e instanceof Error ? e.message : "Search failed" });
} finally {
setIsLoading(false);
@@ -649,6 +651,7 @@ function ProjectDetailPanel({
} else {
const error = await response.json().catch(() => ({ error: response.statusText }));
console.error('Failed to fetch owner team:', error);
+ setOwnerTeamDetails(null);
}
} finally {
setIsLoadingOwner(false);
@@ -675,6 +678,7 @@ function ProjectDetailPanel({
} else {
const error = await response.json().catch(() => ({ error: response.statusText }));
console.error('Failed to fetch users:', error);
+ setUsers([]);
toast({ variant: "destructive", title: `Failed to load users: ${error.error || 'Unknown error'}` });
}
} finally {
@@ -705,6 +709,7 @@ function ProjectDetailPanel({
} else {
const error = await response.json().catch(() => ({ error: response.statusText }));
console.error('Failed to fetch teams:', error);
+ setTeams([]);
toast({ variant: "destructive", title: `Failed to load teams: ${error.error || 'Unknown error'}` });
}
} finally {
@@ -733,6 +738,7 @@ function ProjectDetailPanel({
} else {
const error = await response.json().catch(() => ({ error: response.statusText }));
console.error('Failed to fetch events:', error);
+ setEvents([]);
toast({ variant: "destructive", title: `Failed to load events: ${error.error || 'Unknown error'}` });
}
} finally {
Analysis
Stale data displayed alongside error toasts in internal-support dashboard
What fails: When API calls fail in the internal support dashboard (SearchPanel and ProjectDetailPanel), error toasts are shown but state is not cleared, causing stale data to remain displayed while suggesting an error occurred.
How to reproduce:
- Navigate to the internal support dashboard
- In SearchPanel: Perform a search that succeeds (gets results)
- Trigger a network error or API failure on the next search
- Observe: Error toast appears, but previous search results still display instead of "No projects found"
Same issue occurs for:
- ProjectDetailPanel Users tab: Load users successfully, then trigger API error
- ProjectDetailPanel Teams tab: Load teams successfully, then trigger API error
- ProjectDetailPanel Events tab: Load events successfully, then trigger API error
- ProjectDetailPanel Owner Team section: Load owner team successfully, then trigger API error
Result: Stale data displays with error toast, confusing users who think the old data is current.
Expected: When an API call fails, the associated state (searchResults, users, teams, events, ownerTeamDetails) should be cleared so the UI displays "No [items] found" or empty state, matching the error message.
Root cause: Error handlers in the following locations didn't reset state:
- SearchPanel (line 472): toast shown but searchResults and total not cleared
- ProjectDetailPanel - Owner Team (line 650): error logged but ownerTeamDetails not cleared
- ProjectDetailPanel - Users (line 678): toast shown but users array not cleared
- ProjectDetailPanel - Teams (line 708): toast shown but teams array not cleared
- ProjectDetailPanel - Events (line 736): toast shown but events array not cleared
Fix: Added state resets in all error handlers:
- SearchPanel:
setSearchResults([])andsetTotal(0) - Owner Team fetch:
setOwnerTeamDetails(null) - Users fetch:
setUsers([]) - Teams fetch:
setTeams([]) - Events fetch:
setEvents([])
This ensures the UI renders the correct empty state messages when errors occur instead of displaying stale data.
|
Closing this as most of this has been surfaced with new UI updates. Almost anything I would look for can now be found on the normal dashboard. |
Summary by CodeRabbit
Release Notes
✏️ Tip: You can customize this high-level summary in your review settings.