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' });
},
});