diff --git a/e2e/solid-start/basic-auth/.env b/e2e/solid-start/basic-auth/.env new file mode 100644 index 00000000000..c498ab59bf1 --- /dev/null +++ b/e2e/solid-start/basic-auth/.env @@ -0,0 +1,7 @@ +# Environment variables declared in this file are automatically made available to Prisma. +# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema + +# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB. +# See the documentation for all the connection string options: https://pris.ly/d/connection-strings + +DATABASE_URL="file:./dev.db" \ No newline at end of file diff --git a/e2e/solid-start/basic-auth/.gitignore b/e2e/solid-start/basic-auth/.gitignore new file mode 100644 index 00000000000..75a469e80c0 --- /dev/null +++ b/e2e/solid-start/basic-auth/.gitignore @@ -0,0 +1,20 @@ +node_modules +package-lock.json +yarn.lock + +!.env +.DS_Store +.cache +.vercel +.output + +/build/ +/api/ +/server/build +/public/build +# Sentry Config File +.env.sentry-build-plugin +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/e2e/solid-start/basic-auth/.prettierignore b/e2e/solid-start/basic-auth/.prettierignore new file mode 100644 index 00000000000..2be5eaa6ece --- /dev/null +++ b/e2e/solid-start/basic-auth/.prettierignore @@ -0,0 +1,4 @@ +**/build +**/public +pnpm-lock.yaml +routeTree.gen.ts \ No newline at end of file diff --git a/e2e/solid-start/basic-auth/package.json b/e2e/solid-start/basic-auth/package.json new file mode 100644 index 00000000000..07e9410205c --- /dev/null +++ b/e2e/solid-start/basic-auth/package.json @@ -0,0 +1,36 @@ +{ + "name": "tanstack-solid-start-e2e-basic-auth", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "vite dev --port 3000", + "dev:e2e": "vite dev", + "build": "vite build", + "start": "node .output/server/index.mjs", + "prisma-generate": "prisma generate", + "test:e2e": "exit 0; rm -rf port*.txt; pnpm run prisma-generate && playwright test --project=chromium" + }, + "dependencies": { + "@prisma/client": "5.22.0", + "@tanstack/solid-router": "workspace:^", + "@tanstack/solid-router-devtools": "workspace:^", + "@tanstack/solid-start": "workspace:^", + "prisma": "^5.22.0", + "solid-js": "^1.9.5", + "redaxios": "^0.5.1", + "tailwind-merge": "^2.6.0", + "vite": "^7.1.7" + }, + "devDependencies": { + "@playwright/test": "^1.50.1", + "@tanstack/router-e2e-utils": "workspace:^", + "@types/node": "^22.10.2", + "vite-plugin-solid": "^2.11.9", + "autoprefixer": "^10.4.20", + "postcss": "^8.5.1", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2", + "vite-tsconfig-paths": "^5.1.4" + } +} diff --git a/e2e/solid-start/basic-auth/playwright.config.ts b/e2e/solid-start/basic-auth/playwright.config.ts new file mode 100644 index 00000000000..18b8905468f --- /dev/null +++ b/e2e/solid-start/basic-auth/playwright.config.ts @@ -0,0 +1,34 @@ +import { defineConfig, devices } from '@playwright/test' +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import packageJson from './package.json' with { type: 'json' } + +const PORT = await getTestServerPort(packageJson.name) +const baseURL = `http://localhost:${PORT}` +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + workers: 1, + + reporter: [['line']], + + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL, + }, + + webServer: { + command: `VITE_SERVER_PORT=${PORT} pnpm build && PORT=${PORT} VITE_SERVER_PORT=${PORT} pnpm start`, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/solid-start/basic-auth/postcss.config.mjs b/e2e/solid-start/basic-auth/postcss.config.mjs new file mode 100644 index 00000000000..2e7af2b7f1a --- /dev/null +++ b/e2e/solid-start/basic-auth/postcss.config.mjs @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/e2e/solid-start/basic-auth/prisma/dev.db b/e2e/solid-start/basic-auth/prisma/dev.db new file mode 100644 index 00000000000..5f4ab51a029 Binary files /dev/null and b/e2e/solid-start/basic-auth/prisma/dev.db differ diff --git a/e2e/solid-start/basic-auth/prisma/migrations/20240811183753_init/migration.sql b/e2e/solid-start/basic-auth/prisma/migrations/20240811183753_init/migration.sql new file mode 100644 index 00000000000..4512a8f7823 --- /dev/null +++ b/e2e/solid-start/basic-auth/prisma/migrations/20240811183753_init/migration.sql @@ -0,0 +1,8 @@ +-- CreateTable +CREATE TABLE "User" ( + "email" TEXT NOT NULL PRIMARY KEY, + "password" TEXT NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); diff --git a/e2e/solid-start/basic-auth/prisma/migrations/migration_lock.toml b/e2e/solid-start/basic-auth/prisma/migrations/migration_lock.toml new file mode 100644 index 00000000000..e5e5c4705ab --- /dev/null +++ b/e2e/solid-start/basic-auth/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "sqlite" \ No newline at end of file diff --git a/e2e/solid-start/basic-auth/prisma/schema.prisma b/e2e/solid-start/basic-auth/prisma/schema.prisma new file mode 100644 index 00000000000..3544834310f --- /dev/null +++ b/e2e/solid-start/basic-auth/prisma/schema.prisma @@ -0,0 +1,16 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +model User { + email String @id @unique + password String +} \ No newline at end of file diff --git a/e2e/solid-start/basic-auth/public/android-chrome-192x192.png b/e2e/solid-start/basic-auth/public/android-chrome-192x192.png new file mode 100644 index 00000000000..09c8324f8c6 Binary files /dev/null and b/e2e/solid-start/basic-auth/public/android-chrome-192x192.png differ diff --git a/e2e/solid-start/basic-auth/public/android-chrome-512x512.png b/e2e/solid-start/basic-auth/public/android-chrome-512x512.png new file mode 100644 index 00000000000..11d626ea3d0 Binary files /dev/null and b/e2e/solid-start/basic-auth/public/android-chrome-512x512.png differ diff --git a/e2e/solid-start/basic-auth/public/apple-touch-icon.png b/e2e/solid-start/basic-auth/public/apple-touch-icon.png new file mode 100644 index 00000000000..5a9423cc02c Binary files /dev/null and b/e2e/solid-start/basic-auth/public/apple-touch-icon.png differ diff --git a/e2e/solid-start/basic-auth/public/favicon-16x16.png b/e2e/solid-start/basic-auth/public/favicon-16x16.png new file mode 100644 index 00000000000..e3389b00443 Binary files /dev/null and b/e2e/solid-start/basic-auth/public/favicon-16x16.png differ diff --git a/e2e/solid-start/basic-auth/public/favicon-32x32.png b/e2e/solid-start/basic-auth/public/favicon-32x32.png new file mode 100644 index 00000000000..900c77d444c Binary files /dev/null and b/e2e/solid-start/basic-auth/public/favicon-32x32.png differ diff --git a/e2e/solid-start/basic-auth/public/favicon.ico b/e2e/solid-start/basic-auth/public/favicon.ico new file mode 100644 index 00000000000..1a1751676f7 Binary files /dev/null and b/e2e/solid-start/basic-auth/public/favicon.ico differ diff --git a/e2e/solid-start/basic-auth/public/favicon.png b/e2e/solid-start/basic-auth/public/favicon.png new file mode 100644 index 00000000000..1e77bc06091 Binary files /dev/null and b/e2e/solid-start/basic-auth/public/favicon.png differ diff --git a/e2e/solid-start/basic-auth/public/site.webmanifest b/e2e/solid-start/basic-auth/public/site.webmanifest new file mode 100644 index 00000000000..fa99de77db6 --- /dev/null +++ b/e2e/solid-start/basic-auth/public/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "", + "short_name": "", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/e2e/solid-start/basic-auth/src/components/Auth.tsx b/e2e/solid-start/basic-auth/src/components/Auth.tsx new file mode 100644 index 00000000000..40e44b8c5a4 --- /dev/null +++ b/e2e/solid-start/basic-auth/src/components/Auth.tsx @@ -0,0 +1,59 @@ +import type { JSX } from 'solid-js' + +export function Auth({ + actionText, + onSubmit, + status, + afterSubmit, +}: { + actionText: string + onSubmit: (e: HTMLFormElement) => void + status: 'pending' | 'idle' | 'success' | 'error' + afterSubmit?: JSX.Element +}) { + return ( +
+
+

{actionText}

