From f12019cf1448b13b57bfa94913b9d6ea59a07001 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:32:12 +0800 Subject: [PATCH] fix(invitation): keep login return target on join route Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../NamespaceInvitationServiceTest.java | 25 +++++++++++++++++++ web/src/app/router.test.ts | 20 +++++++++++++++ web/src/app/router.tsx | 3 ++- web/src/shared/lib/auth-route.ts | 4 +-- .../shared/lib/namespace-invitation.test.ts | 3 ++- 5 files changed, 51 insertions(+), 4 deletions(-) diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java index bfa1a670..550c2e26 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java @@ -4,6 +4,7 @@ import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -136,6 +137,30 @@ class NamespaceInvitationServiceTest { .isInstanceOf(DomainNotFoundException.class); } + @Test + void nonActiveUserCannotPreviewOrAcceptAndDoesNotConsumeInvitation() { + NamespaceInvitationService.Created created = service.create("team", "owner"); + ArgumentCaptor saved = ArgumentCaptor.forClass(NamespaceInvitation.class); + verify(invitations).save(saved.capture()); + NamespaceInvitation invitation = saved.getValue(); + when(invitations.findByTokenHash(invitation.getTokenHash())).thenReturn(Optional.of(invitation)); + when(invitations.findNamespaceIdByTokenHash(invitation.getTokenHash())).thenReturn(Optional.of(7L)); + when(invitations.findByNamespaceId(7L)).thenReturn(Optional.of(invitation)); + UserAccount pending = mock(UserAccount.class); + when(pending.getStatus()).thenReturn(UserStatus.PENDING); + when(users.findById("guest")).thenReturn(Optional.of(pending)); + when(users.findLockedById("guest")).thenReturn(Optional.of(pending)); + clearInvocations(invitations, members, audit); + + assertThatThrownBy(() -> service.preview(created.token(), "guest")) + .isInstanceOf(DomainNotFoundException.class); + assertThatThrownBy(() -> service.accept(created.token(), "guest")) + .isInstanceOf(DomainNotFoundException.class); + verify(members, never()).save(any()); + verify(invitations, never()).save(any()); + assertThat(invitation.getConsumedAt()).isNull(); + } + @Test void existingMemberCanOpenCurrentConsumedInvitationWithoutReconsuming() { NamespaceInvitationService.Created created = service.create("team", "owner"); diff --git a/web/src/app/router.test.ts b/web/src/app/router.test.ts index f8a95847..50c879cb 100644 --- a/web/src/app/router.test.ts +++ b/web/src/app/router.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { isRedirect } from '@tanstack/react-router' // The router module captures window.location.search at module load time. // We test the exported ORIGINAL_URL_SEARCH constant and the buildReturnTo @@ -51,4 +52,23 @@ describe('router', () => { const childPaths = children.map((route) => route.fullPath ?? route.path) expect(childPaths).toContain('/device') }) + + it('returns unauthenticated invitation visitors to /join even with a query and deployment base path', async () => { + const children = (router.routeTree.children ?? []) as Array<{ + fullPath?: string + options?: { beforeLoad?: (context: { location: { pathname: string; searchStr: string; hash: string } }) => Promise } + }> + const join = children.find((route) => route.fullPath === '/join') + expect(join?.options?.beforeLoad).toBeDefined() + + await expect(join!.options!.beforeLoad!({ + location: { pathname: '/skillhub/join', searchStr: '?x=1', hash: '#invitation-token' }, + })).rejects.toSatisfy((error: unknown) => { + expect(isRedirect(error)).toBe(true) + if (!isRedirect(error)) return false + expect(error.options.to).toBe('/login') + expect(error.options.search).toEqual({ returnTo: '/join' }) + return true + }) + }) }) diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 6a706d03..55e332ea 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -236,6 +236,7 @@ const rootRoute = createRootRoute({ }) const requireAuth = createRequireAuth(getCurrentUser) +const requireJoinAuth = createRequireAuth(getCurrentUser, '/join') const redirectAuthenticated = createRedirectAuthenticated(getCurrentUser) const landingRoute = createRoute({ @@ -329,7 +330,7 @@ const namespaceRoute = createRoute({ const joinRoute = createRoute({ getParentRoute: () => rootRoute, path: 'join', - beforeLoad: requireAuth, + beforeLoad: requireJoinAuth, component: JoinPage, }) diff --git a/web/src/shared/lib/auth-route.ts b/web/src/shared/lib/auth-route.ts index ada9d746..6988445f 100644 --- a/web/src/shared/lib/auth-route.ts +++ b/web/src/shared/lib/auth-route.ts @@ -53,13 +53,13 @@ export function createRedirectAuthenticated(getCurrentUser: () => Promise Promise) { +export function createRequireAuth(getCurrentUser: () => Promise, returnTo?: string) { return async function requireAuth({ location }: { location: RouteLocationLike }) { const user = await getCurrentUser() if (!user) { throw redirect({ to: '/login', - search: { returnTo: buildReturnTo(location) }, + search: { returnTo: returnTo ?? buildReturnTo(location) }, }) } return { user } diff --git a/web/src/shared/lib/namespace-invitation.test.ts b/web/src/shared/lib/namespace-invitation.test.ts index 0b389281..09aa5b05 100644 --- a/web/src/shared/lib/namespace-invitation.test.ts +++ b/web/src/shared/lib/namespace-invitation.test.ts @@ -14,7 +14,8 @@ describe('namespace invitation URL handoff', () => { window.history.replaceState(null, '', `/join?from=mail#${token}`) captureInvitationFragment() expect(storedInvitationToken()).toBe(token) - expect(window.location.href).toContain('/join?from=mail') + expect(window.location.pathname).toBe('/join') + expect(window.location.search).toBe('?from=mail') expect(window.location.hash).toBe('') })