From 38732a4cf6b65780a6a04260432a3dddead5b14e Mon Sep 17 00:00:00 2001 From: Daniel Bulant Date: Sun, 19 Apr 2026 14:58:46 +0200 Subject: [PATCH] basic user info --- api/package.json | 1 + bun.lock | 3 + web/src/components/user-info.tsx | 29 +++++++ web/src/hooks/user.ts | 21 +++++ web/src/lib/auth-client.ts | 21 +++++ web/src/lib/auth.serverfn.ts | 46 +++++++++++ web/src/lib/utils.ts | 9 ++- web/src/routes/__root.tsx | 127 +++++++++++++++++++------------ web/src/routes/index.tsx | 87 ++------------------- 9 files changed, 213 insertions(+), 131 deletions(-) create mode 100644 web/src/components/user-info.tsx create mode 100644 web/src/hooks/user.ts create mode 100644 web/src/lib/auth.serverfn.ts diff --git a/api/package.json b/api/package.json index 284c443..fabe084 100644 --- a/api/package.json +++ b/api/package.json @@ -14,6 +14,7 @@ "typescript": "^5" }, "dependencies": { + "@spotify/web-api-ts-sdk": "^1.2.0", "@statsfm/statsfm.js": "github.com:statsfm/statsfm.js", "better-auth": "^1.6.5", "elysia": "catalog:" diff --git a/bun.lock b/bun.lock index c397f14..457c5b3 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "api": { "name": "api", "dependencies": { + "@spotify/web-api-ts-sdk": "^1.2.0", "@statsfm/statsfm.js": "github.com:statsfm/statsfm.js", "better-auth": "^1.6.5", "elysia": "catalog:", @@ -482,6 +483,8 @@ "@solid-primitives/utils": ["@solid-primitives/utils@6.4.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A=="], + "@spotify/web-api-ts-sdk": ["@spotify/web-api-ts-sdk@1.2.0", "", {}, "sha512-JUaebva3Ohwo5I5tuTqyW/FKGOMbb40YevJMySAOINRxP7qQ/AMjBzfJx0zeO6yS+wAPfQSoGNsZaUggHw8vsA=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@statsfm/statsfm.js": ["@statsfm/statsfm.js@git+ssh://github.com:statsfm/statsfm.js#44e5eb3e731791c03a896648c6a64a65b7c49612", { "dependencies": { "file-type": "16", "isomorphic-unfetch": "^3.1.0" } }, "44e5eb3e731791c03a896648c6a64a65b7c49612"], diff --git a/web/src/components/user-info.tsx b/web/src/components/user-info.tsx new file mode 100644 index 0000000..c9f7591 --- /dev/null +++ b/web/src/components/user-info.tsx @@ -0,0 +1,29 @@ +import { useRouteContext } from "@tanstack/react-router"; +import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"; +import { + Item, + ItemContent, + ItemDescription, + ItemMedia, + ItemTitle, +} from "./ui/item"; +import { useUser } from "#/hooks/user"; +import { initials } from "#/lib/utils"; + +export function UserInfo() { + const { user } = useUser(); + return ( + + + + + {initials(user?.name || "")} + + + + {user?.name} + No party yet + + + ); +} diff --git a/web/src/hooks/user.ts b/web/src/hooks/user.ts new file mode 100644 index 0000000..88333ab --- /dev/null +++ b/web/src/hooks/user.ts @@ -0,0 +1,21 @@ +import { authClient } from "#/lib/auth-client"; +import type { AuthSession } from "#/lib/auth.serverfn"; +import { Route } from "#/routes/__root"; + +interface UserData { + user: AuthSession["user"] | null; + session: AuthSession["session"] | null; + isLoading: boolean; +} + +export function useUser() { + const routeContext = Route.useRouteContext(); + const { data: clientSession, isPending: isClientSessionPending } = + authClient.useSession(); + const session = (clientSession?.session ?? + routeContext.session?.session ?? + null) as AuthSession["session"] | null; + const user = clientSession?.user ?? routeContext.session?.user ?? null; + const isLoading = !routeContext.session && isClientSessionPending; + return { user, session, isLoading } satisfies UserData; +} diff --git a/web/src/lib/auth-client.ts b/web/src/lib/auth-client.ts index f1012dd..44c6fef 100644 --- a/web/src/lib/auth-client.ts +++ b/web/src/lib/auth-client.ts @@ -1,3 +1,24 @@ import { createAuthClient } from "better-auth/react"; +import type { AuthSession } from "./auth.serverfn"; +import type { QueryClient } from "@tanstack/react-query"; export const authClient = createAuthClient(); + +export const sessionQueryKey = ["auth", "session"] as const; + +export async function fetchSession(): Promise { + const { data } = await authClient.getSession(); + return data ?? null; +} + +export async function signOutAndClearQueryCache({ + navigateToLogin, + queryClient, +}: { + navigateToLogin: () => Promise | void; + queryClient: QueryClient; +}): Promise { + await authClient.signOut(); + queryClient.clear(); + await navigateToLogin(); +} diff --git a/web/src/lib/auth.serverfn.ts b/web/src/lib/auth.serverfn.ts new file mode 100644 index 0000000..6f5134f --- /dev/null +++ b/web/src/lib/auth.serverfn.ts @@ -0,0 +1,46 @@ +import { createServerFn } from "@tanstack/react-start"; + +export interface AuthSession { + user: { + id: string; + createdAt: Date; + updatedAt: Date; + email: string; + emailVerified: boolean; + name: string; + image?: string | null | undefined; + }; + session: { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; +} + +export const getSession = createServerFn({ method: "GET" }).handler( + async (): Promise => { + const { getRequestHeaders } = await import("@tanstack/react-start/server"); + const headers = getRequestHeaders(); + const resolvedBaseUrl = + process.env.VITE_BETTER_AUTH_URL ?? + import.meta.env.VITE_BETTER_AUTH_URL ?? + headers.get("origin") ?? + "http://127.0.0.1:3000"; + const response = await fetch( + new URL("/api/auth/get-session", resolvedBaseUrl), + { headers }, + ); + + if (!response.ok) { + return null; + } + + const data = (await response.json()) as AuthSession | null; + return data ?? null; + }, +); diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index ac680b3..d88c150 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -2,5 +2,12 @@ import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); + return twMerge(clsx(inputs)); +} + +export function initials(name: string) { + return name + .split(" ") + .map((t) => t[0]) + .join(""); } diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx index 2510da2..70e457a 100644 --- a/web/src/routes/__root.tsx +++ b/web/src/routes/__root.tsx @@ -1,66 +1,95 @@ import { TanStackDevtools } from "@tanstack/react-devtools"; import type { QueryClient } from "@tanstack/react-query"; import { - createRootRouteWithContext, - HeadContent, - Scripts, + createRootRouteWithContext, + HeadContent, + redirect, + Scripts, } from "@tanstack/react-router"; import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools"; import { Toaster } from "@/components/ui/sonner"; import TanStackQueryDevtools from "../integrations/tanstack-query/devtools"; import appCss from "../styles.css?url"; +import type { AuthSession } from "#/lib/auth.serverfn"; +import { fetchSession, sessionQueryKey } from "#/lib/auth-client"; interface MyRouterContext { - queryClient: QueryClient; + queryClient: QueryClient; } export const Route = createRootRouteWithContext()({ - head: () => ({ - meta: [ - { - charSet: "utf-8", - }, - { - name: "viewport", - content: "width=device-width, initial-scale=1", - }, - { - title: "TanStack Start Starter", - }, - ], - links: [ - { - rel: "stylesheet", - href: appCss, - }, - ], - }), - shellComponent: RootDocument, + head: () => ({ + meta: [ + { + charSet: "utf-8", + }, + { + name: "viewport", + content: "width=device-width, initial-scale=1", + }, + { + title: "TanStack Start Starter", + }, + ], + links: [ + { + rel: "stylesheet", + href: appCss, + }, + ], + }), + shellComponent: RootDocument, + beforeLoad: async ({ context, location }) => { + const authPublicPaths = new Set(["/login"]); + const isAuthPublicPath = authPublicPaths.has(location.pathname); + let session: AuthSession | null; + if (typeof window === "undefined") { + const { getSession } = await import("../lib/auth.serverfn"); + session = await getSession(); + } else { + session = await context.queryClient.fetchQuery({ + queryKey: sessionQueryKey, + queryFn: fetchSession, + staleTime: 30_000, + }); + } + + const user = session?.user; + + if (!user && !isAuthPublicPath) { + throw redirect({ + to: "/login", + search: { redirect: location.href }, + }); + } + + return { user, session }; + }, }); function RootDocument({ children }: { children: React.ReactNode }) { - return ( - - - - - - {children} - - , - }, - TanStackQueryDevtools, - ]} - /> - - - - ); + return ( + + + + + + {children} + + , + }, + TanStackQueryDevtools, + ]} + /> + + + + ); } diff --git a/web/src/routes/index.tsx b/web/src/routes/index.tsx index 8a340b5..511cd90 100644 --- a/web/src/routes/index.tsx +++ b/web/src/routes/index.tsx @@ -1,87 +1,12 @@ +import { UserInfo } from "#/components/user-info"; import { createFileRoute } from "@tanstack/react-router"; export const Route = createFileRoute("/")({ component: App }); function App() { - return ( -
-
-
-
-

TanStack Start Base Template

-

- Start simple, ship quickly. -

-

- This base starter intentionally keeps things light: two routes, clean - structure, and the essentials you need to build from scratch. -

- -
- -
- {[ - [ - "Type-Safe Routing", - "Routes and links stay in sync across every page.", - ], - [ - "Server Functions", - "Call server code from your UI without creating API boilerplate.", - ], - [ - "Streaming by Default", - "Ship progressively rendered responses for faster experiences.", - ], - [ - "Tailwind Native", - "Design quickly with utility-first styling and reusable tokens.", - ], - ].map(([title, desc], index) => ( -
-

- {title} -

-

{desc}

-
- ))} -
- -
-

Quick Start

-
    -
  • - Edit src/routes/index.tsx to customize the home page. -
  • -
  • - Update src/components/Header.tsx and{" "} - src/components/Footer.tsx for brand links. -
  • -
  • - Add routes in src/routes and tweak visual tokens in{" "} - src/styles.css. -
  • -
-
-
- ); + return ( +
+ +
+ ); }