+
{ + e.preventDefault() + onSubmit(e) + }} + class="space-y-4" + > +
+ + +
+
+ + +
+ + {afterSubmit ? afterSubmit : null} +
+
+
+ ) +} diff --git a/e2e/solid-start/basic-auth/src/components/DefaultCatchBoundary.tsx b/e2e/solid-start/basic-auth/src/components/DefaultCatchBoundary.tsx new file mode 100644 index 00000000000..32aed20e675 --- /dev/null +++ b/e2e/solid-start/basic-auth/src/components/DefaultCatchBoundary.tsx @@ -0,0 +1,53 @@ +import { + ErrorComponent, + Link, + rootRouteId, + useMatch, + useRouter, +} from '@tanstack/solid-router' +import type { ErrorComponentProps } from '@tanstack/solid-router' + +export function DefaultCatchBoundary({ error }: ErrorComponentProps) { + const router = useRouter() + const isRoot = useMatch({ + strict: false, + select: (state) => state.id === rootRouteId, + }) + + console.error(error) + + return ( +
+ +
+ + {isRoot() ? ( + + Home + + ) : ( + { + e.preventDefault() + window.history.back() + }} + > + Go Back + + )} +
+
+ ) +} diff --git a/e2e/solid-start/basic-auth/src/components/Login.tsx b/e2e/solid-start/basic-auth/src/components/Login.tsx new file mode 100644 index 00000000000..328149bf318 --- /dev/null +++ b/e2e/solid-start/basic-auth/src/components/Login.tsx @@ -0,0 +1,71 @@ +import { useRouter } from '@tanstack/solid-router' +import { useServerFn } from '@tanstack/solid-start' +import { useMutation } from '../hooks/useMutation' +import { loginFn } from '../routes/_authed' +import { Auth } from './Auth' +import { signupFn } from '~/routes/signup' + +export function Login() { + const router = useRouter() + + const loginMutation = useMutation({ + fn: loginFn, + onSuccess: async (ctx) => { + if (!ctx.data?.error) { + await router.invalidate() + router.navigate({ to: '/' }) + return + } + }, + }) + + const signupMutation = useMutation({ + fn: useServerFn(signupFn), + }) + + return ( + { + const formData = new FormData(e.target as unknown as HTMLFormElement) + + loginMutation.mutate({ + data: { + email: formData.get('email') as string, + password: formData.get('password') as string, + }, + }) + }} + afterSubmit={ + loginMutation.data() ? ( + <> +
{loginMutation.data()?.message}
+ {loginMutation.data()?.userNotFound ? ( +
+ +
+ ) : null} + + ) : null + } + /> + ) +} diff --git a/e2e/solid-start/basic-auth/src/components/NotFound.tsx b/e2e/solid-start/basic-auth/src/components/NotFound.tsx new file mode 100644 index 00000000000..ca4c1960fa1 --- /dev/null +++ b/e2e/solid-start/basic-auth/src/components/NotFound.tsx @@ -0,0 +1,25 @@ +import { Link } from '@tanstack/solid-router' + +export function NotFound({ children }: { children?: any }) { + return ( +
+
+ {children ||

The page you are looking for does not exist.

} +
+

+ + + Start Over + +

+
+ ) +} diff --git a/e2e/solid-start/basic-auth/src/hooks/useMutation.ts b/e2e/solid-start/basic-auth/src/hooks/useMutation.ts new file mode 100644 index 00000000000..102ecf87fc8 --- /dev/null +++ b/e2e/solid-start/basic-auth/src/hooks/useMutation.ts @@ -0,0 +1,41 @@ +import * as Solid from 'solid-js' + +export function useMutation(opts: { + fn: (variables: TVariables) => Promise + onSuccess?: (ctx: { data: TData }) => void | Promise +}) { + const [submittedAt, setSubmittedAt] = Solid.createSignal() + const [variables, setVariables] = Solid.createSignal() + const [error, setError] = Solid.createSignal() + const [data, setData] = Solid.createSignal() + const [status, setStatus] = Solid.createSignal< + 'idle' | 'pending' | 'success' | 'error' + >('idle') + + const mutate = async (variables: TVariables): Promise => { + setStatus('pending') + setSubmittedAt(Date.now()) + setVariables(variables as any) + // + try { + const data = await opts.fn(variables) + await opts.onSuccess?.({ data }) + setStatus('success') + setError(undefined) + setData(data as any) + return data + } catch (err: any) { + setStatus('error') + setError(err) + } + } + + return { + status, + variables, + submittedAt, + mutate, + error, + data, + } +} diff --git a/e2e/solid-start/basic-auth/src/routeTree.gen.ts b/e2e/solid-start/basic-auth/src/routeTree.gen.ts new file mode 100644 index 00000000000..86521aa7894 --- /dev/null +++ b/e2e/solid-start/basic-auth/src/routeTree.gen.ts @@ -0,0 +1,225 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as SignupRouteImport } from './routes/signup' +import { Route as LogoutRouteImport } from './routes/logout' +import { Route as LoginRouteImport } from './routes/login' +import { Route as AuthedRouteImport } from './routes/_authed' +import { Route as IndexRouteImport } from './routes/index' +import { Route as AuthedPostsRouteImport } from './routes/_authed/posts' +import { Route as AuthedPostsIndexRouteImport } from './routes/_authed/posts.index' +import { Route as AuthedPostsPostIdRouteImport } from './routes/_authed/posts.$postId' + +const SignupRoute = SignupRouteImport.update({ + id: '/signup', + path: '/signup', + getParentRoute: () => rootRouteImport, +} as any) +const LogoutRoute = LogoutRouteImport.update({ + id: '/logout', + path: '/logout', + getParentRoute: () => rootRouteImport, +} as any) +const LoginRoute = LoginRouteImport.update({ + id: '/login', + path: '/login', + getParentRoute: () => rootRouteImport, +} as any) +const AuthedRoute = AuthedRouteImport.update({ + id: '/_authed', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const AuthedPostsRoute = AuthedPostsRouteImport.update({ + id: '/posts', + path: '/posts', + getParentRoute: () => AuthedRoute, +} as any) +const AuthedPostsIndexRoute = AuthedPostsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AuthedPostsRoute, +} as any) +const AuthedPostsPostIdRoute = AuthedPostsPostIdRouteImport.update({ + id: '/$postId', + path: '/$postId', + getParentRoute: () => AuthedPostsRoute, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/login': typeof LoginRoute + '/logout': typeof LogoutRoute + '/signup': typeof SignupRoute + '/posts': typeof AuthedPostsRouteWithChildren + '/posts/$postId': typeof AuthedPostsPostIdRoute + '/posts/': typeof AuthedPostsIndexRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/login': typeof LoginRoute + '/logout': typeof LogoutRoute + '/signup': typeof SignupRoute + '/posts/$postId': typeof AuthedPostsPostIdRoute + '/posts': typeof AuthedPostsIndexRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/_authed': typeof AuthedRouteWithChildren + '/login': typeof LoginRoute + '/logout': typeof LogoutRoute + '/signup': typeof SignupRoute + '/_authed/posts': typeof AuthedPostsRouteWithChildren + '/_authed/posts/$postId': typeof AuthedPostsPostIdRoute + '/_authed/posts/': typeof AuthedPostsIndexRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/login' + | '/logout' + | '/signup' + | '/posts' + | '/posts/$postId' + | '/posts/' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/login' | '/logout' | '/signup' | '/posts/$postId' | '/posts' + id: + | '__root__' + | '/' + | '/_authed' + | '/login' + | '/logout' + | '/signup' + | '/_authed/posts' + | '/_authed/posts/$postId' + | '/_authed/posts/' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + AuthedRoute: typeof AuthedRouteWithChildren + LoginRoute: typeof LoginRoute + LogoutRoute: typeof LogoutRoute + SignupRoute: typeof SignupRoute +} + +declare module '@tanstack/solid-router' { + interface FileRoutesByPath { + '/signup': { + id: '/signup' + path: '/signup' + fullPath: '/signup' + preLoaderRoute: typeof SignupRouteImport + parentRoute: typeof rootRouteImport + } + '/logout': { + id: '/logout' + path: '/logout' + fullPath: '/logout' + preLoaderRoute: typeof LogoutRouteImport + parentRoute: typeof rootRouteImport + } + '/login': { + id: '/login' + path: '/login' + fullPath: '/login' + preLoaderRoute: typeof LoginRouteImport + parentRoute: typeof rootRouteImport + } + '/_authed': { + id: '/_authed' + path: '' + fullPath: '' + preLoaderRoute: typeof AuthedRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/_authed/posts': { + id: '/_authed/posts' + path: '/posts' + fullPath: '/posts' + preLoaderRoute: typeof AuthedPostsRouteImport + parentRoute: typeof AuthedRoute + } + '/_authed/posts/': { + id: '/_authed/posts/' + path: '/' + fullPath: '/posts/' + preLoaderRoute: typeof AuthedPostsIndexRouteImport + parentRoute: typeof AuthedPostsRoute + } + '/_authed/posts/$postId': { + id: '/_authed/posts/$postId' + path: '/$postId' + fullPath: '/posts/$postId' + preLoaderRoute: typeof AuthedPostsPostIdRouteImport + parentRoute: typeof AuthedPostsRoute + } + } +} + +interface AuthedPostsRouteChildren { + AuthedPostsPostIdRoute: typeof AuthedPostsPostIdRoute + AuthedPostsIndexRoute: typeof AuthedPostsIndexRoute +} + +const AuthedPostsRouteChildren: AuthedPostsRouteChildren = { + AuthedPostsPostIdRoute: AuthedPostsPostIdRoute, + AuthedPostsIndexRoute: AuthedPostsIndexRoute, +} + +const AuthedPostsRouteWithChildren = AuthedPostsRoute._addFileChildren( + AuthedPostsRouteChildren, +) + +interface AuthedRouteChildren { + AuthedPostsRoute: typeof AuthedPostsRouteWithChildren +} + +const AuthedRouteChildren: AuthedRouteChildren = { + AuthedPostsRoute: AuthedPostsRouteWithChildren, +} + +const AuthedRouteWithChildren = + AuthedRoute._addFileChildren(AuthedRouteChildren) + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + AuthedRoute: AuthedRouteWithChildren, + LoginRoute: LoginRoute, + LogoutRoute: LogoutRoute, + SignupRoute: SignupRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/solid-start' +declare module '@tanstack/solid-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/e2e/solid-start/basic-auth/src/router.tsx b/e2e/solid-start/basic-auth/src/router.tsx new file mode 100644 index 00000000000..6b397aa78d6 --- /dev/null +++ b/e2e/solid-start/basic-auth/src/router.tsx @@ -0,0 +1,16 @@ +import { createRouter } from '@tanstack/solid-router' +import { routeTree } from './routeTree.gen' +import { DefaultCatchBoundary } from './components/DefaultCatchBoundary' +import { NotFound } from './components/NotFound' + +export function getRouter() { + const router = createRouter({ + routeTree, + scrollRestoration: true, + defaultPreload: 'intent', + defaultErrorComponent: DefaultCatchBoundary, + defaultNotFoundComponent: () => , + }) + + return router +} diff --git a/e2e/solid-start/basic-auth/src/routes/__root.tsx b/e2e/solid-start/basic-auth/src/routes/__root.tsx new file mode 100644 index 00000000000..3a3392e9223 --- /dev/null +++ b/e2e/solid-start/basic-auth/src/routes/__root.tsx @@ -0,0 +1,144 @@ +/// +import { + HeadContent, + Link, + Outlet, + Scripts, + createRootRoute, +} from '@tanstack/solid-router' +import { TanStackRouterDevtools } from '@tanstack/solid-router-devtools' +import { createServerFn } from '@tanstack/solid-start' + +import type * as Solid from 'solid-js' +import { HydrationScript } from 'solid-js/web' +import { DefaultCatchBoundary } from '~/components/DefaultCatchBoundary.js' +import { NotFound } from '~/components/NotFound.js' +import appCss from '~/styles/app.css?url' +import { seo } from '~/utils/seo.js' +import { useAppSession } from '~/utils/session.js' + +const fetchUser = createServerFn({ method: 'GET' }).handler(async () => { + // We need to auth on the server so we have access to secure cookies + const session = await useAppSession() + + if (!session.data.userEmail) { + return null + } + + return { + email: session.data.userEmail, + } +}) + +export const Route = createRootRoute({ + beforeLoad: async () => { + const user = await fetchUser() + + return { + user, + } + }, + head: () => ({ + meta: [ + { + charset: 'utf-8', + }, + { + name: 'viewport', + content: 'width=device-width, initial-scale=1', + }, + ...seo({ + title: + 'TanStack Start | Type-Safe, Client-First, Full-Stack React Framework', + description: `TanStack Start is a type-safe, client-first, full-stack React framework. `, + }), + ], + links: [ + { rel: 'stylesheet', href: appCss }, + { + rel: 'apple-touch-icon', + sizes: '180x180', + href: '/apple-touch-icon.png', + }, + { + rel: 'icon', + type: 'image/png', + sizes: '32x32', + href: '/favicon-32x32.png', + }, + { + rel: 'icon', + type: 'image/png', + sizes: '16x16', + href: '/favicon-16x16.png', + }, + { rel: 'manifest', href: '/site.webmanifest', color: '#fffff' }, + { rel: 'icon', href: '/favicon.ico' }, + ], + }), + errorComponent: (props) => { + return ( + + + + ) + }, + notFoundComponent: () => , + component: RootComponent, +}) + +function RootComponent() { + return ( + + + + ) +} + +function RootDocument({ children }: { children: Solid.JSX.Element }) { + const routeContext = Route.useRouteContext() + + return ( + + + + + + +
+ + Home + {' '} + + Posts + +
+ {routeContext().user ? ( + <> + {routeContext().user?.email} + Logout + + ) : ( + Login + )} +
+
+
+ {children} + + + + + ) +} diff --git a/e2e/solid-start/basic-auth/src/routes/_authed.tsx b/e2e/solid-start/basic-auth/src/routes/_authed.tsx new file mode 100644 index 00000000000..612d8cfec77 --- /dev/null +++ b/e2e/solid-start/basic-auth/src/routes/_authed.tsx @@ -0,0 +1,61 @@ +import { createFileRoute } from '@tanstack/solid-router' +import { createServerFn } from '@tanstack/solid-start' + +import { hashPassword, prismaClient } from '~/utils/prisma' +import { Login } from '~/components/Login' +import { useAppSession } from '~/utils/session' + +export const loginFn = createServerFn({ + method: 'POST', +}) + .inputValidator((payload: { email: string; password: string }) => payload) + .handler(async ({ data }) => { + // Find the user + const user = await prismaClient.user.findUnique({ + where: { + email: data.email, + }, + }) + + // Check if the user exists + if (!user) { + return { + error: true, + userNotFound: true, + message: 'User not found', + } + } + + // Check if the password is correct + const hashedPassword = await hashPassword(data.password) + + if (user.password !== hashedPassword) { + return { + error: true, + message: 'Incorrect password', + } + } + + // Create a session + const session = await useAppSession() + + // Store the user's email in the session + await session.update({ + userEmail: user.email, + }) + }) + +export const Route = createFileRoute('/_authed')({ + beforeLoad: ({ context }) => { + if (!context.user) { + throw new Error('Not authenticated') + } + }, + errorComponent: ({ error }) => { + if (error.message === 'Not authenticated') { + return + } + + throw error + }, +}) diff --git a/e2e/solid-start/basic-auth/src/routes/_authed/posts.$postId.tsx b/e2e/solid-start/basic-auth/src/routes/_authed/posts.$postId.tsx new file mode 100644 index 00000000000..602dca9c090 --- /dev/null +++ b/e2e/solid-start/basic-auth/src/routes/_authed/posts.$postId.tsx @@ -0,0 +1,29 @@ +import { ErrorComponent, createFileRoute } from '@tanstack/solid-router' +import type { ErrorComponentProps } from '@tanstack/solid-router' + +import { NotFound } from '~/components/NotFound.js' +import { fetchPost } from '~/utils/posts.js' + +export function PostErrorComponent({ error }: ErrorComponentProps) { + return +} + +export const Route = createFileRoute('/_authed/posts/$postId')({ + loader: ({ params: { postId } }) => fetchPost({ data: postId }), + errorComponent: PostErrorComponent, + component: PostComponent, + notFoundComponent: () => { + return Post not found + }, +}) + +function PostComponent() { + const post = Route.useLoaderData() + + return ( +
+

{post().title}

+
{post().body}
+
+ ) +} diff --git a/e2e/solid-start/basic-auth/src/routes/_authed/posts.index.tsx b/e2e/solid-start/basic-auth/src/routes/_authed/posts.index.tsx new file mode 100644 index 00000000000..56ac79f2aef --- /dev/null +++ b/e2e/solid-start/basic-auth/src/routes/_authed/posts.index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/solid-router' + +function PostsIndexComponent() { + return
Select a post.
+} + +export const Route = createFileRoute('/_authed/posts/')({ + component: PostsIndexComponent, +}) diff --git a/e2e/solid-start/basic-auth/src/routes/_authed/posts.tsx b/e2e/solid-start/basic-auth/src/routes/_authed/posts.tsx new file mode 100644 index 00000000000..adc52137daf --- /dev/null +++ b/e2e/solid-start/basic-auth/src/routes/_authed/posts.tsx @@ -0,0 +1,39 @@ +import { Link, Outlet, createFileRoute } from '@tanstack/solid-router' + +import { fetchPosts } from '~/utils/posts.js' + +function PostsComponent() { + const posts = Route.useLoaderData() + + return ( +
+
    + {[...posts(), { id: 'i-do-not-exist', title: 'Non-existent Post' }].map( + (post) => { + return ( +
  • + +
    {post.title.substring(0, 20)}
    + +
  • + ) + }, + )} +
+
+ +
+ ) +} + +export const Route = createFileRoute('/_authed/posts')({ + loader: () => fetchPosts(), + component: PostsComponent, +}) diff --git a/e2e/solid-start/basic-auth/src/routes/index.tsx b/e2e/solid-start/basic-auth/src/routes/index.tsx new file mode 100644 index 00000000000..a128aeca0e1 --- /dev/null +++ b/e2e/solid-start/basic-auth/src/routes/index.tsx @@ -0,0 +1,13 @@ +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/')({ + component: Home, +}) + +function Home() { + return ( +
+

