({
@@ -78,15 +89,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// If we still don't have a token after refresh attempt, skip getCurrentUser call
// to avoid unnecessary network request for unauthenticated users.
if (!getAccessToken()) {
- setState({
- currentUser: undefined,
- userGeneralSetting: undefined,
- userWebhooksSetting: undefined,
- userTagsSetting: undefined,
- shortcuts: [],
- isInitialized: true,
- isLoading: false,
- });
+ setState(UNAUTHENTICATED_STATE);
return;
}
@@ -95,15 +98,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (!currentUser) {
clearAccessToken();
- setState({
- currentUser: undefined,
- userGeneralSetting: undefined,
- userWebhooksSetting: undefined,
- userTagsSetting: undefined,
- shortcuts: [],
- isInitialized: true,
- isLoading: false,
- });
+ setState(UNAUTHENTICATED_STATE);
return;
}
@@ -122,15 +117,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} catch (error) {
console.error("Failed to initialize auth:", error);
clearAccessToken();
- setState({
- currentUser: undefined,
- userGeneralSetting: undefined,
- userWebhooksSetting: undefined,
- userTagsSetting: undefined,
- shortcuts: [],
- isInitialized: true,
- isLoading: false,
- });
+ setState(UNAUTHENTICATED_STATE);
}
}, [fetchUserSettings, queryClient]);
@@ -141,15 +128,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
console.error("[AuthContext] Failed to sign out:", error);
} finally {
clearAccessToken();
- setState({
- currentUser: undefined,
- userGeneralSetting: undefined,
- userWebhooksSetting: undefined,
- userTagsSetting: undefined,
- shortcuts: [],
- isInitialized: true,
- isLoading: false,
- });
+ setState(UNAUTHENTICATED_STATE);
queryClient.clear();
}
}, [queryClient]);
diff --git a/web/src/layouts/RootLayout.tsx b/web/src/layouts/RootLayout.tsx
index 02483b0e..25e52ef8 100644
--- a/web/src/layouts/RootLayout.tsx
+++ b/web/src/layouts/RootLayout.tsx
@@ -1,10 +1,12 @@
import { useEffect, useRef } from "react";
-import { Outlet, useLocation, useSearchParams } from "react-router-dom";
+import { Navigate, Outlet, useLocation, useSearchParams } from "react-router-dom";
import Navigation from "@/components/Navigation";
import { useInstance } from "@/contexts/InstanceContext";
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
+import useCurrentUser from "@/hooks/useCurrentUser";
import useMediaQuery from "@/hooks/useMediaQuery";
import { cn } from "@/lib/utils";
+import { buildAuthRoute, shouldGatePrivateInstance } from "@/utils/auth-redirect";
import { useTranslate } from "@/utils/i18n";
const MEMOS_DEPLOY_URL = "https://usememos.com/docs/deploy";
@@ -29,6 +31,7 @@ const RootLayout = () => {
const location = useLocation();
const [searchParams] = useSearchParams();
const sm = useMediaQuery("sm");
+ const currentUser = useCurrentUser();
const { profile } = useInstance();
const { removeFilter } = useMemoFilterContext();
const { pathname } = location;
@@ -45,6 +48,14 @@ const RootLayout = () => {
prevPathnameRef.current = pathname;
}, [pathname, searchParams, removeFilter]);
+ // Private instance (no InstanceURL configured): anonymous visitors may only reach
+ // share links; everything else redirects to the sign-in page, preserving the intended
+ // destination. Public instances keep the open Explore behavior for logged-out users.
+ if (shouldGatePrivateInstance({ isPrivateInstance: !profile.instanceUrl, isAuthenticated: !!currentUser, pathname })) {
+ const redirect = `${pathname}${location.search}${location.hash}`;
+ return ;
+ }
+
return (
{sm && (
diff --git a/web/src/utils/auth-redirect.ts b/web/src/utils/auth-redirect.ts
index c7f63a0f..9d195342 100644
--- a/web/src/utils/auth-redirect.ts
+++ b/web/src/utils/auth-redirect.ts
@@ -12,6 +12,7 @@ export {
buildAuthRoute,
getSafeRedirectPath,
isPublicRoute,
+ shouldGatePrivateInstance,
} from "./redirect-safety";
/**
diff --git a/web/src/utils/redirect-safety.ts b/web/src/utils/redirect-safety.ts
index 48fb134f..3a88c417 100644
--- a/web/src/utils/redirect-safety.ts
+++ b/web/src/utils/redirect-safety.ts
@@ -71,3 +71,19 @@ const PUBLIC_ROUTE_PREFIXES = [
export function isPublicRoute(path: string): boolean {
return PUBLIC_ROUTE_PREFIXES.some((route) => path.startsWith(route));
}
+
+/**
+ * Reports whether an anonymous visitor to a private instance should be redirected
+ * to the sign-in page for the given path.
+ *
+ * A private instance (no configured instance URL) hides everything from anonymous
+ * visitors except share-link pages, which stay accessible so public shares keep
+ * working. Authenticated visitors and open instances are never gated.
+ */
+export function shouldGatePrivateInstance(params: { isPrivateInstance: boolean; isAuthenticated: boolean; pathname: string }): boolean {
+ const { isPrivateInstance, isAuthenticated, pathname } = params;
+ if (!isPrivateInstance || isAuthenticated) {
+ return false;
+ }
+ return !pathname.startsWith(`${ROUTES.SHARED_MEMO}/`);
+}
diff --git a/web/tests/redirect-safety.test.ts b/web/tests/redirect-safety.test.ts
index fc1e0a57..a6c803dd 100644
--- a/web/tests/redirect-safety.test.ts
+++ b/web/tests/redirect-safety.test.ts
@@ -1,5 +1,11 @@
import { describe, expect, it } from "vitest";
-import { AUTH_REDIRECT_PARAM, buildAuthRoute, getSafeRedirectPath, isPublicRoute } from "@/utils/redirect-safety";
+import {
+ AUTH_REDIRECT_PARAM,
+ buildAuthRoute,
+ getSafeRedirectPath,
+ isPublicRoute,
+ shouldGatePrivateInstance,
+} from "@/utils/redirect-safety";
describe("getSafeRedirectPath", () => {
it("accepts safe same-origin internal paths", () => {
@@ -75,3 +81,24 @@ describe("isPublicRoute", () => {
expect(isPublicRoute("/archived")).toBe(false);
});
});
+
+describe("shouldGatePrivateInstance", () => {
+ it("never gates on an open (public) instance", () => {
+ expect(shouldGatePrivateInstance({ isPrivateInstance: false, isAuthenticated: false, pathname: "/" })).toBe(false);
+ expect(shouldGatePrivateInstance({ isPrivateInstance: false, isAuthenticated: false, pathname: "/explore" })).toBe(false);
+ });
+
+ it("never gates an authenticated visitor", () => {
+ expect(shouldGatePrivateInstance({ isPrivateInstance: true, isAuthenticated: true, pathname: "/explore" })).toBe(false);
+ });
+
+ it("gates anonymous visitors to non-share pages on a private instance", () => {
+ for (const pathname of ["/", "/explore", "/about", "/memos/abc", "/u/steven", "/setting"]) {
+ expect(shouldGatePrivateInstance({ isPrivateInstance: true, isAuthenticated: false, pathname })).toBe(true);
+ }
+ });
+
+ it("keeps share links reachable for anonymous visitors on a private instance", () => {
+ expect(shouldGatePrivateInstance({ isPrivateInstance: true, isAuthenticated: false, pathname: "/memos/shares/token123" })).toBe(false);
+ });
+});