-
Notifications
You must be signed in to change notification settings - Fork 501
StackHandler is now a client component #988
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
Merged
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
eeff554
StackHandler is now a Client Component
N2D4 e0868a6
Add more anonymous users tests
N2D4 cc6a5dd
fix StackHandler
N2D4 bc73c5b
Merge dev into client-stack-handler
N2D4 398a665
Merge dev into client-stack-handler
N2D4 6fcf292
Merge dev into client-stack-handler
N2D4 bcd122a
Merge dev into client-stack-handler
N2D4 61210c4
Apply suggestion from @vercel[bot]
N2D4 2d04839
Merge dev into client-stack-handler
N2D4 5c95e8b
fixes
N2D4 f339b65
Fix build
N2D4 3756863
fixes
N2D4 75e07c2
fix
N2D4 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| import { it } from "../helpers"; | ||
| import { createApp } from "./js-helpers"; | ||
|
|
||
| it("should list anonymous users when includeAnonymous is true", async ({ expect }) => { | ||
| const { serverApp, clientApp } = await createApp(); | ||
|
|
||
| // Create a regular user | ||
| const regularUser = await serverApp.createUser({ | ||
| primaryEmail: "regular@test.com", | ||
| password: "password", | ||
| primaryEmailAuthEnabled: true, | ||
| }); | ||
|
|
||
| // Create anonymous users | ||
| const anonymousUser1 = await clientApp.getUser({ or: "anonymous", tokenStore: { headers: new Headers() } }); | ||
| await anonymousUser1.signOut(); | ||
| const anonymousUser2 = await clientApp.getUser({ or: "anonymous", tokenStore: { headers: new Headers() } }); | ||
|
|
||
| expect(anonymousUser1.id).not.toBe(anonymousUser2.id); | ||
|
|
||
| // List users without includeAnonymous | ||
| const usersWithoutAnonymous = await serverApp.listUsers({ includeAnonymous: false, orderBy: "signedUpAt" }); | ||
| const userIdsWithoutAnonymous = usersWithoutAnonymous.map(u => u.id); | ||
| expect(userIdsWithoutAnonymous).toEqual([regularUser.id]); | ||
|
|
||
| // List users with includeAnonymous | ||
| const usersWithAnonymous = await serverApp.listUsers({ includeAnonymous: true, orderBy: "signedUpAt" }); | ||
| const userIdsWithAnonymous = usersWithAnonymous.map(u => u.id); | ||
| expect(userIdsWithAnonymous).toEqual([regularUser.id, anonymousUser1.id, anonymousUser2.id]); | ||
| }); | ||
|
|
||
| it("should default to excluding anonymous users when includeAnonymous is not specified", async ({ expect }) => { | ||
| const { serverApp, clientApp } = await createApp(); | ||
|
|
||
| // Create a regular user | ||
| await serverApp.createUser({ | ||
| primaryEmail: "regular2@test.com", | ||
| password: "password", | ||
| primaryEmailAuthEnabled: true, | ||
| }); | ||
|
|
||
| // Create an anonymous user | ||
| const anonymousUser = await clientApp.getUser({ or: "anonymous" }); | ||
|
|
||
| // List users without specifying includeAnonymous | ||
| const users = await serverApp.listUsers(); | ||
|
|
||
| // Verify anonymous user is NOT included by default | ||
| expect(users.map(u => u.id)).not.toContain(anonymousUser.id); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
286 changes: 286 additions & 0 deletions
286
packages/template/src/components-page/stack-handler-client.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,286 @@ | ||
| "use client"; | ||
|
|
||
| import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; | ||
| import { FilterUndefined, filterUndefined } from "@stackframe/stack-shared/dist/utils/objects"; | ||
| import { getRelativePart } from "@stackframe/stack-shared/dist/utils/urls"; | ||
| import { notFound, redirect, RedirectType, usePathname, useSearchParams } from 'next/navigation'; // THIS_LINE_PLATFORM next | ||
| import { useMemo } from 'react'; | ||
| import { SignIn, SignUp, StackServerApp } from ".."; | ||
| import { useStackApp } from "../lib/hooks"; | ||
| import { HandlerUrls, StackClientApp } from "../lib/stack-app"; | ||
| import { AccountSettings } from "./account-settings"; | ||
| import { CliAuthConfirmation } from "./cli-auth-confirm"; | ||
| import { EmailVerification } from "./email-verification"; | ||
| import { ErrorPage } from "./error-page"; | ||
| import { ForgotPassword } from "./forgot-password"; | ||
| import { MagicLinkCallback } from "./magic-link-callback"; | ||
| import { MFA } from "./mfa"; | ||
| import { OAuthCallback } from "./oauth-callback"; | ||
| import { PasswordReset } from "./password-reset"; | ||
| import { SignOut } from "./sign-out"; | ||
| import { TeamInvitation } from "./team-invitation"; | ||
|
|
||
| /* IF_PLATFORM react | ||
| import { MessageCard } from "../components/message-cards/message-card"; | ||
| // END_PLATFORM react */ | ||
|
|
||
| type Components = { | ||
| SignIn: typeof SignIn, | ||
| SignUp: typeof SignUp, | ||
| EmailVerification: typeof EmailVerification, | ||
| PasswordReset: typeof PasswordReset, | ||
| ForgotPassword: typeof ForgotPassword, | ||
| SignOut: typeof SignOut, | ||
| OAuthCallback: typeof OAuthCallback, | ||
| MagicLinkCallback: typeof MagicLinkCallback, | ||
| TeamInvitation: typeof TeamInvitation, | ||
| ErrorPage: typeof ErrorPage, | ||
| AccountSettings: typeof AccountSettings, | ||
| CliAuthConfirmation: typeof CliAuthConfirmation, | ||
| MFA: typeof MFA, | ||
| }; | ||
|
|
||
| type RouteProps = { | ||
| params: Promise<{ stack?: string[] }> | { stack?: string[] }, | ||
| searchParams: Promise<Record<string, string>> | Record<string, string>, | ||
| }; | ||
|
|
||
| const availablePaths = { | ||
| signIn: 'sign-in', | ||
| signUp: 'sign-up', | ||
| emailVerification: 'email-verification', | ||
| passwordReset: 'password-reset', | ||
| forgotPassword: 'forgot-password', | ||
| signOut: 'sign-out', | ||
| oauthCallback: 'oauth-callback', | ||
| magicLinkCallback: 'magic-link-callback', | ||
| teamInvitation: 'team-invitation', | ||
| accountSettings: 'account-settings', | ||
| cliAuthConfirm: 'cli-auth-confirm', | ||
| mfa: 'mfa', | ||
| error: 'error', | ||
| } as const; | ||
|
|
||
| const pathAliases = { | ||
| // also includes the uppercase and non-dashed versions | ||
| ...Object.fromEntries(Object.entries(availablePaths).map(([key, value]) => [value, value])), | ||
| "log-in": availablePaths.signIn, | ||
| "register": availablePaths.signUp, | ||
| } as const; | ||
|
|
||
| export type BaseHandlerProps = { | ||
| fullPage: boolean, | ||
| componentProps?: { | ||
| [K in keyof Components]?: Parameters<Components[K]>[0]; | ||
| }, | ||
| }; | ||
|
|
||
| function renderComponent(props: { | ||
| path: string, | ||
| searchParams: Record<string, string>, | ||
| fullPage: boolean, | ||
| componentProps?: BaseHandlerProps['componentProps'], | ||
| redirectIfNotHandler?: (name: keyof HandlerUrls) => void, | ||
| onNotFound: () => any, | ||
| app: StackClientApp<any> | StackServerApp<any>, | ||
| }) { | ||
| const { path, searchParams, fullPage, componentProps, redirectIfNotHandler, onNotFound, app } = props; | ||
|
|
||
| switch (path) { | ||
| case availablePaths.signIn: { | ||
| redirectIfNotHandler?.('signIn'); | ||
| return <SignIn | ||
| fullPage={fullPage} | ||
| automaticRedirect | ||
| {...filterUndefinedINU(componentProps?.SignIn)} | ||
| />; | ||
| } | ||
| case availablePaths.signUp: { | ||
| redirectIfNotHandler?.('signUp'); | ||
| return <SignUp | ||
| fullPage={fullPage} | ||
| automaticRedirect | ||
| {...filterUndefinedINU(componentProps?.SignUp)} | ||
| />; | ||
| } | ||
| case availablePaths.emailVerification: { | ||
| redirectIfNotHandler?.('emailVerification'); | ||
| return <EmailVerification | ||
| searchParams={searchParams} | ||
| fullPage={fullPage} | ||
| {...filterUndefinedINU(componentProps?.EmailVerification)} | ||
| />; | ||
| } | ||
| case availablePaths.passwordReset: { | ||
| redirectIfNotHandler?.('passwordReset'); | ||
| return <PasswordReset | ||
| searchParams={searchParams} | ||
| fullPage={fullPage} | ||
| {...filterUndefinedINU(componentProps?.PasswordReset)} | ||
| />; | ||
| } | ||
| case availablePaths.forgotPassword: { | ||
| redirectIfNotHandler?.('forgotPassword'); | ||
| return <ForgotPassword | ||
| fullPage={fullPage} | ||
| {...filterUndefinedINU(componentProps?.ForgotPassword)} | ||
| />; | ||
| } | ||
| case availablePaths.signOut: { | ||
| redirectIfNotHandler?.('signOut'); | ||
| return <SignOut | ||
| fullPage={fullPage} | ||
| {...filterUndefinedINU(componentProps?.SignOut)} | ||
| />; | ||
| } | ||
| case availablePaths.oauthCallback: { | ||
| redirectIfNotHandler?.('oauthCallback'); | ||
| return <OAuthCallback | ||
| fullPage={fullPage} | ||
| {...filterUndefinedINU(componentProps?.OAuthCallback)} | ||
| />; | ||
| } | ||
| case availablePaths.magicLinkCallback: { | ||
| redirectIfNotHandler?.('magicLinkCallback'); | ||
| return <MagicLinkCallback | ||
| searchParams={searchParams} | ||
| fullPage={fullPage} | ||
| {...filterUndefinedINU(componentProps?.MagicLinkCallback)} | ||
| />; | ||
| } | ||
| case availablePaths.teamInvitation: { | ||
| redirectIfNotHandler?.('teamInvitation'); | ||
| return <TeamInvitation | ||
| searchParams={searchParams} | ||
| fullPage={fullPage} | ||
| {...filterUndefinedINU(componentProps?.TeamInvitation)} | ||
| />; | ||
| } | ||
| case availablePaths.accountSettings: { | ||
| return <AccountSettings | ||
| fullPage={fullPage} | ||
| {...filterUndefinedINU(componentProps?.AccountSettings)} | ||
| />; | ||
| } | ||
| case availablePaths.error: { | ||
| return <ErrorPage | ||
| searchParams={searchParams} | ||
| fullPage={fullPage} | ||
| {...filterUndefinedINU(componentProps?.ErrorPage)} | ||
| />; | ||
| } | ||
| case availablePaths.cliAuthConfirm: { | ||
| return <CliAuthConfirmation | ||
| fullPage={fullPage} | ||
| {...filterUndefinedINU(componentProps?.CliAuthConfirmation)} | ||
| />; | ||
| } | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| case availablePaths.mfa: { | ||
| redirectIfNotHandler?.('mfa'); | ||
| return <MFA | ||
| fullPage={fullPage} | ||
| {...filterUndefinedINU(componentProps?.MFA)} | ||
| />; | ||
| } | ||
| default: { | ||
| if (Object.values(availablePaths).includes(path as any)) { | ||
| throw new StackAssertionError(`Path alias ${path} not included in switch statement, but in availablePaths?`, { availablePaths }); | ||
| } | ||
| for (const [key, value] of Object.entries(pathAliases)) { | ||
| if (path.toLowerCase().replaceAll('-', '') === key.toLowerCase().replaceAll('-', '')) { | ||
| const redirectUrl = `${app.urls.handler}/${value}?${new URLSearchParams(searchParams).toString()}`; | ||
| return { redirect: redirectUrl }; | ||
| } | ||
| } | ||
| return onNotFound(); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| export function StackHandlerClient(props: BaseHandlerProps & Partial<RouteProps> & { location?: string }) { | ||
| // Use hooks to get app | ||
| const stackApp = useStackApp(); | ||
|
|
||
| // IF_PLATFORM next | ||
| const pathname = usePathname(); | ||
| const searchParamsFromHook = useSearchParams(); | ||
| const currentLocation = pathname; | ||
| const searchParamsSource = searchParamsFromHook; | ||
| /* ELSE_IF_PLATFORM react | ||
| const currentLocation = props.location ?? window.location.pathname; | ||
| const searchParamsSource = new URLSearchParams(window.location.search); | ||
| END_PLATFORM */ | ||
|
|
||
| const { path, searchParams } = useMemo(() => { | ||
| const handlerPath = new URL(stackApp.urls.handler, 'http://example.com').pathname; | ||
| const relativePath = currentLocation.startsWith(handlerPath) | ||
| ? currentLocation.slice(handlerPath.length).replace(/^\/+/, '') | ||
| : currentLocation.replace(/^\/+/, ''); | ||
|
|
||
| return { | ||
| path: relativePath, | ||
| searchParams: Object.fromEntries(searchParamsSource.entries()) | ||
| }; | ||
| }, [currentLocation, searchParamsSource, stackApp.urls.handler]); | ||
|
|
||
| const redirectIfNotHandler = (name: keyof HandlerUrls) => { | ||
| const url = stackApp.urls[name]; | ||
| const handlerUrl = stackApp.urls.handler; | ||
|
|
||
| if (url !== handlerUrl && url.startsWith(handlerUrl + "/")) { | ||
| return; | ||
| } | ||
|
|
||
| const urlObj = new URL(url, 'http://example.com'); | ||
| for (const [key, value] of Object.entries(searchParams)) { | ||
| urlObj.searchParams.set(key, value); | ||
| } | ||
|
|
||
| // IF_PLATFORM next | ||
| redirect(getRelativePart(urlObj), RedirectType.replace); | ||
| /* ELSE_IF_PLATFORM react | ||
| window.location.href = getRelativePart(urlObj); | ||
| END_PLATFORM */ | ||
| }; | ||
|
|
||
| const result = renderComponent({ | ||
| path, | ||
| searchParams, | ||
| fullPage: props.fullPage, | ||
| componentProps: props.componentProps, | ||
| redirectIfNotHandler, | ||
| onNotFound: () => | ||
| // IF_PLATFORM next | ||
| notFound() | ||
| /* ELSE_IF_PLATFORM react | ||
| ( | ||
| <MessageCard | ||
| title="Page does not exist" | ||
| fullPage={props.fullPage} | ||
| primaryButtonText="Go to Home" | ||
| primaryAction={() => stackApp.redirectToHome()} | ||
| > | ||
| The page you are looking for could not be found. Please check the URL and try again. | ||
| </MessageCard> | ||
| ) | ||
| END_PLATFORM */ | ||
N2D4 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| , | ||
| app: stackApp, | ||
| }); | ||
|
|
||
| if (result && 'redirect' in result) { | ||
| // IF_PLATFORM next | ||
| redirect(result.redirect, RedirectType.replace); | ||
| /* ELSE_IF_PLATFORM react | ||
| window.location.href = result.redirect; | ||
| return null; | ||
| END_PLATFORM */ | ||
| } | ||
|
|
||
| return result; | ||
| } | ||
|
|
||
| // filter undefined values in object. if object itself is undefined, return undefined | ||
| function filterUndefinedINU<T extends {}>(value: T | undefined): FilterUndefined<T> | undefined { | ||
| return value === undefined ? value : filterUndefined(value); | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.