Aside state persisted in query params#189
Conversation
Greptile SummaryThis PR persists aside/detail-panel state in the URL via Key issues found:
Confidence Score: 2/5
Last reviewed commit: 45463e4 |
|
@greptile re-review |
| byId: authorizedProcedure({ | ||
| permission: { coverage: ["view"] }, | ||
| }) | ||
| .input(CoverageRequestIdInput) | ||
| .query(async ({ input, ctx }) => { | ||
| const currentUser = ctx.currentSessionService.requireUser(); | ||
| const request = await ctx.coverageService.getCoverageRequestById( | ||
| input.coverageRequestId, | ||
| ); | ||
|
|
||
| if ( | ||
| hasPermission({ | ||
| role: currentUser.role as Role, | ||
| permission: { shifts: ["view-all"] }, | ||
| }) | ||
| ) { | ||
| return getListCoverageRequestWithReason(request); | ||
| } | ||
| return getListCoverageRequestBase(request); | ||
| }), |
There was a problem hiding this comment.
byId endpoint bypasses row-level visibility filters
The list endpoint passes viewerUserId and viewerRole to listCoverageRequests, which applies row-level security filtering for non-admin users (those without shifts: ["view-all"]). Non-admins are restricted to seeing only open requests (excluding shifts they're already assigned to) and requests where they are the requester or covering volunteer.
The new byId route fetches any coverage request by ID with no equivalent user-scoped visibility filter — it applies only the top-level coverage: ["view"] permission gate. This means a non-admin user with a ?coverageId= URL (e.g., shared by someone else) can fetch coverage request details they would never see in the list — including resolved requests they have no involvement in.
This is particularly impactful because URL sharing is precisely the feature being shipped. Add the same role-based filter here:
| byId: authorizedProcedure({ | |
| permission: { coverage: ["view"] }, | |
| }) | |
| .input(CoverageRequestIdInput) | |
| .query(async ({ input, ctx }) => { | |
| const currentUser = ctx.currentSessionService.requireUser(); | |
| const request = await ctx.coverageService.getCoverageRequestById( | |
| input.coverageRequestId, | |
| ); | |
| if ( | |
| hasPermission({ | |
| role: currentUser.role as Role, | |
| permission: { shifts: ["view-all"] }, | |
| }) | |
| ) { | |
| return getListCoverageRequestWithReason(request); | |
| } | |
| return getListCoverageRequestBase(request); | |
| }), | |
| byId: authorizedProcedure({ | |
| permission: { coverage: ["view"] }, | |
| }) | |
| .input(CoverageRequestIdInput) | |
| .query(async ({ input, ctx }) => { | |
| const currentUser = ctx.currentSessionService.requireUser(); | |
| const request = await ctx.coverageService.getCoverageRequestById( | |
| input.coverageRequestId, | |
| ); | |
| const canViewAll = hasPermission({ | |
| role: currentUser.role as Role, | |
| permission: { shifts: ["view-all"] }, | |
| }); | |
| // For non-admins, enforce the same visibility rules as the list endpoint: | |
| // only allow access to open requests or requests they're involved in. | |
| if (!canViewAll) { | |
| const isInvolved = | |
| request.requestingVolunteer.id === currentUser.id || | |
| request.coveredByVolunteer?.id === currentUser.id; | |
| const isOpen = request.status === CoverageStatus.open; | |
| if (!isInvolved && !isOpen) { | |
| throw new NeuronError("Forbidden", NeuronErrorCodes.FORBIDDEN); | |
| } | |
| } | |
| return canViewAll | |
| ? getListCoverageRequestWithReason(request) | |
| : getListCoverageRequestBase(request); | |
| }), |
| meta: { suppressToast: true }, | ||
| }, | ||
| ); | ||
| const selectedItem = data as CoverageListItem | undefined; |
There was a problem hiding this comment.
Unsafe type cast may hide server/client type mismatch
data as CoverageListItem | undefined forcefully asserts the tRPC response to CoverageListItem. The server returns a union of getListCoverageRequestBase / getListCoverageRequestWithReason, and CoverageListItem is defined as ListCoverageRequestBase | ListCoverageRequestWithReason. If the inferred tRPC return type doesn't match — for instance, because of a date serialization difference where tRPC returns a string but the model expects a Date — the cast will silence the error and cause a silent runtime failure.
Remove the cast and fix any TypeScript errors surfaced by the raw data type instead, or add a Zod/tRPC validation schema on the client side.
| useEffect(() => { | ||
| if (linkedClass?.termId) { | ||
| setSelectedTermId((prev) => { | ||
| if (prev === linkedClass.termId) return prev; | ||
| setQueryTerm(linkedClass.termId); | ||
| return linkedClass.termId; | ||
| }); | ||
| } | ||
| }, [linkedClass?.termId, setQueryTerm]); |
There was a problem hiding this comment.
Side effect inside a setState updater function
setQueryTerm(linkedClass.termId) is called inside the setSelectedTermId updater function. React's updater functions are expected to be pure — they must not produce side effects. In React Strict Mode (enabled by default in development), React intentionally invokes updaters twice to surface these violations; this will cause setQueryTerm (and its URL mutation) to fire twice on every effect run.
Perform both state mutations directly instead of nesting one inside the other:
| useEffect(() => { | |
| if (linkedClass?.termId) { | |
| setSelectedTermId((prev) => { | |
| if (prev === linkedClass.termId) return prev; | |
| setQueryTerm(linkedClass.termId); | |
| return linkedClass.termId; | |
| }); | |
| } | |
| }, [linkedClass?.termId, setQueryTerm]); | |
| useEffect(() => { | |
| if (linkedClass?.termId && linkedClass.termId !== selectedTermId) { | |
| setSelectedTermId(linkedClass.termId); | |
| void setQueryTerm(linkedClass.termId); | |
| } | |
| }, [linkedClass?.termId, selectedTermId, setQueryTerm, setSelectedTermId]); |
Summary
Aside (detail panel) state is now persisted in the URL via query parameters. Opening an aside updates the URL, and refreshing the page or sharing the link reopens the same aside.
What changed
Controlled PageLayout
PageLayoutnow supports an optional Radix-style controlled pattern withopen/onOpenChangeprops. When provided, the aside open/close state is driven externally. Pages without asides are unaffected.Classes page (
?classId=)Clicking a class adds
?classId=xxxto the URL. On load, if the param is present, the aside opens and the correct term is automatically selected.Schedule page (
?shiftId=)Clicking a shift adds
?shiftId=xxxto the URL. On load, the aside opens and the view navigates to the shift's month so it appears in the list/calendar.Coverage page (
?coverageId=)Clicking a coverage request adds
?coverageId=xxxto the URL. A newcoverage.byIdbackend route was added so the aside can fetch its data independently (previously it read from the in-memory list). Next/prev navigation updates the URL param accordingly.Calendar view fix
The calendar view now respects
selectedDateon initial render, fixing an issue where switching from list to week view would reset the visible date.