diff --git a/web/src/components/MemoActionMenu/hooks.ts b/web/src/components/MemoActionMenu/hooks.ts index 53c5c80e8..b0fecaabb 100644 --- a/web/src/components/MemoActionMenu/hooks.ts +++ b/web/src/components/MemoActionMenu/hooks.ts @@ -4,7 +4,7 @@ import { useCallback } from "react"; import toast from "react-hot-toast"; import { useLocation } from "react-router-dom"; import { useInstance } from "@/contexts/InstanceContext"; -import { memoKeys, useDeleteMemo, useUpdateMemo } from "@/hooks/useMemoQueries"; +import { useDeleteMemo, useUpdateMemo } from "@/hooks/useMemoQueries"; import useNavigateTo from "@/hooks/useNavigateTo"; import { userKeys } from "@/hooks/useUserQueries"; import { State } from "@/types/proto/api/v1/common_pb"; diff --git a/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx b/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx index 3c13361a2..f236d0e84 100644 --- a/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx +++ b/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx @@ -3,7 +3,7 @@ import { timestampDate } from "@bufbuild/protobuf/wkt"; import { isEqual } from "lodash-es"; import { CheckCircleIcon, Code2Icon, HashIcon, LinkIcon } from "lucide-react"; import { cn } from "@/lib/utils"; -import { Memo, Memo_Property, Memo_PropertySchema, MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb"; +import { Memo, Memo_PropertySchema, MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb"; import { useTranslate } from "@/utils/i18n"; import MemoRelationForceGraph from "../MemoRelationForceGraph"; diff --git a/web/src/components/MemoEditor/components/LocationDialog.tsx b/web/src/components/MemoEditor/components/LocationDialog.tsx index 33e49f757..da5a088bd 100644 --- a/web/src/components/MemoEditor/components/LocationDialog.tsx +++ b/web/src/components/MemoEditor/components/LocationDialog.tsx @@ -25,7 +25,7 @@ export const LocationDialog = ({ open, onOpenChange, state, - locationInitialized, + locationInitialized: _locationInitialized, onPositionChange, onUpdateCoordinate, onPlaceholderChange, diff --git a/web/src/components/MemoEditor/hooks/useKeyboard.ts b/web/src/components/MemoEditor/hooks/useKeyboard.ts index 5eacbad2a..1cd22ada3 100644 --- a/web/src/components/MemoEditor/hooks/useKeyboard.ts +++ b/web/src/components/MemoEditor/hooks/useKeyboard.ts @@ -7,7 +7,7 @@ interface UseKeyboardOptions { onToggleFocusMode?: () => void; } -export const useKeyboard = (editorRef: React.RefObject, options: UseKeyboardOptions) => { +export const useKeyboard = (_editorRef: React.RefObject, options: UseKeyboardOptions) => { useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { // Cmd/Ctrl + Enter to save diff --git a/web/src/components/MemoEditor/hooks/useLinkMemo.ts b/web/src/components/MemoEditor/hooks/useLinkMemo.ts index 60f881787..fd1524136 100644 --- a/web/src/components/MemoEditor/hooks/useLinkMemo.ts +++ b/web/src/components/MemoEditor/hooks/useLinkMemo.ts @@ -5,14 +5,7 @@ import { memoServiceClient } from "@/connect"; import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts"; import { extractUserIdFromName } from "@/helpers/resource-names"; import useCurrentUser from "@/hooks/useCurrentUser"; -import { - Memo, - MemoRelation, - MemoRelation_Memo, - MemoRelation_MemoSchema, - MemoRelation_Type, - MemoRelationSchema, -} from "@/types/proto/api/v1/memo_service_pb"; +import { Memo, MemoRelation, MemoRelation_MemoSchema, MemoRelation_Type, MemoRelationSchema } from "@/types/proto/api/v1/memo_service_pb"; interface UseLinkMemoParams { isOpen: boolean; diff --git a/web/src/components/MemoEditor/state/actions.ts b/web/src/components/MemoEditor/state/actions.ts index fc405d54c..89296f7da 100644 --- a/web/src/components/MemoEditor/state/actions.ts +++ b/web/src/components/MemoEditor/state/actions.ts @@ -1,6 +1,6 @@ import type { LocalFile } from "@/components/memo-metadata"; import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; -import type { Location, MemoRelation } from "@/types/proto/api/v1/memo_service_pb"; +import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb"; import type { EditorAction, EditorState, LoadingKey } from "./types"; export const editorActions = { diff --git a/web/src/components/Settings/MemberSection.tsx b/web/src/components/Settings/MemberSection.tsx index ced068cf8..b339b1f49 100644 --- a/web/src/components/Settings/MemberSection.tsx +++ b/web/src/components/Settings/MemberSection.tsx @@ -2,7 +2,7 @@ import { create } from "@bufbuild/protobuf"; import { FieldMaskSchema } from "@bufbuild/protobuf/wkt"; import { sortBy } from "lodash-es"; import { MoreVerticalIcon, PlusIcon } from "lucide-react"; -import React, { useState } from "react"; +import { useState } from "react"; import toast from "react-hot-toast"; import ConfirmDialog from "@/components/ConfirmDialog"; import { Button } from "@/components/ui/button"; diff --git a/web/src/components/UpdateAccountDialog.tsx b/web/src/components/UpdateAccountDialog.tsx index 3faf9ea30..c75026528 100644 --- a/web/src/components/UpdateAccountDialog.tsx +++ b/web/src/components/UpdateAccountDialog.tsx @@ -1,4 +1,3 @@ -import { create } from "@bufbuild/protobuf"; import { isEqual } from "lodash-es"; import { XIcon } from "lucide-react"; import { useState } from "react"; @@ -12,7 +11,6 @@ import { useInstance } from "@/contexts/InstanceContext"; import { convertFileToBase64 } from "@/helpers/utils"; import useCurrentUser from "@/hooks/useCurrentUser"; import { useUpdateUser } from "@/hooks/useUserQueries"; -import { User as UserPb, UserSchema } from "@/types/proto/api/v1/user_service_pb"; import { useTranslate } from "@/utils/i18n"; import UserAvatar from "./UserAvatar"; diff --git a/web/src/components/UserMenu.tsx b/web/src/components/UserMenu.tsx index 43639901b..3bb9b56f3 100644 --- a/web/src/components/UserMenu.tsx +++ b/web/src/components/UserMenu.tsx @@ -89,8 +89,8 @@ const UserMenu = (props: Props) => { try { // Then clear user-specific localStorage items - // Preserve app-wide settings like theme - const keysToPreserve = ["memos-theme", "tag-view-as-tree", "tag-tree-auto-expand", "viewStore"]; + // Preserve app-wide settings (theme, locale, view preferences, tag view settings) + const keysToPreserve = ["memos-theme", "memos-locale", "memos-view-setting", "tag-view-as-tree", "tag-tree-auto-expand"]; const keysToRemove: string[] = []; for (let i = 0; i < localStorage.length; i++) { @@ -105,8 +105,8 @@ const UserMenu = (props: Props) => { // Ignore errors from localStorage operations } - // Always redirect to auth page - window.location.href = Routes.AUTH; + // Always redirect to auth page (use replace to prevent back navigation) + window.location.replace(Routes.AUTH); }; return ( diff --git a/web/src/connect.ts b/web/src/connect.ts index 125385cfc..1a8cb7177 100644 --- a/web/src/connect.ts +++ b/web/src/connect.ts @@ -2,8 +2,6 @@ import { timestampDate } from "@bufbuild/protobuf/wkt"; import { Code, ConnectError, createClient, type Interceptor } from "@connectrpc/connect"; import { createConnectTransport } from "@connectrpc/connect-web"; import { getAccessToken, setAccessToken } from "./auth-state"; -import { getInstanceConfig } from "./instance-config"; -import { ROUTES } from "./router/routes"; import { ActivityService } from "./types/proto/api/v1/activity_service_pb"; import { AttachmentService } from "./types/proto/api/v1/attachment_service_pb"; import { AuthService } from "./types/proto/api/v1/auth_service_pb"; @@ -12,6 +10,7 @@ import { InstanceService } from "./types/proto/api/v1/instance_service_pb"; import { MemoService } from "./types/proto/api/v1/memo_service_pb"; import { ShortcutService } from "./types/proto/api/v1/shortcut_service_pb"; import { UserService } from "./types/proto/api/v1/user_service_pb"; +import { redirectOnAuthFailure } from "./utils/auth-redirect"; // ============================================================================ // Constants @@ -20,19 +19,6 @@ import { UserService } from "./types/proto/api/v1/user_service_pb"; const RETRY_HEADER = "X-Retry"; const RETRY_HEADER_VALUE = "true"; -const ROUTE_CONFIG = { - // Routes accessible without authentication (uses prefix matching) - public: [ - ROUTES.AUTH, // Authentication pages - ROUTES.EXPLORE, // Explore page - "/u/", // User profile pages (dynamic) - "/memos/", // Individual memo detail pages (dynamic) - ], - - // Routes that require authentication (uses exact matching) - private: [ROUTES.ROOT, ROUTES.ATTACHMENTS, ROUTES.INBOX, ROUTES.ARCHIVED, ROUTES.SETTING], -} as const; - // ============================================================================ // Token Refresh State Management // ============================================================================ @@ -60,42 +46,6 @@ const createTokenRefreshManager = () => { const tokenRefreshManager = createTokenRefreshManager(); -// ============================================================================ -// Route Access Control -// ============================================================================ - -function isPublicRoute(path: string): boolean { - return ROUTE_CONFIG.public.some((route) => path.startsWith(route)); -} - -function isPrivateRoute(path: string): boolean { - return (ROUTE_CONFIG.private as readonly string[]).includes(path); -} - -function getAuthFailureRedirect(currentPath: string): string | null { - if (isPublicRoute(currentPath)) { - return null; - } - - if (getInstanceConfig().memoRelatedSetting.disallowPublicVisibility) { - return ROUTES.AUTH; - } - - if (isPrivateRoute(currentPath)) { - return ROUTES.EXPLORE; - } - - return null; -} - -function performRedirect(redirectUrl: string | null): void { - if (redirectUrl) { - // Use replace() instead of href to prevent back button from showing cached sensitive data - // This removes the current page from browser history after authentication failure - window.location.replace(redirectUrl); - } -} - // ============================================================================ // Token Refresh // ============================================================================ @@ -165,8 +115,7 @@ const authInterceptor: Interceptor = (next) => async (req) => { req.header.set(RETRY_HEADER, RETRY_HEADER_VALUE); return await next(req); } catch (refreshError) { - const redirectUrl = getAuthFailureRedirect(window.location.pathname); - performRedirect(redirectUrl); + redirectOnAuthFailure(); throw refreshError; } } diff --git a/web/src/hooks/useMediaQuery.ts b/web/src/hooks/useMediaQuery.ts index af8df11a1..ed5e1986b 100644 --- a/web/src/hooks/useMediaQuery.ts +++ b/web/src/hooks/useMediaQuery.ts @@ -32,4 +32,3 @@ const useMediaQuery = (breakpoint: Breakpoint): boolean => { }; export default useMediaQuery; - diff --git a/web/src/layouts/RootLayout.tsx b/web/src/layouts/RootLayout.tsx index 6568172e7..cf2454a83 100644 --- a/web/src/layouts/RootLayout.tsx +++ b/web/src/layouts/RootLayout.tsx @@ -1,4 +1,4 @@ -import { Suspense, useEffect, useMemo, useState } from "react"; +import { Suspense, useEffect, useMemo } from "react"; import { Outlet, useLocation, useSearchParams } from "react-router-dom"; import usePrevious from "react-use/lib/usePrevious"; import Navigation from "@/components/Navigation"; @@ -8,7 +8,7 @@ import useCurrentUser from "@/hooks/useCurrentUser"; import useMediaQuery from "@/hooks/useMediaQuery"; import { cn } from "@/lib/utils"; import Loading from "@/pages/Loading"; -import { Routes } from "@/router"; +import { redirectOnAuthFailure } from "@/utils/auth-redirect"; const RootLayout = () => { const location = useLocation(); @@ -17,25 +17,14 @@ const RootLayout = () => { const currentUser = useCurrentUser(); const { memoRelatedSetting } = useInstance(); const { removeFilter } = useMemoFilterContext(); - const [initialized, setInitialized] = useState(false); const pathname = useMemo(() => location.pathname, [location.pathname]); const prevPathname = usePrevious(pathname); useEffect(() => { - if (!currentUser) { - // If disallowPublicVisibility is enabled, redirect to login - if (memoRelatedSetting.disallowPublicVisibility) { - window.location.replace(Routes.AUTH); - return; - } else if ( - ([Routes.ROOT, Routes.ATTACHMENTS, Routes.INBOX, Routes.ARCHIVED, Routes.SETTING] as string[]).includes(location.pathname) - ) { - window.location.replace(Routes.EXPLORE); - return; - } + if (!currentUser && memoRelatedSetting.disallowPublicVisibility) { + redirectOnAuthFailure(); } - setInitialized(true); - }, [currentUser, memoRelatedSetting.disallowPublicVisibility, location.pathname]); + }, [currentUser, memoRelatedSetting.disallowPublicVisibility]); useEffect(() => { // When the route changes and there is no filter in the search params, remove all filters @@ -44,9 +33,7 @@ const RootLayout = () => { } }, [prevPathname, pathname, searchParams, removeFilter]); - return !initialized ? ( - - ) : ( + return (
{sm && (
{ setShowCommentEditor(true); }; - const handleCommentCreated = async (memoCommentName: string) => { + const handleCommentCreated = async (_memoCommentName: string) => { // React Query will auto-refetch due to invalidation in the mutation setShowCommentEditor(false); }; diff --git a/web/src/utils/auth-redirect.ts b/web/src/utils/auth-redirect.ts new file mode 100644 index 000000000..a352576f2 --- /dev/null +++ b/web/src/utils/auth-redirect.ts @@ -0,0 +1,36 @@ +import { getInstanceConfig } from "@/instance-config"; +import { ROUTES } from "@/router/routes"; + +const PUBLIC_ROUTES = [ + ROUTES.AUTH, // Authentication pages + ROUTES.EXPLORE, // Explore page + "/u/", // User profile pages (dynamic) + "/memos/", // Individual memo detail pages (dynamic) +] as const; + +const PRIVATE_ROUTES = [ROUTES.ROOT, ROUTES.ATTACHMENTS, ROUTES.INBOX, ROUTES.ARCHIVED, ROUTES.SETTING] as const; + +function isPublicRoute(path: string): boolean { + return PUBLIC_ROUTES.some((route) => path.startsWith(route)); +} + +function isPrivateRoute(path: string): boolean { + return PRIVATE_ROUTES.includes(path as any); +} + +export function redirectOnAuthFailure(): void { + const currentPath = window.location.pathname; + + // Don't redirect if it's a public route + if (isPublicRoute(currentPath)) { + return; + } + + const disallowPublicVisibility = getInstanceConfig().memoRelatedSetting.disallowPublicVisibility; + const target = disallowPublicVisibility ? ROUTES.AUTH : ROUTES.EXPLORE; + + // Only redirect if it's a private route or disallowPublicVisibility is enabled + if (disallowPublicVisibility || isPrivateRoute(currentPath)) { + window.location.replace(target); + } +} diff --git a/web/tsconfig.json b/web/tsconfig.json index 56a269f53..44b35c12d 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -8,6 +8,8 @@ "esModuleInterop": false, "allowSyntheticDefaultImports": true, "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, "forceConsistentCasingInFileNames": true, "module": "ESNext", "moduleResolution": "Node",