diff --git a/src/components/ui/theme-switcher.tsx b/src/components/ui/theme-switcher.tsx index 71f4534d3..b633f0349 100644 --- a/src/components/ui/theme-switcher.tsx +++ b/src/components/ui/theme-switcher.tsx @@ -10,7 +10,6 @@ import { useTranslation } from 'react-i18next'; import { match } from 'ts-pattern'; import { cn } from '@/lib/tailwind/utils'; -import { useHydrated } from '@/hooks/use-hydrated'; import { Button } from '@/components/ui/button'; import { @@ -25,11 +24,6 @@ export const themes = ['system', 'light', 'dark'] as const; export const ThemeSwitcher = (props: { iconOnly?: boolean }) => { const { t } = useTranslation(['common']); const { theme, setTheme } = useTheme(); - const hydrated = useHydrated(); - - if (!hydrated) { - return
; - } return ( @@ -41,13 +35,13 @@ export const ThemeSwitcher = (props: { iconOnly?: boolean }) => { /> } > - {match(theme as (typeof themes)[number]) + {match((theme as (typeof themes)[number]) ?? 'system') .with('system', () => ) .with('light', () => ) .with('dark', () => ) .exhaustive()} - {match(theme as (typeof themes)[number]) + {match((theme as (typeof themes)[number]) ?? 'system') .with('system', () => t('common:themes.values.system')) .with('light', () => t('common:themes.values.light')) .with('dark', () => t('common:themes.values.dark')) diff --git a/src/features/account/change-name-drawer.tsx b/src/features/account/change-name-drawer.tsx index 00c3061fa..613b4e807 100644 --- a/src/features/account/change-name-drawer.tsx +++ b/src/features/account/change-name-drawer.tsx @@ -29,12 +29,12 @@ import { FormFieldsAccountUpdateName, zFormFieldsAccountUpdateName, } from '@/features/account/schema'; -import { authClient } from '@/features/auth/client'; +import { useSession } from '@/features/auth/use-session'; export const ChangeNameDrawer = (props: { children: ReactElement }) => { const { t } = useTranslation(['account']); const [open, setOpen] = useState(false); - const session = authClient.useSession(); + const session = useSession(); const form = useForm({ resolver: zodResolver(zFormFieldsAccountUpdateName()), values: { diff --git a/src/features/account/user-card.tsx b/src/features/account/user-card.tsx index 751169968..460418c7f 100644 --- a/src/features/account/user-card.tsx +++ b/src/features/account/user-card.tsx @@ -8,12 +8,12 @@ import { Card, CardAction, CardHeader, CardTitle } from '@/components/ui/card'; import { AccountCardRow } from '@/features/account/account-card-row'; import { ChangeNameDrawer } from '@/features/account/change-name-drawer'; -import { authClient } from '@/features/auth/client'; import { ConfirmSignOut } from '@/features/auth/confirm-signout'; +import { useSession } from '@/features/auth/use-session'; export const UserCard = () => { const { t } = useTranslation(['auth', 'account']); - const session = authClient.useSession(); + const session = useSession(); return ( diff --git a/src/features/auth/guard-authenticated.tsx b/src/features/auth/guard-authenticated.tsx index 9f355ae98..f3340d96c 100644 --- a/src/features/auth/guard-authenticated.tsx +++ b/src/features/auth/guard-authenticated.tsx @@ -1,5 +1,5 @@ import { useRouter } from '@tanstack/react-router'; -import { ReactNode } from 'react'; +import { ReactNode, useEffect } from 'react'; import { PageError } from '@/components/errors/page-error'; import { Spinner } from '@/components/ui/spinner'; @@ -7,6 +7,7 @@ import { Spinner } from '@/components/ui/spinner'; import { authClient } from '@/features/auth/client'; import { PageOnboarding } from '@/features/auth/page-onboarding'; import { Permission, Role } from '@/features/auth/permissions'; +import { useSession } from '@/features/auth/use-session'; export const GuardAuthenticated = ({ children, @@ -15,9 +16,26 @@ export const GuardAuthenticated = ({ children?: ReactNode; permissionApps?: Permission['apps']; }) => { - const session = authClient.useSession(); + const session = useSession(); const router = useRouter(); + useEffect(() => { + if ( + session.isPending || + (session.error && session.error.status > 0) || + session.data?.user + ) + return; + + router.navigate({ + to: '/login', + replace: true, + search: { + redirect: location.href, + }, + }); + }, [router, session.isPending, session.data?.user, session.error]); + if (session.isPending) { return ; } @@ -27,22 +45,13 @@ export const GuardAuthenticated = ({ } if (!session.data?.user) { - router.navigate({ - to: '/login', - replace: true, - search: { - redirect: location.href, - }, - }); return null; } - // Check if onboarding is done if (!session.data.user.onboardedAt) { return ; } - // Unauthorized if the user permission do not match if ( permissionApps && !authClient.admin.checkRolePermission({ diff --git a/src/features/auth/guard-public-only.tsx b/src/features/auth/guard-public-only.tsx index 38430bae8..eb35eadb1 100644 --- a/src/features/auth/guard-public-only.tsx +++ b/src/features/auth/guard-public-only.tsx @@ -3,11 +3,11 @@ import { Activity, ReactNode } from 'react'; import { PageError } from '@/components/errors/page-error'; import { Spinner } from '@/components/ui/spinner'; -import { authClient } from '@/features/auth/client'; +import { useSession } from '@/features/auth/use-session'; import { useRedirectAfterLogin } from '@/features/auth/utils'; export const GuardPublicOnly = ({ children }: { children?: ReactNode }) => { - const session = authClient.useSession(); + const session = useSession(); useRedirectAfterLogin(); if (session.error && session.error.status > 0) { diff --git a/src/features/auth/page-login-verify.tsx b/src/features/auth/page-login-verify.tsx index 27ab0fbea..f2077b996 100644 --- a/src/features/auth/page-login-verify.tsx +++ b/src/features/auth/page-login-verify.tsx @@ -24,6 +24,7 @@ import { FormFieldsLoginVerify, zFormFieldsLoginVerify, } from '@/features/auth/schema'; +import { useSession } from '@/features/auth/use-session'; import { LoginEmailOtpHint } from '@/features/devtools/login-hint'; const I18N_KEY_PAGE_PREFIX = AUTH_SIGNUP_ENABLED @@ -36,7 +37,7 @@ export default function PageLoginVerify({ search: { redirect?: string; email: string }; }) { const { t } = useTranslation(['auth', 'common']); - const session = authClient.useSession(); + const session = useSession(); const form = useForm({ mode: 'onSubmit', diff --git a/src/features/auth/page-logout.tsx b/src/features/auth/page-logout.tsx index 87da0f442..4cd883493 100644 --- a/src/features/auth/page-logout.tsx +++ b/src/features/auth/page-logout.tsx @@ -6,10 +6,11 @@ import { PageError } from '@/components/errors/page-error'; import { Spinner } from '@/components/ui/spinner'; import { authClient } from '@/features/auth/client'; +import { useSession } from '@/features/auth/use-session'; export const PageLogout = () => { const navigate = useNavigate(); - const session = authClient.useSession(); + const session = useSession(); const { mutate, error } = useMutation({ mutationFn: async () => { const response = await authClient.signOut(); @@ -20,7 +21,7 @@ export const PageLogout = () => { }, onSuccess: () => { navigate({ - to: '/', + to: '/login', }); }, }); diff --git a/src/features/auth/page-onboarding.tsx b/src/features/auth/page-onboarding.tsx index 1f6921a9d..cd21b8a6c 100644 --- a/src/features/auth/page-onboarding.tsx +++ b/src/features/auth/page-onboarding.tsx @@ -15,15 +15,15 @@ import { } from '@/components/form'; import { Button } from '@/components/ui/button'; -import { authClient } from '@/features/auth/client'; import { ConfirmSignOut } from '@/features/auth/confirm-signout'; import { LayoutLogin } from '@/features/auth/layout-login'; import { useMascot } from '@/features/auth/mascot'; import { zFormFieldsOnboarding } from '@/features/auth/schema'; +import { useSession } from '@/features/auth/use-session'; export const PageOnboarding = () => { const { t } = useTranslation(['auth']); - const session = authClient.useSession(); + const session = useSession(); const submitOnboarding = useMutation( orpc.account.submitOnboarding.mutationOptions({ diff --git a/src/features/auth/session.ts b/src/features/auth/session.ts new file mode 100644 index 000000000..fce6b6bec --- /dev/null +++ b/src/features/auth/session.ts @@ -0,0 +1,20 @@ +import { createServerFn } from '@tanstack/react-start'; +import { getRequestHeaders } from '@tanstack/react-start/server'; + +import { auth } from '@/server/auth'; + +export const initAuthSsr = createServerFn({ method: 'GET' }).handler( + async () => { + const authSession = await auth.api.getSession({ + headers: getRequestHeaders(), + }); + + return { + authSession, + }; + } +); + +export type AuthSession = Awaited< + ReturnType +>['authSession']; diff --git a/src/features/auth/use-session.ts b/src/features/auth/use-session.ts new file mode 100644 index 000000000..e87007aee --- /dev/null +++ b/src/features/auth/use-session.ts @@ -0,0 +1,26 @@ +import { authClient } from '@/features/auth/client'; +import { RootRouteApi } from '@/routes/__root'; + +/** + * Wraps `authClient.useSession()` and, while the client-side session hook is + * still pending on first mount, falls back to the session pre-fetched on the + * server in the root route `beforeLoad`. This prevents content shifts after a + * refresh for components that read session data directly (UserCard, NavUser, + * onboarding, etc.) — they get real data on the very first render. + * + * Drop-in replacement for `authClient.useSession()`. + */ +export const useSession = () => { + const session = authClient.useSession(); + const { authSession } = RootRouteApi.useRouteContext(); + + if (session.isPending && authSession?.session) { + return { + ...session, + data: authSession satisfies typeof session.data, + isPending: false, + }; + } + + return session; +}; diff --git a/src/features/auth/utils.ts b/src/features/auth/utils.ts index 64b2803f2..aff3354d7 100644 --- a/src/features/auth/utils.ts +++ b/src/features/auth/utils.ts @@ -3,11 +3,12 @@ import { useEffect } from 'react'; import { authClient } from '@/features/auth/client'; import { Role } from '@/features/auth/permissions'; +import { useSession } from '@/features/auth/use-session'; export const useRedirectAfterLogin = () => { const search = useSearch({ strict: false }); const router = useRouter(); - const session = authClient.useSession(); + const session = useSession(); const searchRedirect = search.redirect; useEffect(() => { diff --git a/src/features/auth/with-permissions.tsx b/src/features/auth/with-permissions.tsx index b95da398b..b851739d4 100644 --- a/src/features/auth/with-permissions.tsx +++ b/src/features/auth/with-permissions.tsx @@ -2,6 +2,7 @@ import { ReactNode } from 'react'; import { authClient } from '@/features/auth/client'; import { Permission, Role } from '@/features/auth/permissions'; +import { useSession } from '@/features/auth/use-session'; export const WithPermissions = (props: { permissions: Permission[]; @@ -9,7 +10,7 @@ export const WithPermissions = (props: { loadingFallback?: ReactNode; fallback?: ReactNode; }) => { - const session = authClient.useSession(); + const session = useSession(); const userRole = session.data?.user.role; if (session.isPending) { diff --git a/src/features/demo/demo-app-switch.tsx b/src/features/demo/demo-app-switch.tsx index 11d8f5e8d..94d5f75ca 100644 --- a/src/features/demo/demo-app-switch.tsx +++ b/src/features/demo/demo-app-switch.tsx @@ -17,10 +17,11 @@ import { import { authClient } from '@/features/auth/client'; import { Role } from '@/features/auth/permissions'; +import { useSession } from '@/features/auth/use-session'; export const DemoAppSwitch = () => { const { t } = useTranslation(['demo']); - const session = authClient.useSession(); + const session = useSession(); const userRole = session.data?.user.role; const matchRoute = useMatchRoute(); const currentApp = matchRoute({ to: '/manager', fuzzy: true }) diff --git a/src/features/user/manager/form-user.tsx b/src/features/user/manager/form-user.tsx index e197bfbb0..302144dc8 100644 --- a/src/features/user/manager/form-user.tsx +++ b/src/features/user/manager/form-user.tsx @@ -8,13 +8,13 @@ import { FormFieldLabel, } from '@/components/form'; -import { authClient } from '@/features/auth/client'; import { rolesNames } from '@/features/auth/permissions'; +import { useSession } from '@/features/auth/use-session'; import { FormFieldsUser } from '@/features/user/schema'; export const FormUser = (props: { userId?: string }) => { const { t } = useTranslation(['user']); - const session = authClient.useSession(); + const session = useSession(); const form = useFormContext(); const isCurrentUser = props.userId === session.data?.user.id; diff --git a/src/features/user/manager/page-user-new.tsx b/src/features/user/manager/page-user-new.tsx index 16727c9da..3c4f0e1fd 100644 --- a/src/features/user/manager/page-user-new.tsx +++ b/src/features/user/manager/page-user-new.tsx @@ -1,12 +1,12 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { ORPCError } from '@orpc/client'; import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useRouter } from '@tanstack/react-router'; import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import { orpc } from '@/lib/orpc/client'; -import { useNavigateBack } from '@/hooks/use-navigate-back'; import { BackButton } from '@/components/back-button'; import { Form } from '@/components/form'; @@ -25,7 +25,7 @@ import { export const PageUserNew = () => { const { t } = useTranslation(['user']); - const { navigateBack } = useNavigateBack(); + const router = useRouter(); const queryClient = useQueryClient(); const form = useForm({ resolver: zodResolver(zFormFieldsUser()), @@ -45,8 +45,10 @@ export const PageUserNew = () => { type: 'all', }); - // Redirect - navigateBack({ ignoreBlocker: true }); + await router.navigate({ + to: '/manager/users', + ignoreBlocker: true, + }); }, onError: (error) => { if ( diff --git a/src/features/user/manager/page-user-update.tsx b/src/features/user/manager/page-user-update.tsx index d25f4af7d..7be2048d4 100644 --- a/src/features/user/manager/page-user-update.tsx +++ b/src/features/user/manager/page-user-update.tsx @@ -17,7 +17,7 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; -import { authClient } from '@/features/auth/client'; +import { useSession } from '@/features/auth/use-session'; import { FormUser } from '@/features/user/manager/form-user'; import { zFormFieldsUser } from '@/features/user/schema'; import { @@ -30,7 +30,7 @@ import { export const PageUserUpdate = (props: { params: { id: string } }) => { const { t } = useTranslation(['user']); const { navigateBack } = useNavigateBack(); - const session = authClient.useSession(); + const session = useSession(); const queryClient = useQueryClient(); const userQuery = useQuery( orpc.user.getById.queryOptions({ input: { id: props.params.id } }) diff --git a/src/features/user/manager/page-user.tsx b/src/features/user/manager/page-user.tsx index ccf2a138a..4b00cd47f 100644 --- a/src/features/user/manager/page-user.tsx +++ b/src/features/user/manager/page-user.tsx @@ -41,7 +41,7 @@ import { ResponsiveIconButton } from '@/components/ui/responsive-icon-button'; import { Skeleton } from '@/components/ui/skeleton'; import { Spinner } from '@/components/ui/spinner'; -import { authClient } from '@/features/auth/client'; +import { useSession } from '@/features/auth/use-session'; import { WithPermissions } from '@/features/auth/with-permissions'; import { PageLayout, @@ -53,7 +53,7 @@ import { export const PageUser = (props: { params: { id: string } }) => { const queryClient = useQueryClient(); const { navigateBack } = useNavigateBack(); - const session = authClient.useSession(); + const session = useSession(); const { t } = useTranslation(['user']); const userQuery = useQuery( orpc.user.getById.queryOptions({ @@ -353,7 +353,7 @@ const UserSessions = (props: { userId: string }) => { const RevokeAllSessionsButton = (props: { userId: string }) => { const queryClient = useQueryClient(); - const currentSession = authClient.useSession(); + const currentSession = useSession(); const { t } = useTranslation(['user']); const revokeAllSessions = useMutation( orpc.user.revokeUserSessions.mutationOptions({ @@ -393,7 +393,7 @@ const RevokeSessionButton = (props: { sessionToken: string; }) => { const queryClient = useQueryClient(); - const currentSession = authClient.useSession(); + const currentSession = useSession(); const { t } = useTranslation(['user']); const revokeSession = useMutation( orpc.user.revokeUserSession.mutationOptions({ diff --git a/src/layout/manager/nav-user.tsx b/src/layout/manager/nav-user.tsx index 221b629df..6ff2320c8 100644 --- a/src/layout/manager/nav-user.tsx +++ b/src/layout/manager/nav-user.tsx @@ -33,8 +33,8 @@ import { } from '@/components/ui/sidebar'; import { themes } from '@/components/ui/theme-switcher'; -import { authClient } from '@/features/auth/client'; import { ConfirmSignOut } from '@/features/auth/confirm-signout'; +import { useSession } from '@/features/auth/use-session'; import { WithPermissions } from '@/features/auth/with-permissions'; import { BuildInfoDrawer } from '@/features/build-info/build-info-drawer'; import { BuildInfoVersion } from '@/features/build-info/build-info-version'; @@ -42,7 +42,7 @@ import { BuildInfoVersion } from '@/features/build-info/build-info-version'; export function NavUser() { const { t } = useTranslation(['common', 'auth', 'layout']); const { isMobile } = useSidebar(); - const session = authClient.useSession(); + const session = useSession(); const { setOpenMobile } = useSidebar(); const { theme, setTheme } = useTheme(); @@ -111,12 +111,15 @@ export function NavUser() { - + {themes.map((item) => ( ( )) diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index d7ffe98bd..cba1ae5ea 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -4,6 +4,7 @@ import { QueryClient } from '@tanstack/react-query'; import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools'; import { createRootRouteWithContext, + getRouteApi, HeadContent, Outlet, Scripts, @@ -20,6 +21,7 @@ import { AVAILABLE_LANGUAGES } from '@/lib/i18n/constants'; import { PageError } from '@/components/errors/page-error'; import { MailDevDevtoolPanel } from '@/devtools/maildev'; +import { type AuthSession, initAuthSsr } from '@/features/auth/session'; import { EnvHint } from '@/features/devtools/env-hint'; import { Providers } from '@/providers'; import { getUserLanguage } from '@/server/utils'; @@ -34,6 +36,13 @@ const initSsrApp = createServerFn({ method: 'GET' }).handler(() => { export const Route = createRootRouteWithContext<{ queryClient: QueryClient; }>()({ + beforeLoad: async (): Promise<{ authSession: AuthSession | null }> => { + if (!import.meta.env.SSR) { + return { authSession: null }; + } + const { authSession } = await initAuthSsr(); + return { authSession }; + }, loader: async () => { // Setup language and theme in SSR to prevent hydratation errors if (import.meta.env.SSR) { @@ -98,6 +107,8 @@ export const Route = createRootRouteWithContext<{ }), }); +export const RootRouteApi = getRouteApi('__root__'); + function RootComponent() { return ( @@ -143,7 +154,7 @@ function RootDocument({ children }: Readonly<{ children: ReactNode }>) { : undefined, }} > - + diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 5a3a55b75..eb3a9d69b 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,9 +1,30 @@ import { createFileRoute, redirect } from '@tanstack/react-router'; +import { authClient } from '@/features/auth/client'; +import { Role } from '@/features/auth/permissions'; +import { initAuthSsr } from '@/features/auth/session'; + export const Route = createFileRoute('/')({ component: RouteComponent, - beforeLoad: () => { - throw redirect({ to: '/login' }); + beforeLoad: async () => { + const { authSession } = await initAuthSsr(); + + if (!authSession?.session) { + throw redirect({ to: '/login' }); + } + + if ( + authClient.admin.checkRolePermission({ + role: authSession.user.role as Role, + permissions: { + apps: ['manager'], + }, + }) + ) { + throw redirect({ to: '/manager' }); + } + + throw redirect({ to: '/app' }); }, });