Welcome Home!!!

+
+ ) +} diff --git a/e2e/solid-start/basic-auth/src/routes/login.tsx b/e2e/solid-start/basic-auth/src/routes/login.tsx new file mode 100644 index 00000000000..90a75e6867c --- /dev/null +++ b/e2e/solid-start/basic-auth/src/routes/login.tsx @@ -0,0 +1,10 @@ +import { createFileRoute } from '@tanstack/solid-router' +import { Login } from '~/components/Login' + +export const Route = createFileRoute('/login')({ + component: LoginComp, +}) + +function LoginComp() { + return +} diff --git a/e2e/solid-start/basic-auth/src/routes/logout.tsx b/e2e/solid-start/basic-auth/src/routes/logout.tsx new file mode 100644 index 00000000000..85b1670a5f0 --- /dev/null +++ b/e2e/solid-start/basic-auth/src/routes/logout.tsx @@ -0,0 +1,19 @@ +import { redirect, createFileRoute } from '@tanstack/solid-router' +import { createServerFn } from '@tanstack/solid-start' + +import { useAppSession } from '~/utils/session' + +const logoutFn = createServerFn({ method: 'POST' }).handler(async () => { + const session = await useAppSession() + + session.clear() + + throw redirect({ + href: '/', + }) +}) + +export const Route = createFileRoute('/logout')({ + preload: false, + loader: () => logoutFn(), +}) diff --git a/e2e/solid-start/basic-auth/src/routes/signup.tsx b/e2e/solid-start/basic-auth/src/routes/signup.tsx new file mode 100644 index 00000000000..48ee2981f5e --- /dev/null +++ b/e2e/solid-start/basic-auth/src/routes/signup.tsx @@ -0,0 +1,100 @@ +import { createFileRoute, redirect } from '@tanstack/solid-router' +import { createServerFn, useServerFn } from '@tanstack/solid-start' + +import { hashPassword, prismaClient } from '~/utils/prisma' +import { useMutation } from '~/hooks/useMutation' +import { Auth } from '~/components/Auth' +import { useAppSession } from '~/utils/session' + +export const signupFn = createServerFn({ + method: 'POST', +}) + .inputValidator( + (data: { email: string; password: string; redirectUrl?: string }) => data, + ) + .handler(async ({ data: payload }) => { + // Check if the user already exists + const found = await prismaClient.user.findUnique({ + where: { + email: payload.email, + }, + }) + + // Encrypt the password using Sha256 into plaintext + const password = await hashPassword(payload.password) + + // Create a session + const session = await useAppSession() + + if (found) { + if (found.password !== password) { + return { + error: true, + userExists: true, + message: 'User already exists', + } + } + + // Store the user's email in the session + await session.update({ + userEmail: found.email, + }) + + // Redirect to the prev page stored in the "redirect" search param + throw redirect({ + href: payload.redirectUrl || '/', + }) + } + + // Create the user + const user = await prismaClient.user.create({ + data: { + email: payload.email, + password, + }, + }) + + // Store the user's email in the session + await session.update({ + userEmail: user.email, + }) + + // Redirect to the prev page stored in the "redirect" search param + throw redirect({ + href: payload.redirectUrl || '/', + }) + }) + +export const Route = createFileRoute('/signup')({ + component: SignupComp, +}) + +function SignupComp() { + const signupMutation = useMutation({ + fn: useServerFn(signupFn), + }) + + return ( + { + const formData = new FormData(e.target as any as HTMLFormElement) + + signupMutation.mutate({ + data: { + email: formData.get('email') as string, + password: formData.get('password') as string, + }, + }) + }} + afterSubmit={ + signupMutation.data()?.error ? ( + <> +
{signupMutation.data()?.message}
+ + ) : null + } + /> + ) +} diff --git a/e2e/solid-start/basic-auth/src/styles/app.css b/e2e/solid-start/basic-auth/src/styles/app.css new file mode 100644 index 00000000000..c53c8706654 --- /dev/null +++ b/e2e/solid-start/basic-auth/src/styles/app.css @@ -0,0 +1,22 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + html { + color-scheme: light dark; + } + + * { + @apply border-gray-200 dark:border-gray-800; + } + + html, + body { + @apply text-gray-900 bg-gray-50 dark:bg-gray-950 dark:text-gray-200; + } + + .using-mouse * { + outline: none !important; + } +} diff --git a/e2e/solid-start/basic-auth/src/utils/posts.ts b/e2e/solid-start/basic-auth/src/utils/posts.ts new file mode 100644 index 00000000000..0408542344c --- /dev/null +++ b/e2e/solid-start/basic-auth/src/utils/posts.ts @@ -0,0 +1,37 @@ +import { notFound } from '@tanstack/solid-router' +import { createServerFn } from '@tanstack/solid-start' +import axios from 'redaxios' + +export type PostType = { + id: string + title: string + body: string +} + +export const fetchPost = createServerFn({ method: 'GET' }) + .inputValidator((postId: string) => postId) + .handler(async ({ data: postId }) => { + console.info(`Fetching post with id ${postId}...`) + const post = await axios + .get(`https://jsonplaceholder.typicode.com/posts/${postId}`) + .then((r) => r.data) + .catch((err) => { + console.error(err) + if (err.status === 404) { + throw notFound() + } + throw err + }) + + return post + }) + +export const fetchPosts = createServerFn({ method: 'GET' }).handler( + async () => { + console.info('Fetching posts...') + await new Promise((r) => setTimeout(r, 1000)) + return axios + .get>('https://jsonplaceholder.typicode.com/posts') + .then((r) => r.data.slice(0, 10)) + }, +) diff --git a/e2e/solid-start/basic-auth/src/utils/prisma.ts b/e2e/solid-start/basic-auth/src/utils/prisma.ts new file mode 100644 index 00000000000..74f5137b465 --- /dev/null +++ b/e2e/solid-start/basic-auth/src/utils/prisma.ts @@ -0,0 +1,16 @@ +import crypto from 'node:crypto' +import { PrismaClient } from '@prisma/client' + +export const prismaClient = new PrismaClient() + +export function hashPassword(password: string) { + return new Promise((resolve, reject) => { + crypto.pbkdf2(password, 'salt', 100000, 64, 'sha256', (err, derivedKey) => { + if (err) { + reject(err) + } else { + resolve(derivedKey.toString('hex')) + } + }) + }) +} diff --git a/e2e/solid-start/basic-auth/src/utils/seo.ts b/e2e/solid-start/basic-auth/src/utils/seo.ts new file mode 100644 index 00000000000..d18ad84b74e --- /dev/null +++ b/e2e/solid-start/basic-auth/src/utils/seo.ts @@ -0,0 +1,33 @@ +export const seo = ({ + title, + description, + keywords, + image, +}: { + title: string + description?: string + image?: string + keywords?: string +}) => { + const tags = [ + { title }, + { name: 'description', content: description }, + { name: 'keywords', content: keywords }, + { name: 'twitter:title', content: title }, + { name: 'twitter:description', content: description }, + { name: 'twitter:creator', content: '@tannerlinsley' }, + { name: 'twitter:site', content: '@tannerlinsley' }, + { name: 'og:type', content: 'website' }, + { name: 'og:title', content: title }, + { name: 'og:description', content: description }, + ...(image + ? [ + { name: 'twitter:image', content: image }, + { name: 'twitter:card', content: 'summary_large_image' }, + { name: 'og:image', content: image }, + ] + : []), + ] + + return tags +} diff --git a/e2e/solid-start/basic-auth/src/utils/session.ts b/e2e/solid-start/basic-auth/src/utils/session.ts new file mode 100644 index 00000000000..120a410b60f --- /dev/null +++ b/e2e/solid-start/basic-auth/src/utils/session.ts @@ -0,0 +1,13 @@ +// src/services/session.server.ts +import { useSession } from '@tanstack/solid-start/server' +import type { User } from '@prisma/client' + +type SessionUser = { + userEmail: User['email'] +} + +export function useAppSession() { + return useSession({ + password: 'ChangeThisBeforeShippingToProdOrYouWillBeFired', + }) +} diff --git a/e2e/solid-start/basic-auth/tailwind.config.mjs b/e2e/solid-start/basic-auth/tailwind.config.mjs new file mode 100644 index 00000000000..e49f4eb776e --- /dev/null +++ b/e2e/solid-start/basic-auth/tailwind.config.mjs @@ -0,0 +1,4 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./src/**/*.{js,jsx,ts,tsx}'], +} diff --git a/e2e/solid-start/basic-auth/tests/app.spec.ts b/e2e/solid-start/basic-auth/tests/app.spec.ts new file mode 100644 index 00000000000..e96cb400b04 --- /dev/null +++ b/e2e/solid-start/basic-auth/tests/app.spec.ts @@ -0,0 +1,60 @@ +import { expect, test } from '@playwright/test' +import type { Page } from '@playwright/test' + +async function signup( + page: Page, + baseUrl: string, + email: string, + password: string, +) { + await page.goto(baseUrl + '/signup') + await page.fill('input[name="email"]', email) + await page.fill('input[name="password"]', password) + await page.click('button[type="submit"]') +} + +async function login( + page: Page, + email: string, + password: string, + signupOnFail = false, +) { + await page.goto('/login') + await page.fill('input[name="email"]', email) + await page.fill('input[name="password"]', password) + await page.click('button[type="submit"]') + + if (signupOnFail) { + await page.waitForSelector('text=User not found') + await page.click('button:has-text("Sign up instead?")') + await page.waitForSelector('text=Logout') + } +} + +test('Posts redirects to login when not authenticated', async ({ page }) => { + await page.goto('/posts') + await expect(page.locator('h1')).toContainText('Login') +}) + +test('Login fails with user not found', async ({ page }) => { + await login(page, 'bad@gmail.com', 'badpassword') + expect(page.getByText('User not found')).toBeTruthy() +}) + +test('Login fails with incorrect password', async ({ page }) => { + await signup(page, 'test@gmail.com', 'badpassword') + expect(page.getByText('Incorrect password')).toBeTruthy() +}) + +test('Can sign up from a not found user', async ({ page }) => { + await login(page, 'test2@gmail.com', 'badpassword', true) + expect(page.getByText('test@gmail.com')).toBeTruthy() +}) + +test('Navigating to post after logging in', async ({ page }) => { + await login(page, 'test@gmail.com', 'test') + await new Promise((r) => setTimeout(r, 1000)) + await page.getByRole('link', { name: 'Posts' }).click() + await page.getByRole('link', { name: 'sunt aut facere repe' }).click() + await expect(page.getByRole('heading')).toContainText('sunt aut facere') +}) diff --git a/e2e/solid-start/basic-auth/tests/mock-db-setup.test.ts b/e2e/solid-start/basic-auth/tests/mock-db-setup.test.ts new file mode 100644 index 00000000000..742381a0475 --- /dev/null +++ b/e2e/solid-start/basic-auth/tests/mock-db-setup.test.ts @@ -0,0 +1,21 @@ +import { test as setup } from '@playwright/test' + +import { PrismaClient } from '@prisma/client' + +const prismaClient = new PrismaClient() + +setup('create new database', async () => { + if ( + await prismaClient.user.findUnique({ + where: { + email: 'test2@gmail.com', + }, + }) + ) { + await prismaClient.user.delete({ + where: { + email: 'test2@gmail.com', + }, + }) + } +}) diff --git a/e2e/solid-start/basic-auth/tests/mock-db-teardown.test.ts b/e2e/solid-start/basic-auth/tests/mock-db-teardown.test.ts new file mode 100644 index 00000000000..d933c7cb089 --- /dev/null +++ b/e2e/solid-start/basic-auth/tests/mock-db-teardown.test.ts @@ -0,0 +1,21 @@ +import { test as teardown } from '@playwright/test' + +import { PrismaClient } from '@prisma/client' + +const prismaClient = new PrismaClient() + +teardown('create new database', async () => { + if ( + await prismaClient.user.findUnique({ + where: { + email: 'test2@gmail.com', + }, + }) + ) { + await prismaClient.user.delete({ + where: { + email: 'test2@gmail.com', + }, + }) + } +}) diff --git a/e2e/solid-start/basic-auth/tsconfig.json b/e2e/solid-start/basic-auth/tsconfig.json new file mode 100644 index 00000000000..a40235b863f --- /dev/null +++ b/e2e/solid-start/basic-auth/tsconfig.json @@ -0,0 +1,23 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "target": "ES2022", + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./src/*"] + }, + "noEmit": true + } +} diff --git a/e2e/solid-start/basic-auth/vite.config.ts b/e2e/solid-start/basic-auth/vite.config.ts new file mode 100644 index 00000000000..cba8396d96a --- /dev/null +++ b/e2e/solid-start/basic-auth/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite' +import tsConfigPaths from 'vite-tsconfig-paths' +import { tanstackStart } from '@tanstack/solid-start/plugin/vite' +import viteSolid from 'vite-plugin-solid' + +export default defineConfig({ + plugins: [ + tsConfigPaths({ + projects: ['./tsconfig.json'], + }), + tanstackStart(), + viteSolid({ ssr: true }), + ], +}) diff --git a/examples/solid/start-basic-auth/.env b/examples/solid/start-basic-auth/.env new file mode 100644 index 00000000000..c498ab59bf1 --- /dev/null +++ b/examples/solid/start-basic-auth/.env @@ -0,0 +1,7 @@ +# Environment variables declared in this file are automatically made available to Prisma. +# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema + +# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB. +# See the documentation for all the connection string options: https://pris.ly/d/connection-strings + +DATABASE_URL="file:./dev.db" \ No newline at end of file diff --git a/examples/solid/start-basic-auth/.gitignore b/examples/solid/start-basic-auth/.gitignore new file mode 100644 index 00000000000..28185491585 --- /dev/null +++ b/examples/solid/start-basic-auth/.gitignore @@ -0,0 +1,18 @@ +node_modules +package-lock.json +yarn.lock + +!.env +.DS_Store +.cache +.vercel +.output +/build/ +/api/ +/server/build +/public/build# Sentry Config File +.env.sentry-build-plugin +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/examples/solid/start-basic-auth/.prettierignore b/examples/solid/start-basic-auth/.prettierignore new file mode 100644 index 00000000000..2be5eaa6ece --- /dev/null +++ b/examples/solid/start-basic-auth/.prettierignore @@ -0,0 +1,4 @@ +**/build +**/public +pnpm-lock.yaml +routeTree.gen.ts \ No newline at end of file diff --git a/examples/solid/start-basic-auth/.vscode/settings.json b/examples/solid/start-basic-auth/.vscode/settings.json new file mode 100644 index 00000000000..00b5278e580 --- /dev/null +++ b/examples/solid/start-basic-auth/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "files.watcherExclude": { + "**/routeTree.gen.ts": true + }, + "search.exclude": { + "**/routeTree.gen.ts": true + }, + "files.readonlyInclude": { + "**/routeTree.gen.ts": true + } +} diff --git a/examples/solid/start-basic-auth/README.md b/examples/solid/start-basic-auth/README.md new file mode 100644 index 00000000000..90cba4aac1e --- /dev/null +++ b/examples/solid/start-basic-auth/README.md @@ -0,0 +1,72 @@ +# Welcome to TanStack.com! + +This site is built with TanStack Router! + +- [TanStack Router Docs](https://tanstack.com/router) + +It's deployed automagically with Netlify! + +- [Netlify](https://netlify.com/) + +## Development + +From your terminal: + +```sh +pnpm install +pnpm dev +``` + +This starts your app in development mode, rebuilding assets on file changes. + +## Editing and previewing the docs of TanStack projects locally + +The documentations for all TanStack projects except for `React Charts` are hosted on [https://tanstack.com](https://tanstack.com), powered by this TanStack Router app. +In production, the markdown doc pages are fetched from the GitHub repos of the projects, but in development they are read from the local file system. + +Follow these steps if you want to edit the doc pages of a project (in these steps we'll assume it's [`TanStack/form`](https://github.com/tanstack/form)) and preview them locally : + +1. Create a new directory called `tanstack`. + +```sh +mkdir tanstack +``` + +2. Enter the directory and clone this repo and the repo of the project there. + +```sh +cd tanstack +git clone git@github.com:TanStack/tanstack.com.git +git clone git@github.com:TanStack/form.git +``` + +> [!NOTE] +> Your `tanstack` directory should look like this: +> +> ``` +> tanstack/ +> | +> +-- form/ +> | +> +-- tanstack.com/ +> ``` + +> [!WARNING] +> Make sure the name of the directory in your local file system matches the name of the project's repo. For example, `tanstack/form` must be cloned into `form` (this is the default) instead of `some-other-name`, because that way, the doc pages won't be found. + +3. Enter the `tanstack/tanstack.com` directory, install the dependencies and run the app in dev mode: + +```sh +cd tanstack.com +pnpm i +# The app will run on https://localhost:3000 by default +pnpm dev +``` + +4. Now you can visit http://localhost:3000/form/latest/docs/overview in the browser and see the changes you make in `tanstack/form/docs`. + +> [!NOTE] +> The updated pages need to be manually reloaded in the browser. + +> [!WARNING] +> You will need to update the `docs/config.json` file (in the project's repo) if you add a new doc page! diff --git a/examples/solid/start-basic-auth/package.json b/examples/solid/start-basic-auth/package.json new file mode 100644 index 00000000000..9cac4db9b8d --- /dev/null +++ b/examples/solid/start-basic-auth/package.json @@ -0,0 +1,32 @@ +{ + "name": "tanstack-solid-start-example-basic-auth", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "start": "vite start", + "prisma-generate": "prisma generate" + }, + "dependencies": { + "@prisma/client": "5.22.0", + "@tanstack/solid-router": "^1.133.20", + "@tanstack/solid-router-devtools": "^1.133.20", + "@tanstack/solid-start": "^1.133.20", + "prisma": "^5.22.0", + "solid-js": "^1.9.5", + "redaxios": "^0.5.1", + "tailwind-merge": "^2.6.0" + }, + "devDependencies": { + "vite-plugin-solid": "^2.11.9", + "@types/node": "^22.5.4", + "postcss": "^8.5.1", + "autoprefixer": "^10.4.20", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2", + "vite": "^7.1.7", + "vite-tsconfig-paths": "^5.1.4" + } +} diff --git a/examples/solid/start-basic-auth/postcss.config.mjs b/examples/solid/start-basic-auth/postcss.config.mjs new file mode 100644 index 00000000000..2e7af2b7f1a --- /dev/null +++ b/examples/solid/start-basic-auth/postcss.config.mjs @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/examples/solid/start-basic-auth/prisma/dev.db b/examples/solid/start-basic-auth/prisma/dev.db new file mode 100644 index 00000000000..5f4ab51a029 Binary files /dev/null and b/examples/solid/start-basic-auth/prisma/dev.db differ diff --git a/examples/solid/start-basic-auth/prisma/migrations/20240811183753_init/migration.sql b/examples/solid/start-basic-auth/prisma/migrations/20240811183753_init/migration.sql new file mode 100644 index 00000000000..4512a8f7823 --- /dev/null +++ b/examples/solid/start-basic-auth/prisma/migrations/20240811183753_init/migration.sql @@ -0,0 +1,8 @@ +-- CreateTable +CREATE TABLE "User" ( + "email" TEXT NOT NULL PRIMARY KEY, + "password" TEXT NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); diff --git a/examples/solid/start-basic-auth/prisma/migrations/migration_lock.toml b/examples/solid/start-basic-auth/prisma/migrations/migration_lock.toml new file mode 100644 index 00000000000..e5e5c4705ab --- /dev/null +++ b/examples/solid/start-basic-auth/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "sqlite" \ No newline at end of file diff --git a/examples/solid/start-basic-auth/prisma/schema.prisma b/examples/solid/start-basic-auth/prisma/schema.prisma new file mode 100644 index 00000000000..3544834310f --- /dev/null +++ b/examples/solid/start-basic-auth/prisma/schema.prisma @@ -0,0 +1,16 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +model User { + email String @id @unique + password String +} \ No newline at end of file diff --git a/examples/solid/start-basic-auth/public/android-chrome-192x192.png b/examples/solid/start-basic-auth/public/android-chrome-192x192.png new file mode 100644 index 00000000000..09c8324f8c6 Binary files /dev/null and b/examples/solid/start-basic-auth/public/android-chrome-192x192.png differ diff --git a/examples/solid/start-basic-auth/public/android-chrome-512x512.png b/examples/solid/start-basic-auth/public/android-chrome-512x512.png new file mode 100644 index 00000000000..11d626ea3d0 Binary files /dev/null and b/examples/solid/start-basic-auth/public/android-chrome-512x512.png differ diff --git a/examples/solid/start-basic-auth/public/apple-touch-icon.png b/examples/solid/start-basic-auth/public/apple-touch-icon.png new file mode 100644 index 00000000000..5a9423cc02c Binary files /dev/null and b/examples/solid/start-basic-auth/public/apple-touch-icon.png differ diff --git a/examples/solid/start-basic-auth/public/favicon-16x16.png b/examples/solid/start-basic-auth/public/favicon-16x16.png new file mode 100644 index 00000000000..e3389b00443 Binary files /dev/null and b/examples/solid/start-basic-auth/public/favicon-16x16.png differ diff --git a/examples/solid/start-basic-auth/public/favicon-32x32.png b/examples/solid/start-basic-auth/public/favicon-32x32.png new file mode 100644 index 00000000000..900c77d444c Binary files /dev/null and b/examples/solid/start-basic-auth/public/favicon-32x32.png differ diff --git a/examples/solid/start-basic-auth/public/favicon.ico b/examples/solid/start-basic-auth/public/favicon.ico new file mode 100644 index 00000000000..1a1751676f7 Binary files /dev/null and b/examples/solid/start-basic-auth/public/favicon.ico differ diff --git a/examples/solid/start-basic-auth/public/favicon.png b/examples/solid/start-basic-auth/public/favicon.png new file mode 100644 index 00000000000..1e77bc06091 Binary files /dev/null and b/examples/solid/start-basic-auth/public/favicon.png differ diff --git a/examples/solid/start-basic-auth/public/site.webmanifest b/examples/solid/start-basic-auth/public/site.webmanifest new file mode 100644 index 00000000000..fa99de77db6 --- /dev/null +++ b/examples/solid/start-basic-auth/public/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "", + "short_name": "", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/examples/solid/start-basic-auth/src/components/Auth.tsx b/examples/solid/start-basic-auth/src/components/Auth.tsx new file mode 100644 index 00000000000..40e44b8c5a4 --- /dev/null +++ b/examples/solid/start-basic-auth/src/components/Auth.tsx @@ -0,0 +1,59 @@ +import type { JSX } from 'solid-js' + +export function Auth({ + actionText, + onSubmit, + status, + afterSubmit, +}: { + actionText: string + onSubmit: (e: HTMLFormElement) => void + status: 'pending' | 'idle' | 'success' | 'error' + afterSubmit?: JSX.Element +}) { + return ( +
+
+

