Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/backend/.env.development
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ STACK_SEED_INTERNAL_PROJECT_SIGN_UP_ENABLED=true
STACK_SEED_INTERNAL_PROJECT_OTP_ENABLED=true
STACK_SEED_INTERNAL_PROJECT_ALLOW_LOCALHOST=true
STACK_SEED_INTERNAL_PROJECT_OAUTH_PROVIDERS=github,spotify,google,microsoft
STACK_SEED_INTERNAL_PROJECT_USER_GITHUB_ID=admin@example.com
STACK_SEED_INTERNAL_PROJECT_USER_INTERNAL_ACCESS=true
STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY=this-publishable-client-key-is-for-local-development-only
STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY=this-secret-server-key-is-for-local-development-only
Expand Down
50 changes: 50 additions & 0 deletions apps/e2e/tests/js/list-users.test.ts
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);
});
5 changes: 2 additions & 3 deletions packages/init-stack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import * as os from 'os';
import * as path from "path";
import { PostHog } from 'posthog-node';
import packageJson from '../package.json';
import { invokeCallback } from "./telegram";
import { scheduleMcpConfiguration } from "./mcp";
import { invokeCallback } from "./telegram";
import { Colorize, configureVerboseLogging, logVerbose, templateIdentity } from "./util";

export { templateIdentity } from "./util";
Expand Down Expand Up @@ -981,8 +981,7 @@ ${shouldInheritFromClient ? `${indentation}inheritsFrom: stackClientApp,` : `${i
}
laterWriteFileIfNotExists(
handlerPath,
`import { StackHandler } from "@stackframe/stack"; \nimport { stackServerApp } from "../../../stack/server"; \n\nexport default function Handler(props${handlerFileExtension.includes("ts") ? ": unknown" : ""
}) { \n${projectInfo.indentation} return <StackHandler fullPage app = { stackServerApp } routeProps = { props } />; \n } \n`
`import { StackHandler } from "@stackframe/stack";\n\nexport default function Handler() {\n${projectInfo.indentation}return <StackHandler fullPage />;\n}\n`
);
},

Expand Down
286 changes: 286 additions & 0 deletions packages/template/src/components-page/stack-handler-client.tsx
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)}
/>;
}
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 */
,
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);
}
Loading