-
Notifications
You must be signed in to change notification settings - Fork 41
Add automatic redirect to last accessed workspace #539
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
WalkthroughAdds client-side per-user last-workspace persistence (localStorage), a /workspace redirect handler route that forwards users to last or first workspace, removes lastWorkspaceSlug from public API/types, and adds a Prisma field lastWorkspaceSlug on User (stored but not exposed). Several components persist/clear the slug. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor User
participant Router as Router (/workspace)
participant WRH as WorkspaceRedirectHandler
participant Store as Redux Store
participant API as Workspace API
participant Nav as Navigator
User->>Router: Navigate to /workspace (private)
Router->>WRH: Render
WRH->>Store: selectUser()
WRH->>API: useGetWorkspaceListQuery()
alt user has lastWorkspaceSlug
WRH->>Nav: replace to "/{lastWorkspaceSlug}"
else no lastWorkspaceSlug
alt workspace list has first page
API-->>WRH: workspaces[0].slug
WRH->>Nav: replace to "/{firstSlug}"
else still loading / no workspaces
WRH-->>User: show Backdrop (loading)
end
end
sequenceDiagram
autonumber
participant Layout as Workspace/Document Layout
participant Store as Redux Store
participant Util as lastWorkspace.ts (localStorage)
Layout->>Store: selectUser()
Layout->>Layout: read workspaceSlug from route
alt user.id and workspaceSlug present
Layout->>Util: setLastWorkspaceSlug(user.id, workspaceSlug)
else
Layout-->>Layout: No-op
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Suggested reviewers
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
📜 Recent review detailsConfiguration used: CodeRabbit UI Review profile: CHILL Plan: Pro 📒 Files selected for processing (15)
💤 Files with no reviewable changes (4)
🚧 Files skipped from review as they are similar to previous changes (6)
🧰 Additional context used🧬 Code graph analysis (3)frontend/src/components/popovers/ProfilePopover.tsx (2)
frontend/src/components/common/WorkspaceRedirectHandler.tsx (3)
frontend/src/hooks/api/user.ts (2)
🔇 Additional comments (10)
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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 5
🧹 Nitpick comments (6)
frontend/src/components/common/GuestRoute.tsx (1)
18-22: Optional: centralize all “where to go” logic behind WorkspaceRedirectHandlerIf
lastWorkspaceSlugbecomes stale (user removed from that workspace), navigating directly to/${slug}could yield a 404 before any fallback. Consider always sending guests to/workspaceand letting the centralized redirect handler decide the target. This trades one extra redirect for more robust behavior in edge cases.Example change:
- const redirectPath = userStore.data?.lastWorkspaceSlug - ? `/${userStore.data.lastWorkspaceSlug}` - : "/workspace"; - - return <Navigate to={redirectPath} state={{ from: location }} replace />; + return <Navigate to="/workspace" state={{ from: location }} replace />;backend/src/users/dto/update-last-workspace-slug.dto.ts (1)
1-8: ValidationPipe Transform ConfirmedGlobal
ValidationPipeis configured with{ transform: true }inbackend/src/main.ts:22, so the@Transformdecorator will run as expected.You can safely apply the following enhancements to
UpdateLastWorkspaceSlugDtoto tighten validation and normalize the workspace slug:--- a/backend/src/users/dto/update-last-workspace-slug.dto.ts +++ b/backend/src/users/dto/update-last-workspace-slug.dto.ts @@ -import { ApiProperty } from "@nestjs/swagger"; -import { IsString } from "class-validator"; +import { ApiProperty } from "@nestjs/swagger"; +import { IsString, IsNotEmpty, Matches, MaxLength } from "class-validator"; +import { Transform } from "class-transformer"; export class UpdateLastWorkspaceSlugDto { @ApiProperty({ type: String, description: "Workspace slug to set as last accessed" }) - @IsString() - workspaceSlug: string; + @IsString() + @IsNotEmpty() + @MaxLength(64) + @Matches(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/, { message: "Invalid slug format" }) + @Transform(({ value }) => String(value).trim().toLowerCase()) + workspaceSlug: string; }• Imports updated to include
IsNotEmpty,MaxLength,Matches, andTransform.
• Enforces non-empty, max-length (64), and a canonical slug pattern.
• Normalizes incoming values by trimming whitespace and lowercasing.These changes will prevent silent mismatches in routing and comparisons.
frontend/src/hooks/api/user.ts (1)
112-130: Guard against empty input and avoid sending whitespace-only slugsA small guard + trim prevents no-op/invalid writes and reduces noise on the backend. Also keeps typing as
voidconsistently returned.export const useUpdateLastWorkspaceSlugMutation = () => { const authStore = useSelector(selectAuth); const queryClient = useQueryClient(); return useMutation({ - mutationFn: async (workspaceSlug: string) => { - const res = await axios.patch<void>("/users/last-workspace-slug", { - workspaceSlug, - }); - - return res.data; - }, + mutationFn: async (workspaceSlug: string) => { + const ws = workspaceSlug?.trim(); + if (!ws) { + return; + } + const res = await axios.patch<void>("/users/last-workspace-slug", { workspaceSlug: ws }); + return res.data; + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: generateGetUserQueryKey(authStore.accessToken || ""), }); }, }); };Optional follow-up: consider short-circuiting server-side (don’t update if the value is unchanged) to eliminate redundant writes under frequent navigations.
frontend/src/hooks/api/workspace.ts (1)
45-45: Avoid redundant PATCH calls when the slug hasn’t changedOnly update the last-workspace slug if it differs from the one we already have for the user. This reduces unnecessary network chatter and DB writes.
- updateLastWorkspaceSlug(query.data.slug).catch(() => {}); + // Only persist when the value actually changes + if (userStore.data?.lastWorkspaceSlug !== query.data.slug) { + updateLastWorkspaceSlug(query.data.slug).catch(() => {}); + }- }, [dispatch, query.data, updateLastWorkspaceSlug]); + }, [dispatch, query.data, updateLastWorkspaceSlug, userStore]);Add the minimal wiring outside this hunk:
// Add imports import { useSelector } from "react-redux"; import { selectUser } from "../../store/userSlice"; // Inside the hook (near dispatch) const userStore = useSelector(selectUser);Optional: instead of
.catch(() => {}), report errors to your telemetry (Sentry, console.warn) so you can observe unexpected membership failures.If you want, I can push a follow-up commit wiring these imports and dependencies.
Also applies to: 52-52
backend/src/users/users.service.ts (1)
15-29: Prefer findUniqueOrThrow here to avoid returning null for a typed FindUserResponse.The signature promises FindUserResponse, but findUnique may yield null (e.g., stale token). Using findUniqueOrThrow produces a consistent 404 and a safer type.
Here’s a minimal refactor:
- const foundUser = await this.prismaService.user.findUnique({ + const foundUser = await this.prismaService.user.findUniqueOrThrow({ select: { id: true, nickname: true, lastWorkspaceSlug: true, createdAt: true, updatedAt: true, }, where: { id: userId }, });If you intentionally want null → 200, please confirm so we can align the controller docs accordingly.
backend/src/users/users.controller.ts (1)
1-1: Import HttpCode/HttpStatus for proper 204 semantics on PATCH.You’re returning void; better to signal “no content” explicitly.
-import { Body, Controller, Get, Put, Req, Patch } from "@nestjs/common"; +import { Body, Controller, Get, Put, Req, Patch, HttpCode, HttpStatus } from "@nestjs/common";
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (9)
backend/prisma/schema.prisma(1 hunks)backend/src/users/dto/update-last-workspace-slug.dto.ts(1 hunks)backend/src/users/users.controller.ts(3 hunks)backend/src/users/users.service.ts(3 hunks)frontend/src/components/common/GuestRoute.tsx(1 hunks)frontend/src/components/common/WorkspaceRedirectHandler.tsx(1 hunks)frontend/src/hooks/api/user.ts(1 hunks)frontend/src/hooks/api/workspace.ts(3 hunks)frontend/src/routes.tsx(2 hunks)
🧰 Additional context used
🧬 Code graph analysis (4)
frontend/src/hooks/api/user.ts (1)
frontend/src/store/authSlice.ts (1)
selectAuth(36-36)
backend/src/users/users.controller.ts (2)
backend/src/users/dto/update-last-workspace-slug.dto.ts (1)
UpdateLastWorkspaceSlugDto(4-8)backend/src/utils/types/req.type.ts (1)
AuthorizedRequest(6-6)
frontend/src/hooks/api/workspace.ts (3)
backend/src/users/users.controller.ts (1)
updateLastWorkspaceSlug(58-66)backend/src/users/users.service.ts (1)
updateLastWorkspaceSlug(102-120)frontend/src/hooks/api/user.ts (1)
useUpdateLastWorkspaceSlugMutation(112-130)
frontend/src/components/common/WorkspaceRedirectHandler.tsx (2)
frontend/src/store/userSlice.ts (1)
selectUser(33-33)frontend/src/hooks/api/workspace.ts (1)
useGetWorkspaceListQuery(56-73)
🔇 Additional comments (4)
frontend/src/components/common/GuestRoute.tsx (1)
18-22: LGTM: avoids “/undefined” and keeps a clean redirect pathRedirect selection using
lastWorkspaceSlugwith a safe fallback to/workspaceis concise and prevents bad URLs. Passingstate={{ from: location }}is a nice touch.backend/src/users/users.service.ts (1)
20-21: Verification Complete –lastWorkspaceSlugIs Properly Exposed as OptionalI’ve confirmed that:
FindUserResponseextendsUserDomain, which declaresin@ApiProperty({ type: String, description: "Last worksace slug of user", required: false }) lastWorkspaceSlug?: string;backend/src/users/types/user-domain.type.ts(line 9).- The
required: falseflag on the@ApiPropertydecorator ensures the OpenAPI schema markslastWorkspaceSlugas optional.- The TypeScript definition already includes
lastWorkspaceSlug?, so there’s no risk of contract drift.No further changes are needed here—approving these code changes.
frontend/src/routes.tsx (2)
50-53: Route ordering is good.The redirect route is placed before the dynamic “:workspaceSlug” route; combined with router ranking, this ensures deterministic matching.
50-53: Route collision: static “workspace” path shadows dynamic slugI’ve confirmed in frontend/src/routes.tsx (lines 50–53) that this static route sits above both dynamic routes
:workspaceSlug(lines 55 & 70), so navigating to/workspacealways hits theWorkspaceRedirectHandler, making a real workspace whose slug is"workspace"unreachable.Fix options (pick one):
- Rename the redirect route to a non-colliding prefix (recommended): e.g.
"app/workspace". You’ll also need to update any hard-coded/workspaceredirects in:
frontend/src/components/common/GuestRoute.tsx(redirect default)- any UI links or tests pointing to
/workspace- { path: "workspace", accessType: AccessType.PRIVATE, element: <WorkspaceRedirectHandler /> }, + { path: "app/workspace", accessType: AccessType.PRIVATE, element: <WorkspaceRedirectHandler /> },- Or reserve “workspace” as a forbidden slug: add it to your backend’s slug-validation rules (e.g. in the CreateWorkspace DTO), migrate any existing collisions, and document the restriction.
Please confirm which approach you’d like, and I can prepare the corresponding route updates, GuestRoute adjustments, and any necessary test/UI changes.
| socialProvider String @map("social_provider") | ||
| socialUid String @unique @map("social_uid") | ||
| nickname String? | ||
| lastWorkspaceSlug String? @map("last_workspace_slug") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Verification agent
🧩 Analysis chain
Field addition looks right; consider canonicalization and verify slug uniqueness contract
Adding lastWorkspaceSlug String? @map("last_workspace_slug") on User matches the feature intent and aligns with the frontend usage. Two low-friction improvements:
- Store slugs in a canonical form (lowercase, trimmed) to avoid future mismatches. Do this in the service layer when persisting (and/or validate format at the DTO layer).
- Verify that
Workspace.slugis globally unique. Your routes use/:workspaceSlug, and the service checks membership by slug; if slugs aren’t unique, that contract becomes ambiguous.
Run the following to confirm uniqueness and surface the current slug constraints:
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "Snippet around Workspace.slug:"
rg -nU -C3 $'model\\s+Workspace\\b(?s).*?^\\s*slug\\s+String.*$' backend/prisma/schema.prisma
echo -e "\nCheck for unique constraints on slug:"
rg -nP 'slug\s+String.*@unique|@@unique\(\s*\[\s*slug\s*\]\s*\)' backend/prisma/schema.prisma || echo "No explicit unique constraint for slug found."Length of output: 3622
Ensure Workspace.slug is unique and slugs are stored canonically
The new lastWorkspaceSlug String? @map("last_workspace_slug") field is correct, but our verification shows that Workspace.slug currently has no unique constraint, which is required for unambiguous routing via /:workspaceSlug. Additionally, to prevent future mismatches, slugs should be stored in a canonical form (e.g. lowercase, trimmed).
• backend/prisma/schema.prisma (Workspace model)
- Add a unique constraint to
slug, for example:model Workspace { id String @id @default(auto()) @map("_id") @db.ObjectId title String - slug String + slug String @unique createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") // … } - Alternatively, you can define a model-level unique index:
@@unique([slug])
• Service/DTO layers
- Normalize slugs on input (e.g.
toLowerCase().trim()) and/or validate their format in DTOs to enforce canonical form before persisting.
🤖 Prompt for AI Agents
In backend/prisma/schema.prisma around line 18, the Workspace model currently
lacks a unique constraint on slug and slugs must be stored canonically; add a
unique constraint for slug (either mark the slug field as unique or add a
model-level @@unique([slug])) in the Workspace model, and update service/DTO
layers to normalize incoming slugs (e.g., toLowerCase().trim()) and validate
format before persisting to ensure canonical storage and unambiguous routing.
| @Patch("last-workspace-slug") | ||
| @ApiOperation({ | ||
| summary: "Update Last Accessed Workspace", | ||
| description: "Update the last accessed workspace slug for the user", | ||
| }) | ||
| @ApiBody({ | ||
| type: UpdateLastWorkspaceSlugDto, | ||
| }) | ||
| @ApiOkResponse() | ||
| async updateLastWorkspaceSlug( | ||
| @Req() req: AuthorizedRequest, | ||
| @Body() updateLastWorkspaceSlugDto: UpdateLastWorkspaceSlugDto | ||
| ): Promise<void> { | ||
| return this.usersService.updateLastWorkspaceSlug( | ||
| req.user.id, | ||
| updateLastWorkspaceSlugDto.workspaceSlug | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Return 204 No Content and document authz failures (403/404) for non-members.
- For a field update with no body response, 204 is the right status.
- Service now throws a ForbiddenException on non-membership (recommended). Reflect that in API docs.
@Patch("last-workspace-slug")
@ApiOperation({
summary: "Update Last Accessed Workspace",
description: "Update the last accessed workspace slug for the user",
})
@ApiBody({ type: UpdateLastWorkspaceSlugDto })
-@ApiOkResponse()
+@ApiOkResponse() // optionally swap to @ApiNoContentResponse() if you import it
+@HttpCode(HttpStatus.NO_CONTENT)
async updateLastWorkspaceSlug(
@Req() req: AuthorizedRequest,
@Body() updateLastWorkspaceSlugDto: UpdateLastWorkspaceSlugDto
): Promise<void> {
return this.usersService.updateLastWorkspaceSlug(
req.user.id,
updateLastWorkspaceSlugDto.workspaceSlug
);
}Optional Swagger polish (outside the hunk):
- Add
@ApiForbiddenResponse({ description: "User is not a member of the workspace" }). - Prefer
@ApiNoContentResponse()if you switch to 204 in docs too.
Also consider enhancing DTO validation to prevent empty strings and invalid slug shapes.
Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
backend/src/users/users.controller.ts lines 49-66: The endpoint currently
documents/returns a 200 with no body; change behavior and docs to reflect a 204
No Content for a successful update and document authorization failures (403) and
not-found (404) for non-members; add @HttpCode(204) (or equivalent) to the
controller method and replace @ApiOkResponse() with @ApiNoContentResponse(),
also add @ApiForbiddenResponse({ description: "User is not a member of the
workspace" }) and @ApiNotFoundResponse(...) as appropriate, and ensure the
method signature returns void (or Promise<void>) and lets the service throw
ForbiddenException for non-membership; optionally tighten DTO validation to
reject empty/invalid slugs.
backend/src/users/users.service.ts
Outdated
| async updateLastWorkspaceSlug(userId: string, workspaceSlug: string): Promise<void> { | ||
| await this.prismaService.userWorkspace.findFirstOrThrow({ | ||
| where: { | ||
| userId, | ||
| workspace: { | ||
| slug: workspaceSlug, | ||
| }, | ||
| }, | ||
| }); | ||
|
|
||
| await this.prismaService.user.update({ | ||
| where: { | ||
| id: userId, | ||
| }, | ||
| data: { | ||
| lastWorkspaceSlug: workspaceSlug, | ||
| }, | ||
| }); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Map membership failures to a 403 and perform the check+update in a transaction to avoid TOCTOU.
- Today, findFirstOrThrow will raise a Prisma error that bubbles up as 500 unless handled. For non-members, a 403 (or 404 to avoid disclosure) is more appropriate.
- Wrapping the membership check and update in a single transaction prevents a tiny race where membership could change between the two statements.
- async updateLastWorkspaceSlug(userId: string, workspaceSlug: string): Promise<void> {
- await this.prismaService.userWorkspace.findFirstOrThrow({
- where: {
- userId,
- workspace: { slug: workspaceSlug },
- },
- });
-
- await this.prismaService.user.update({
- where: { id: userId },
- data: { lastWorkspaceSlug: workspaceSlug },
- });
- }
+ async updateLastWorkspaceSlug(userId: string, workspaceSlug: string): Promise<void> {
+ try {
+ await this.prismaService.$transaction(async (tx) => {
+ await tx.userWorkspace.findFirstOrThrow({
+ where: {
+ userId,
+ workspace: { slug: workspaceSlug },
+ },
+ });
+ await tx.user.update({
+ where: { id: userId },
+ data: { lastWorkspaceSlug: workspaceSlug },
+ });
+ });
+ } catch (err) {
+ // Map "not found" on membership to a safe authz error (avoid leaking slug existence).
+ // Use Forbidden (403) or NotFound (404) depending on your policy.
+ throw new ForbiddenException("You are not a member of the specified workspace.");
+ }
+ }Additionally, add the import outside this hunk:
import { ConflictException, ForbiddenException, Injectable } from "@nestjs/common";Optionally, normalize input to avoid accidental whitespace mismatches:
- In DTO: add
@IsNotEmpty(),@Matches(/^[a-z0-9-]+$/)and@Transform(({ value }) => String(value).trim().toLowerCase())if slugs are canonicalized as lowercase.
I can patch the DTO and add tests to assert 403 behavior on non-membership. Want me to open a follow-up PR?
| useEffect(() => { | ||
| if (isLoading) return; | ||
|
|
||
| if (user?.data?.lastWorkspaceSlug) { | ||
| navigate(`/${user.data.lastWorkspaceSlug}`, { replace: true }); | ||
| return; | ||
| } | ||
|
|
||
| if (workspaceList?.pages?.[0]?.workspaces?.length) { | ||
| const firstWorkspace = workspaceList.pages[0].workspaces[0]; | ||
| navigate(`/${firstWorkspace.slug}`, { replace: true }); | ||
| return; | ||
| } | ||
| }, [user, workspaceList, navigate, isLoading]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Edge cases: stale lastWorkspaceSlug, pagination, and error handling can cause loops or dead-ends.
- If lastWorkspaceSlug points to a workspace the user no longer can access, you’ll navigate to “/:slug”, likely get a 403/404, and many apps redirect back to "/workspace", creating a loop.
- Fallback checks rely only on the first page of workspaces; if the last workspace isn’t on page 1, you may wrongly conclude it’s “not found”.
- If fetching the list errors (network), you’ll render a spinner forever.
Mitigations:
- Verify membership before redirecting to lastWorkspaceSlug (e.g., fire a lightweight GET /workspaces/:slug, and on 404/403 clear the server-side lastWorkspaceSlug and fall back).
- If the workspace list errors or is empty, navigate to a clear fallback (e.g., an onboarding or “create workspace” page), or show a message with a CTA instead of an indefinite spinner.
- Guard against redirect loops by setting a query flag or a local ref (e.g., ?fallback=1) and skipping lastWorkspaceSlug when that flag is present.
Concrete minimal improvements in this component:
- const { data: workspaceList, isLoading } = useGetWorkspaceListQuery();
+ const { data: workspaceList, isLoading, isError } = useGetWorkspaceListQuery();
@@
- useEffect(() => {
+ useEffect(() => {
if (isLoading) return;
if (user?.data?.lastWorkspaceSlug) {
navigate(`/${user.data.lastWorkspaceSlug}`, { replace: true });
return;
}
- if (workspaceList?.pages?.[0]?.workspaces?.length) {
+ if (workspaceList?.pages?.[0]?.workspaces?.length) {
const firstWorkspace = workspaceList.pages[0].workspaces[0];
navigate(`/${firstWorkspace.slug}`, { replace: true });
return;
}
- }, [user, workspaceList, navigate, isLoading]);
+ // When there is no last slug, no workspaces, or fetch failed, move to a safe fallback route.
+ if (!user?.data?.lastWorkspaceSlug && (isError || workspaceList?.pages?.[0]?.workspaces?.length === 0)) {
+ navigate("/app/workspace-empty", { replace: true }); // pick your onboarding route
+ }
+ }, [user?.data?.lastWorkspaceSlug, workspaceList, navigate, isLoading, isError]);If there’s no onboarding route yet, consider adding one, or at least return a friendly message (see next comment).
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| useEffect(() => { | |
| if (isLoading) return; | |
| if (user?.data?.lastWorkspaceSlug) { | |
| navigate(`/${user.data.lastWorkspaceSlug}`, { replace: true }); | |
| return; | |
| } | |
| if (workspaceList?.pages?.[0]?.workspaces?.length) { | |
| const firstWorkspace = workspaceList.pages[0].workspaces[0]; | |
| navigate(`/${firstWorkspace.slug}`, { replace: true }); | |
| return; | |
| } | |
| }, [user, workspaceList, navigate, isLoading]); | |
| // frontend/src/components/common/WorkspaceRedirectHandler.tsx | |
| // Add `isError` to the hook’s return values | |
| const { data: workspaceList, isLoading, isError } = useGetWorkspaceListQuery(); | |
| useEffect(() => { | |
| if (isLoading) return; | |
| if (user?.data?.lastWorkspaceSlug) { | |
| navigate(`/${user.data.lastWorkspaceSlug}`, { replace: true }); | |
| return; | |
| } | |
| if (workspaceList?.pages?.[0]?.workspaces?.length) { | |
| const firstWorkspace = workspaceList.pages[0].workspaces[0]; | |
| navigate(`/${firstWorkspace.slug}`, { replace: true }); | |
| return; | |
| } | |
| // When there is no last slug, no workspaces, or fetch failed, move to a safe fallback route. | |
| if ( | |
| !user?.data?.lastWorkspaceSlug && | |
| (isError || workspaceList?.pages?.[0]?.workspaces?.length === 0) | |
| ) { | |
| navigate("/app/workspace-empty", { replace: true }); // adjust to your onboarding route | |
| } | |
| }, [ | |
| user?.data?.lastWorkspaceSlug, | |
| workspaceList, | |
| navigate, | |
| isLoading, | |
| isError, | |
| ]); |
🤖 Prompt for AI Agents
In frontend/src/components/common/WorkspaceRedirectHandler.tsx around lines 13
to 26, the redirect logic can cause loops or dead-ends because it trusts
lastWorkspaceSlug, only checks page 1 of workspaceList, and never handles fetch
errors; change it to (1) before navigating to user.data.lastWorkspaceSlug
perform a lightweight verification GET /workspaces/:slug (or a backend endpoint)
and if you receive 403/404 clear the server-side lastWorkspaceSlug and proceed
to fallback; (2) when falling back, search the full paginated workspaceList or
call a dedicated list endpoint that returns all accessible workspaces instead of
just pages[0]; (3) if fetching workspaceList errors or returns empty, navigate
to a clear fallback route (onboarding or create-workspace) or render a friendly
CTA instead of leaving a spinner; and (4) guard against redirect loops by adding
a query flag or a local ref (e.g., ?redirectAttempt=1 or a ref) and skip trying
lastWorkspaceSlug when that flag/ref is present.
| return ( | ||
| <Backdrop open> | ||
| <CircularProgress color="inherit" /> | ||
| </Backdrop> | ||
| ); | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Avoid an always-open backdrop; show progress only while loading or redirecting.
Right now, if there’s nothing to redirect to, the user faces an indefinite spinner with no action. At a minimum, tie Backdrop.open to isLoading. Ideally, render a small empty state with a link/button to create or join a workspace.
- return (
- <Backdrop open>
- <CircularProgress color="inherit" />
- </Backdrop>
- );
+ return isLoading ? (
+ <Backdrop open>
+ <CircularProgress color="inherit" />
+ </Backdrop>
+ ) : nullIf you introduce an onboarding route (e.g., “/app/workspace-empty”), render a lightweight message here instead of null until navigation happens.
I can wire an EmptyState component and a “Create Workspace” CTA if you share the intended route or modal.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| return ( | |
| <Backdrop open> | |
| <CircularProgress color="inherit" /> | |
| </Backdrop> | |
| ); | |
| }; | |
| return isLoading ? ( | |
| <Backdrop open> | |
| <CircularProgress color="inherit" /> | |
| </Backdrop> | |
| ) : null; | |
| }; |
🤖 Prompt for AI Agents
In frontend/src/components/common/WorkspaceRedirectHandler.tsx around lines 28
to 33, the Backdrop is currently always open showing an indefinite spinner;
update the component to open the Backdrop only when isLoading is true (Backdrop
open={isLoading}) and, when not loading and there is no redirect target, render
a lightweight empty-state UI (or a link/button) instead of leaving the UI
blank—either render an inline message with a "Create/Join Workspace" CTA or
navigate/render to an onboarding route like "/app/workspace-empty" while
awaiting navigation; ensure the empty state is shown immediately when not
loading and no destination is available so users aren’t stuck with an infinite
spinner.
blurfx
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you for your work.
However, I would like to suggest a different approach to the work you have done.
Is it necessary for information about the last accessed workspace to be persistently stored in the database?
This issue is sufficiently resolvable on the client side.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
🧹 Nitpick comments (2)
frontend/src/components/layouts/DocumentLayout.tsx (1)
13-17: Consider consolidating persistence logic.Both DocumentLayout and WorkspaceLayout persist the workspace slug identically. While this ensures coverage, you could optionally consolidate this into a shared hook (e.g.,
useWorkspacePersistence) to reduce duplication.frontend/src/hooks/api/user.ts (1)
45-72: Remove unnecessary dependency from interceptor effect.The interceptor effect at line 72 now includes
userStore.data?.idin its dependency array. Since the interceptor reads the currentuserStore.data?.idfrom the closure when errors occur, adding it to deps causes the interceptor to re-register whenever the user ID changes, which is unnecessary overhead.Consider removing
userStore.data?.idfrom the dependency array at line 72. The interceptor will still have access to the current user ID through the closure, and this avoids re-creating the interceptor when the user ID changes.Alternatively, if you want to ensure the interceptor always has the latest user ID without relying on closure behavior, you could use a ref:
const userIdRef = useRef(userStore.data?.id); useEffect(() => { userIdRef.current = userStore.data?.id; }, [userStore.data?.id]);Then reference
userIdRef.currentin the interceptor and removeuserStore.data?.idfrom the interceptor effect's dependencies.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (13)
api/backend-openapi-spec.json(0 hunks)backend/src/users/types/user-domain.type.ts(0 hunks)backend/src/users/users.service.ts(1 hunks)frontend/src/components/common/GuestRoute.tsx(1 hunks)frontend/src/components/common/WorkspaceRedirectHandler.tsx(1 hunks)frontend/src/components/headers/SettingHeader.tsx(1 hunks)frontend/src/components/layouts/DocumentLayout.tsx(1 hunks)frontend/src/components/layouts/WorkspaceLayout.tsx(2 hunks)frontend/src/components/popovers/ProfilePopover.tsx(1 hunks)frontend/src/hooks/api/types/user.d.ts(0 hunks)frontend/src/hooks/api/user.ts(5 hunks)frontend/src/store/userSlice.ts(0 hunks)frontend/src/utils/lastWorkspace.ts(1 hunks)
💤 Files with no reviewable changes (4)
- frontend/src/hooks/api/types/user.d.ts
- frontend/src/store/userSlice.ts
- api/backend-openapi-spec.json
- backend/src/users/types/user-domain.type.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- frontend/src/components/common/WorkspaceRedirectHandler.tsx
🧰 Additional context used
🧬 Code graph analysis (4)
frontend/src/components/popovers/ProfilePopover.tsx (2)
frontend/src/store/userSlice.ts (1)
selectUser(32-32)frontend/src/utils/lastWorkspace.ts (1)
clearLastWorkspaceSlug(24-33)
frontend/src/components/layouts/DocumentLayout.tsx (2)
frontend/src/store/userSlice.ts (1)
selectUser(32-32)frontend/src/utils/lastWorkspace.ts (1)
setLastWorkspaceSlug(13-22)
frontend/src/components/layouts/WorkspaceLayout.tsx (2)
frontend/src/store/userSlice.ts (1)
selectUser(32-32)frontend/src/utils/lastWorkspace.ts (1)
setLastWorkspaceSlug(13-22)
frontend/src/hooks/api/user.ts (2)
frontend/src/store/userSlice.ts (1)
selectUser(32-32)frontend/src/utils/lastWorkspace.ts (1)
clearLastWorkspaceSlug(24-33)
🔇 Additional comments (10)
frontend/src/components/headers/SettingHeader.tsx (1)
22-24: LGTM!Simplifying to a fixed "/workspace" path properly delegates workspace resolution to the WorkspaceRedirectHandler, improving separation of concerns.
frontend/src/components/common/GuestRoute.tsx (1)
14-16: LGTM!The simplified redirect to "/workspace" correctly delegates resolution to WorkspaceRedirectHandler. Preserving location state and using replace prevents back-button issues.
backend/src/users/users.service.ts (1)
15-29: LGTM!Simplifying findOne to return the user record directly correctly implements the removal of lastWorkspaceSlug from the API surface.
frontend/src/components/layouts/WorkspaceLayout.tsx (1)
28-32: LGTM!The effect correctly persists the workspace slug when both user ID and slug are available. The dependencies and guard logic are appropriate.
frontend/src/hooks/api/user.ts (1)
84-93: Verify clearing last workspace slug on all query errors.Currently, any query error triggers
clearLastWorkspaceSlug, including transient network failures. Consider whether the slug should only be cleared on authentication errors (401/403) rather than all errors, to avoid losing user preferences on temporary network issues.frontend/src/components/popovers/ProfilePopover.tsx (1)
27-31: LGTM!The logout flow correctly clears the last workspace slug before clearing the user session, ensuring complete cleanup.
frontend/src/utils/lastWorkspace.ts (4)
1-1: LGTM!Simple key generation function that namespaces the workspace slug per user correctly.
3-11: LGTM!Properly guards against missing userId and handles localStorage errors gracefully by returning null.
13-22: LGTM!Correctly guards against invalid inputs and handles localStorage errors with appropriate DEV-only logging.
24-33: LGTM!Correctly guards and handles removal with appropriate error logging. The idempotent nature of removeItem ensures safe repeated calls.
86ad3f8 to
b00d4e3
Compare
|
@blurfx Thanks for the suggestion! I dropped the backend field and now record the last workspace entirely on the client via localStorage. The |
| @@ -0,0 +1,33 @@ | |||
| export const getLastWorkspaceKey = (userId: string) => `cp:last-workspace:${userId}`; | |||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| export const getLastWorkspaceKey = (userId: string) => `cp:last-workspace:${userId}`; | |
| const getLastWorkspaceKey = (userId: string) => `cp:last-workspace:${userId}`; |
getLastWorkspaceKey does not need to be exported.
| socialProvider String @map("social_provider") | ||
| socialUid String @unique @map("social_uid") | ||
| nickname String? | ||
| lastWorkspaceSlug String? @map("last_workspace_slug") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| lastWorkspaceSlug String? @map("last_workspace_slug") |
lastWorkspaceSlug still remains on schema
What this PR does / why we need it:
Implements automatic redirect to user's last accessed workspace on login. Users are now taken directly to their most recently used workspace, reducing navigation steps and improving UX.
Which issue(s) this PR fixes:
Fixes #188
Special notes for your reviewer:
lastWorkspaceSlugfield to User model (requires DB migration)lastWorkspaceSlug?: stringDoes this PR introduce a user-facing change?:
Additional documentation:
Checklist:
Summary by CodeRabbit