{actionText}

+
{ + e.preventDefault() + onSubmit(e) + }} + class="space-y-4" + > +
+ + +
+
+ + +
+ + {afterSubmit ? afterSubmit : null} +
+
+
+ ) +} diff --git a/examples/solid/start-basic-auth/src/components/DefaultCatchBoundary.tsx b/examples/solid/start-basic-auth/src/components/DefaultCatchBoundary.tsx new file mode 100644 index 00000000000..32aed20e675 --- /dev/null +++ b/examples/solid/start-basic-auth/src/components/DefaultCatchBoundary.tsx @@ -0,0 +1,53 @@ +import { + ErrorComponent, + Link, + rootRouteId, + useMatch, + useRouter, +} from '@tanstack/solid-router' +import type { ErrorComponentProps } from '@tanstack/solid-router' + +export function DefaultCatchBoundary({ error }: ErrorComponentProps) { + const router = useRouter() + const isRoot = useMatch({ + strict: false, + select: (state) => state.id === rootRouteId, + }) + + console.error(error) + + return ( +
+ +
+ + {isRoot() ? ( + + Home + + ) : ( + { + e.preventDefault() + window.history.back() + }} + > + Go Back + + )} +
+
+ ) +} diff --git a/examples/solid/start-basic-auth/src/components/Login.tsx b/examples/solid/start-basic-auth/src/components/Login.tsx new file mode 100644 index 00000000000..ae2b270393a --- /dev/null +++ b/examples/solid/start-basic-auth/src/components/Login.tsx @@ -0,0 +1,71 @@ +import { useRouter } from '@tanstack/solid-router' +import { useServerFn } from '@tanstack/solid-start' +import { useMutation } from '../hooks/useMutation' +import { loginFn } from '../routes/_authed' +import { Auth } from './Auth' +import { signupFn } from '~/routes/signup' + +export function Login() { + const router = useRouter() + + const loginMutation = useMutation({ + fn: loginFn, + onSuccess: async (ctx) => { + if (!ctx.data?.error) { + await router.invalidate() + router.navigate({ to: '/' }) + return + } + }, + }) + + const signupMutation = useMutation({ + fn: useServerFn(signupFn), + }) + + return ( + { + const formData = new FormData(e.target as any as HTMLFormElement) + + loginMutation.mutate({ + data: { + email: formData.get('email') as string, + password: formData.get('password') as string, + }, + }) + }} + afterSubmit={ + loginMutation.data() ? ( + <> +
{loginMutation.data()?.message}
+ {loginMutation.data()?.userNotFound ? ( +
+ +
+ ) : null} + + ) : null + } + /> + ) +} diff --git a/examples/solid/start-basic-auth/src/components/NotFound.tsx b/examples/solid/start-basic-auth/src/components/NotFound.tsx new file mode 100644 index 00000000000..ca4c1960fa1 --- /dev/null +++ b/examples/solid/start-basic-auth/src/components/NotFound.tsx @@ -0,0 +1,25 @@ +import { Link } from '@tanstack/solid-router' + +export function NotFound({ children }: { children?: any }) { + return ( +
+
+ {children ||

The page you are looking for does not exist.

} +
+

+ + + Start Over + +

+
+ ) +} diff --git a/examples/solid/start-basic-auth/src/hooks/useMutation.ts b/examples/solid/start-basic-auth/src/hooks/useMutation.ts new file mode 100644 index 00000000000..102ecf87fc8 --- /dev/null +++ b/examples/solid/start-basic-auth/src/hooks/useMutation.ts @@ -0,0 +1,41 @@ +import * as Solid from 'solid-js' + +export function useMutation(opts: { + fn: (variables: TVariables) => Promise + onSuccess?: (ctx: { data: TData }) => void | Promise +}) { + const [submittedAt, setSubmittedAt] = Solid.createSignal() + const [variables, setVariables] = Solid.createSignal() + const [error, setError] = Solid.createSignal() + const [data, setData] = Solid.createSignal() + const [status, setStatus] = Solid.createSignal< + 'idle' | 'pending' | 'success' | 'error' + >('idle') + + const mutate = async (variables: TVariables): Promise => { + setStatus('pending') + setSubmittedAt(Date.now()) + setVariables(variables as any) + // + try { + const data = await opts.fn(variables) + await opts.onSuccess?.({ data }) + setStatus('success') + setError(undefined) + setData(data as any) + return data + } catch (err: any) { + setStatus('error') + setError(err) + } + } + + return { + status, + variables, + submittedAt, + mutate, + error, + data, + } +} diff --git a/examples/solid/start-basic-auth/src/routeTree.gen.ts b/examples/solid/start-basic-auth/src/routeTree.gen.ts new file mode 100644 index 00000000000..3b5f50dde1b --- /dev/null +++ b/examples/solid/start-basic-auth/src/routeTree.gen.ts @@ -0,0 +1,224 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as SignupRouteImport } from './routes/signup' +import { Route as LogoutRouteImport } from './routes/logout' +import { Route as LoginRouteImport } from './routes/login' +import { Route as AuthedRouteImport } from './routes/_authed' +import { Route as IndexRouteImport } from './routes/index' +import { Route as AuthedPostsRouteRouteImport } from './routes/_authed/posts.route' +import { Route as AuthedPostsIndexRouteImport } from './routes/_authed/posts.index' +import { Route as AuthedPostsPostIdRouteImport } from './routes/_authed/posts.$postId' + +const SignupRoute = SignupRouteImport.update({ + id: '/signup', + path: '/signup', + getParentRoute: () => rootRouteImport, +} as any) +const LogoutRoute = LogoutRouteImport.update({ + id: '/logout', + path: '/logout', + getParentRoute: () => rootRouteImport, +} as any) +const LoginRoute = LoginRouteImport.update({ + id: '/login', + path: '/login', + getParentRoute: () => rootRouteImport, +} as any) +const AuthedRoute = AuthedRouteImport.update({ + id: '/_authed', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const AuthedPostsRouteRoute = AuthedPostsRouteRouteImport.update({ + id: '/posts', + path: '/posts', + getParentRoute: () => AuthedRoute, +} as any) +const AuthedPostsIndexRoute = AuthedPostsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AuthedPostsRouteRoute, +} as any) +const AuthedPostsPostIdRoute = AuthedPostsPostIdRouteImport.update({ + id: '/$postId', + path: '/$postId', + getParentRoute: () => AuthedPostsRouteRoute, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/login': typeof LoginRoute + '/logout': typeof LogoutRoute + '/signup': typeof SignupRoute + '/posts': typeof AuthedPostsRouteRouteWithChildren + '/posts/$postId': typeof AuthedPostsPostIdRoute + '/posts/': typeof AuthedPostsIndexRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/login': typeof LoginRoute + '/logout': typeof LogoutRoute + '/signup': typeof SignupRoute + '/posts/$postId': typeof AuthedPostsPostIdRoute + '/posts': typeof AuthedPostsIndexRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/_authed': typeof AuthedRouteWithChildren + '/login': typeof LoginRoute + '/logout': typeof LogoutRoute + '/signup': typeof SignupRoute + '/_authed/posts': typeof AuthedPostsRouteRouteWithChildren + '/_authed/posts/$postId': typeof AuthedPostsPostIdRoute + '/_authed/posts/': typeof AuthedPostsIndexRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/login' + | '/logout' + | '/signup' + | '/posts' + | '/posts/$postId' + | '/posts/' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/login' | '/logout' | '/signup' | '/posts/$postId' | '/posts' + id: + | '__root__' + | '/' + | '/_authed' + | '/login' + | '/logout' + | '/signup' + | '/_authed/posts' + | '/_authed/posts/$postId' + | '/_authed/posts/' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + AuthedRoute: typeof AuthedRouteWithChildren + LoginRoute: typeof LoginRoute + LogoutRoute: typeof LogoutRoute + SignupRoute: typeof SignupRoute +} + +declare module '@tanstack/solid-router' { + interface FileRoutesByPath { + '/signup': { + id: '/signup' + path: '/signup' + fullPath: '/signup' + preLoaderRoute: typeof SignupRouteImport + parentRoute: typeof rootRouteImport + } + '/logout': { + id: '/logout' + path: '/logout' + fullPath: '/logout' + preLoaderRoute: typeof LogoutRouteImport + parentRoute: typeof rootRouteImport + } + '/login': { + id: '/login' + path: '/login' + fullPath: '/login' + preLoaderRoute: typeof LoginRouteImport + parentRoute: typeof rootRouteImport + } + '/_authed': { + id: '/_authed' + path: '' + fullPath: '' + preLoaderRoute: typeof AuthedRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/_authed/posts': { + id: '/_authed/posts' + path: '/posts' + fullPath: '/posts' + preLoaderRoute: typeof AuthedPostsRouteRouteImport + parentRoute: typeof AuthedRoute + } + '/_authed/posts/': { + id: '/_authed/posts/' + path: '/' + fullPath: '/posts/' + preLoaderRoute: typeof AuthedPostsIndexRouteImport + parentRoute: typeof AuthedPostsRouteRoute + } + '/_authed/posts/$postId': { + id: '/_authed/posts/$postId' + path: '/$postId' + fullPath: '/posts/$postId' + preLoaderRoute: typeof AuthedPostsPostIdRouteImport + parentRoute: typeof AuthedPostsRouteRoute + } + } +} + +interface AuthedPostsRouteRouteChildren { + AuthedPostsPostIdRoute: typeof AuthedPostsPostIdRoute + AuthedPostsIndexRoute: typeof AuthedPostsIndexRoute +} + +const AuthedPostsRouteRouteChildren: AuthedPostsRouteRouteChildren = { + AuthedPostsPostIdRoute: AuthedPostsPostIdRoute, + AuthedPostsIndexRoute: AuthedPostsIndexRoute, +} + +const AuthedPostsRouteRouteWithChildren = + AuthedPostsRouteRoute._addFileChildren(AuthedPostsRouteRouteChildren) + +interface AuthedRouteChildren { + AuthedPostsRouteRoute: typeof AuthedPostsRouteRouteWithChildren +} + +const AuthedRouteChildren: AuthedRouteChildren = { + AuthedPostsRouteRoute: AuthedPostsRouteRouteWithChildren, +} + +const AuthedRouteWithChildren = + AuthedRoute._addFileChildren(AuthedRouteChildren) + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + AuthedRoute: AuthedRouteWithChildren, + LoginRoute: LoginRoute, + LogoutRoute: LogoutRoute, + SignupRoute: SignupRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/solid-start' +declare module '@tanstack/solid-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/examples/solid/start-basic-auth/src/router.tsx b/examples/solid/start-basic-auth/src/router.tsx new file mode 100644 index 00000000000..da050c7db65 --- /dev/null +++ b/examples/solid/start-basic-auth/src/router.tsx @@ -0,0 +1,22 @@ +import { createRouter } from '@tanstack/solid-router' +import { routeTree } from './routeTree.gen' +import { DefaultCatchBoundary } from './components/DefaultCatchBoundary' +import { NotFound } from './components/NotFound' + +export function getRouter() { + const router = createRouter({ + routeTree, + defaultPreload: 'intent', + defaultErrorComponent: DefaultCatchBoundary, + defaultNotFoundComponent: () => , + scrollRestoration: true, + }) + + return router +} + +declare module '@tanstack/solid-router' { + interface Register { + router: ReturnType + } +} diff --git a/examples/solid/start-basic-auth/src/routes/__root.tsx b/examples/solid/start-basic-auth/src/routes/__root.tsx new file mode 100644 index 00000000000..3aa0bfa18b7 --- /dev/null +++ b/examples/solid/start-basic-auth/src/routes/__root.tsx @@ -0,0 +1,143 @@ +/// +import { + HeadContent, + Link, + Outlet, + Scripts, + createRootRoute, +} from '@tanstack/solid-router' +import { TanStackRouterDevtools } from '@tanstack/solid-router-devtools' +import { createServerFn } from '@tanstack/solid-start' +import { HydrationScript } from 'solid-js/web' +import type * as Solid from 'solid-js' +import { DefaultCatchBoundary } from '~/components/DefaultCatchBoundary.js' +import { NotFound } from '~/components/NotFound.js' +import appCss from '~/styles/app.css?url' +import { seo } from '~/utils/seo.js' +import { useAppSession } from '~/utils/session.js' + +const fetchUser = createServerFn({ method: 'GET' }).handler(async () => { + // We need to auth on the server so we have access to secure cookies + const session = await useAppSession() + + if (!session.data.userEmail) { + return null + } + + return { + email: session.data.userEmail, + } +}) + +export const Route = createRootRoute({ + beforeLoad: async () => { + const user = await fetchUser() + + return { + user, + } + }, + head: () => ({ + meta: [ + { + charset: 'utf-8', + }, + { + name: 'viewport', + content: 'width=device-width, initial-scale=1', + }, + ...seo({ + title: + 'TanStack Start | Type-Safe, Client-First, Full-Stack React Framework', + description: `TanStack Start is a type-safe, client-first, full-stack React framework. `, + }), + ], + links: [ + { rel: 'stylesheet', href: appCss }, + { + rel: 'apple-touch-icon', + sizes: '180x180', + href: '/apple-touch-icon.png', + }, + { + rel: 'icon', + type: 'image/png', + sizes: '32x32', + href: '/favicon-32x32.png', + }, + { + rel: 'icon', + type: 'image/png', + sizes: '16x16', + href: '/favicon-16x16.png', + }, + { rel: 'manifest', href: '/site.webmanifest', color: '#fffff' }, + { rel: 'icon', href: '/favicon.ico' }, + ], + }), + errorComponent: (props) => { + return ( + + + + ) + }, + notFoundComponent: () => , + component: RootComponent, +}) + +function RootComponent() { + return ( + + + + ) +} + +function RootDocument({ children }: { children: Solid.JSX.Element }) { + const routeContext = Route.useRouteContext() + + return ( + + + + + + +
+ + Home + {' '} + + Posts + +
+ {routeContext().user ? ( + <> + {routeContext().user?.email} + Logout + + ) : ( + Login + )} +
+
+
+ {children} + + + + + ) +} diff --git a/examples/solid/start-basic-auth/src/routes/_authed.tsx b/examples/solid/start-basic-auth/src/routes/_authed.tsx new file mode 100644 index 00000000000..3caa846b223 --- /dev/null +++ b/examples/solid/start-basic-auth/src/routes/_authed.tsx @@ -0,0 +1,58 @@ +import { createFileRoute } from '@tanstack/solid-router' +import { createServerFn } from '@tanstack/solid-start' +import { hashPassword, prismaClient } from '~/utils/prisma' +import { Login } from '~/components/Login' +import { useAppSession } from '~/utils/session' + +export const loginFn = createServerFn({ method: 'POST' }) + .inputValidator((d: { email: string; password: string }) => d) + .handler(async ({ data }) => { + // Find the user + const user = await prismaClient.user.findUnique({ + where: { + email: data.email, + }, + }) + + // Check if the user exists + if (!user) { + return { + error: true, + userNotFound: true, + message: 'User not found', + } + } + + // Check if the password is correct + const hashedPassword = await hashPassword(data.password) + + if (user.password !== hashedPassword) { + return { + error: true, + message: 'Incorrect password', + } + } + + // Create a session + const session = await useAppSession() + + // Store the user's email in the session + await session.update({ + userEmail: user.email, + }) + }) + +export const Route = createFileRoute('/_authed')({ + beforeLoad: ({ context }) => { + if (!context.user) { + throw new Error('Not authenticated') + } + }, + errorComponent: ({ error }) => { + if (error.message === 'Not authenticated') { + return + } + + throw error + }, +}) diff --git a/examples/solid/start-basic-auth/src/routes/_authed/posts.$postId.tsx b/examples/solid/start-basic-auth/src/routes/_authed/posts.$postId.tsx new file mode 100644 index 00000000000..bbbbd815388 --- /dev/null +++ b/examples/solid/start-basic-auth/src/routes/_authed/posts.$postId.tsx @@ -0,0 +1,28 @@ +import { ErrorComponent, createFileRoute } from '@tanstack/solid-router' +import type { ErrorComponentProps } from '@tanstack/solid-router' +import { NotFound } from '~/components/NotFound.js' +import { fetchPost } from '~/utils/posts.js' + +export function PostErrorComponent({ error }: ErrorComponentProps) { + return +} + +function PostComponent() { + const post = Route.useLoaderData() + + return ( +
+

{post().title}

+
{post().body}
+
+ ) +} + +export const Route = createFileRoute('/_authed/posts/$postId')({ + loader: ({ params: { postId } }) => fetchPost({ data: postId }), + errorComponent: PostErrorComponent, + component: PostComponent, + notFoundComponent: () => { + return Post not found + }, +}) diff --git a/examples/solid/start-basic-auth/src/routes/_authed/posts.index.tsx b/examples/solid/start-basic-auth/src/routes/_authed/posts.index.tsx new file mode 100644 index 00000000000..4ba0817e286 --- /dev/null +++ b/examples/solid/start-basic-auth/src/routes/_authed/posts.index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/_authed/posts/')({ + component: PostsIndexComponent, +}) + +function PostsIndexComponent() { + return
Select a post.
+} diff --git a/examples/solid/start-basic-auth/src/routes/_authed/posts.route.tsx b/examples/solid/start-basic-auth/src/routes/_authed/posts.route.tsx new file mode 100644 index 00000000000..256aaee50f4 --- /dev/null +++ b/examples/solid/start-basic-auth/src/routes/_authed/posts.route.tsx @@ -0,0 +1,38 @@ +import { Link, Outlet, createFileRoute } from '@tanstack/solid-router' +import { fetchPosts } from '~/utils/posts.js' + +export const Route = createFileRoute('/_authed/posts')({ + loader: () => fetchPosts(), + component: PostsComponent, +}) + +function PostsComponent() { + const posts = Route.useLoaderData() + + return ( +
+
    + {[...posts(), { id: 'i-do-not-exist', title: 'Non-existent Post' }].map( + (post) => { + return ( +
  • + +
    {post.title.substring(0, 20)}
    + +
  • + ) + }, + )} +
+
+ +
+ ) +} diff --git a/examples/solid/start-basic-auth/src/routes/index.tsx b/examples/solid/start-basic-auth/src/routes/index.tsx new file mode 100644 index 00000000000..a128aeca0e1 --- /dev/null +++ b/examples/solid/start-basic-auth/src/routes/index.tsx @@ -0,0 +1,13 @@ +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/')({ + component: Home, +}) + +function Home() { + return ( +
+

Welcome Home!!!

+
+ ) +} diff --git a/examples/solid/start-basic-auth/src/routes/login.tsx b/examples/solid/start-basic-auth/src/routes/login.tsx new file mode 100644 index 00000000000..90a75e6867c --- /dev/null +++ b/examples/solid/start-basic-auth/src/routes/login.tsx @@ -0,0 +1,10 @@ +import { createFileRoute } from '@tanstack/solid-router' +import { Login } from '~/components/Login' + +export const Route = createFileRoute('/login')({ + component: LoginComp, +}) + +function LoginComp() { + return +} diff --git a/examples/solid/start-basic-auth/src/routes/logout.tsx b/examples/solid/start-basic-auth/src/routes/logout.tsx new file mode 100644 index 00000000000..f7d624ff153 --- /dev/null +++ b/examples/solid/start-basic-auth/src/routes/logout.tsx @@ -0,0 +1,18 @@ +import { createFileRoute, redirect } from '@tanstack/solid-router' +import { createServerFn } from '@tanstack/solid-start' +import { useAppSession } from '~/utils/session' + +const logoutFn = createServerFn().handler(async () => { + const session = await useAppSession() + + session.clear() + + throw redirect({ + href: '/', + }) +}) + +export const Route = createFileRoute('/logout')({ + preload: false, + loader: () => logoutFn(), +}) diff --git a/examples/solid/start-basic-auth/src/routes/signup.tsx b/examples/solid/start-basic-auth/src/routes/signup.tsx new file mode 100644 index 00000000000..020e07c029b --- /dev/null +++ b/examples/solid/start-basic-auth/src/routes/signup.tsx @@ -0,0 +1,97 @@ +import { createFileRoute, redirect } from '@tanstack/solid-router' +import { createServerFn, useServerFn } from '@tanstack/solid-start' +import { hashPassword, prismaClient } from '~/utils/prisma' +import { useMutation } from '~/hooks/useMutation' +import { Auth } from '~/components/Auth' +import { useAppSession } from '~/utils/session' + +export const signupFn = createServerFn({ method: 'POST' }) + .inputValidator( + (d: { email: string; password: string; redirectUrl?: string }) => d, + ) + .handler(async ({ data }) => { + // Check if the user already exists + const found = await prismaClient.user.findUnique({ + where: { + email: data.email, + }, + }) + + // Encrypt the password using Sha256 into plaintext + const password = await hashPassword(data.password) + + // Create a session + const session = await useAppSession() + + if (found) { + if (found.password !== password) { + return { + error: true, + userExists: true, + message: 'User already exists', + } + } + + // Store the user's email in the session + await session.update({ + userEmail: found.email, + }) + + // Redirect to the prev page stored in the "redirect" search param + throw redirect({ + href: data.redirectUrl || '/', + }) + } + + // Create the user + const user = await prismaClient.user.create({ + data: { + email: data.email, + password, + }, + }) + + // Store the user's email in the session + await session.update({ + userEmail: user.email, + }) + + // Redirect to the prev page stored in the "redirect" search param + throw redirect({ + href: data.redirectUrl || '/', + }) + }) + +export const Route = createFileRoute('/signup')({ + component: SignupComp, +}) + +function SignupComp() { + const signupMutation = useMutation({ + fn: useServerFn(signupFn), + }) + + return ( + { + const formData = new FormData(e.target as any as HTMLFormElement) + + signupMutation.mutate({ + data: { + email: formData.get('email') as string, + password: formData.get('password') as string, + }, + }) + }} + afterSubmit={ + signupMutation.data()?.error ? ( + <> +
{signupMutation.data()?.message}
+ + ) : null + } + /> + ) +} diff --git a/examples/solid/start-basic-auth/src/styles/app.css b/examples/solid/start-basic-auth/src/styles/app.css new file mode 100644 index 00000000000..c53c8706654 --- /dev/null +++ b/examples/solid/start-basic-auth/src/styles/app.css @@ -0,0 +1,22 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + html { + color-scheme: light dark; + } + + * { + @apply border-gray-200 dark:border-gray-800; + } + + html, + body { + @apply text-gray-900 bg-gray-50 dark:bg-gray-950 dark:text-gray-200; + } + + .using-mouse * { + outline: none !important; + } +} diff --git a/examples/solid/start-basic-auth/src/utils/posts.ts b/examples/solid/start-basic-auth/src/utils/posts.ts new file mode 100644 index 00000000000..5c2e34c98dc --- /dev/null +++ b/examples/solid/start-basic-auth/src/utils/posts.ts @@ -0,0 +1,37 @@ +import { notFound } from '@tanstack/solid-router' +import { createServerFn } from '@tanstack/solid-start' +import axios from 'redaxios' + +export type PostType = { + id: string + title: string + body: string +} + +export const fetchPost = createServerFn({ method: 'GET' }) + .inputValidator((postId: string) => postId) + .handler(async ({ data }) => { + console.info(`Fetching post with id ${data}...`) + const post = await axios + .get(`https://jsonplaceholder.typicode.com/posts/${data}`) + .then((r) => r.data) + .catch((err) => { + console.error(err) + if (err.status === 404) { + throw notFound() + } + throw err + }) + + return post + }) + +export const fetchPosts = createServerFn({ method: 'GET' }).handler( + async () => { + console.info('Fetching posts...') + await new Promise((r) => setTimeout(r, 1000)) + return axios + .get>('https://jsonplaceholder.typicode.com/posts') + .then((r) => r.data.slice(0, 10)) + }, +) diff --git a/examples/solid/start-basic-auth/src/utils/prisma.ts b/examples/solid/start-basic-auth/src/utils/prisma.ts new file mode 100644 index 00000000000..74f5137b465 --- /dev/null +++ b/examples/solid/start-basic-auth/src/utils/prisma.ts @@ -0,0 +1,16 @@ +import crypto from 'node:crypto' +import { PrismaClient } from '@prisma/client' + +export const prismaClient = new PrismaClient() + +export function hashPassword(password: string) { + return new Promise((resolve, reject) => { + crypto.pbkdf2(password, 'salt', 100000, 64, 'sha256', (err, derivedKey) => { + if (err) { + reject(err) + } else { + resolve(derivedKey.toString('hex')) + } + }) + }) +} diff --git a/examples/solid/start-basic-auth/src/utils/seo.ts b/examples/solid/start-basic-auth/src/utils/seo.ts new file mode 100644 index 00000000000..d18ad84b74e --- /dev/null +++ b/examples/solid/start-basic-auth/src/utils/seo.ts @@ -0,0 +1,33 @@ +export const seo = ({ + title, + description, + keywords, + image, +}: { + title: string + description?: string + image?: string + keywords?: string +}) => { + const tags = [ + { title }, + { name: 'description', content: description }, + { name: 'keywords', content: keywords }, + { name: 'twitter:title', content: title }, + { name: 'twitter:description', content: description }, + { name: 'twitter:creator', content: '@tannerlinsley' }, + { name: 'twitter:site', content: '@tannerlinsley' }, + { name: 'og:type', content: 'website' }, + { name: 'og:title', content: title }, + { name: 'og:description', content: description }, + ...(image + ? [ + { name: 'twitter:image', content: image }, + { name: 'twitter:card', content: 'summary_large_image' }, + { name: 'og:image', content: image }, + ] + : []), + ] + + return tags +} diff --git a/examples/solid/start-basic-auth/src/utils/session.ts b/examples/solid/start-basic-auth/src/utils/session.ts new file mode 100644 index 00000000000..120a410b60f --- /dev/null +++ b/examples/solid/start-basic-auth/src/utils/session.ts @@ -0,0 +1,13 @@ +// src/services/session.server.ts +import { useSession } from '@tanstack/solid-start/server' +import type { User } from '@prisma/client' + +type SessionUser = { + userEmail: User['email'] +} + +export function useAppSession() { + return useSession({ + password: 'ChangeThisBeforeShippingToProdOrYouWillBeFired', + }) +} diff --git a/examples/solid/start-basic-auth/tailwind.config.mjs b/examples/solid/start-basic-auth/tailwind.config.mjs new file mode 100644 index 00000000000..e49f4eb776e --- /dev/null +++ b/examples/solid/start-basic-auth/tailwind.config.mjs @@ -0,0 +1,4 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./src/**/*.{js,jsx,ts,tsx}'], +} diff --git a/examples/solid/start-basic-auth/tsconfig.json b/examples/solid/start-basic-auth/tsconfig.json new file mode 100644 index 00000000000..a40235b863f --- /dev/null +++ b/examples/solid/start-basic-auth/tsconfig.json @@ -0,0 +1,23 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "target": "ES2022", + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./src/*"] + }, + "noEmit": true + } +} diff --git a/examples/solid/start-basic-auth/vite.config.ts b/examples/solid/start-basic-auth/vite.config.ts new file mode 100644 index 00000000000..27a27a7b5a5 --- /dev/null +++ b/examples/solid/start-basic-auth/vite.config.ts @@ -0,0 +1,17 @@ +import { tanstackStart } from '@tanstack/solid-start/plugin/vite' +import { defineConfig } from 'vite' +import tsConfigPaths from 'vite-tsconfig-paths' +import viteSolid from 'vite-plugin-solid' + +export default defineConfig({ + server: { + port: 3000, + }, + plugins: [ + tsConfigPaths({ + projects: ['./tsconfig.json'], + }), + tanstackStart(), + viteSolid({ ssr: true }), + ], +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c73b7ac041b..edbf26aab26 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2378,7 +2378,7 @@ importers: version: 1.0.3(@rsbuild/core@1.2.4) '@rsbuild/plugin-solid': specifier: ^1.0.4 - version: 1.0.4(@babel/core@7.27.4)(@rsbuild/core@1.2.4)(solid-js@1.9.5) + version: 1.0.4(@babel/core@7.28.4)(@rsbuild/core@1.2.4)(solid-js@1.9.5) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -2424,7 +2424,7 @@ importers: version: 1.0.3(@rsbuild/core@1.2.4) '@rsbuild/plugin-solid': specifier: ^1.0.4 - version: 1.0.4(@babel/core@7.28.4)(@rsbuild/core@1.2.4)(solid-js@1.9.5) + version: 1.0.4(@babel/core@7.27.4)(@rsbuild/core@1.2.4)(solid-js@1.9.5) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -2560,6 +2560,64 @@ importers: specifier: ^5.1.4 version: 5.1.4(typescript@5.8.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + e2e/solid-start/basic-auth: + dependencies: + '@prisma/client': + specifier: 5.22.0 + version: 5.22.0(prisma@5.22.0) + '@tanstack/solid-router': + specifier: workspace:^ + version: link:../../../packages/solid-router + '@tanstack/solid-router-devtools': + specifier: workspace:^ + version: link:../../../packages/solid-router-devtools + '@tanstack/solid-start': + specifier: workspace:* + version: link:../../../packages/solid-start + prisma: + specifier: ^5.22.0 + version: 5.22.0 + redaxios: + specifier: ^0.5.1 + version: 0.5.1 + solid-js: + specifier: ^1.9.5 + version: 1.9.5 + tailwind-merge: + specifier: ^2.6.0 + version: 2.6.0 + vite: + specifier: ^7.1.7 + version: 7.1.7(@types/node@22.10.2)(jiti@2.6.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + devDependencies: + '@playwright/test': + specifier: ^1.52.0 + version: 1.52.0 + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@types/node': + specifier: 22.10.2 + version: 22.10.2 + autoprefixer: + specifier: ^10.4.20 + version: 10.4.20(postcss@8.5.6) + postcss: + specifier: ^8.5.1 + version: 8.5.6 + tailwindcss: + specifier: ^3.4.17 + version: 3.4.17 + typescript: + specifier: ^5.7.2 + version: 5.9.2 + vite-plugin-solid: + specifier: ^2.11.9 + version: 2.11.9(@testing-library/jest-dom@6.6.3)(solid-js@1.9.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@5.9.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + e2e/solid-start/basic-solid-query: dependencies: '@tanstack/solid-query': @@ -6986,6 +7044,58 @@ importers: specifier: ^5.1.4 version: 5.1.4(typescript@5.8.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + examples/solid/start-basic-auth: + dependencies: + '@prisma/client': + specifier: 5.22.0 + version: 5.22.0(prisma@5.22.0) + '@tanstack/solid-router': + specifier: ^1.133.20 + version: link:../../../packages/solid-router + '@tanstack/solid-router-devtools': + specifier: workspace:^ + version: link:../../../packages/solid-router-devtools + '@tanstack/solid-start': + specifier: workspace:* + version: link:../../../packages/solid-start + prisma: + specifier: ^5.22.0 + version: 5.22.0 + redaxios: + specifier: ^0.5.1 + version: 0.5.1 + solid-js: + specifier: ^1.9.5 + version: 1.9.5 + tailwind-merge: + specifier: ^2.6.0 + version: 2.6.0 + devDependencies: + '@types/node': + specifier: 22.10.2 + version: 22.10.2 + autoprefixer: + specifier: ^10.4.20 + version: 10.4.20(postcss@8.5.6) + postcss: + specifier: ^8.5.1 + version: 8.5.6 + tailwindcss: + specifier: ^3.4.17 + version: 3.4.17 + typescript: + specifier: ^5.7.2 + version: 5.9.2 + vite: + specifier: ^7.1.7 + version: 7.1.7(@types/node@22.10.2)(jiti@2.6.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + vite-plugin-solid: + specifier: ^2.11.9 + version: 2.11.9(@testing-library/jest-dom@6.6.3)(solid-js@1.9.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@5.9.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + examples/solid/start-basic-static: dependencies: '@tanstack/solid-router':