diff --git a/web/src/components/PasswordSignInForm.tsx b/web/src/components/PasswordSignInForm.tsx index 127636f17..862aa6a44 100644 --- a/web/src/components/PasswordSignInForm.tsx +++ b/web/src/components/PasswordSignInForm.tsx @@ -11,9 +11,14 @@ import { useInstance } from "@/contexts/InstanceContext"; import useLoading from "@/hooks/useLoading"; import useNavigateTo from "@/hooks/useNavigateTo"; import { handleError } from "@/lib/error"; +import { ROUTES } from "@/router/routes"; import { useTranslate } from "@/utils/i18n"; -function PasswordSignInForm() { +interface PasswordSignInFormProps { + redirectPath?: string; +} + +function PasswordSignInForm({ redirectPath }: PasswordSignInFormProps) { const t = useTranslate(); const navigateTo = useNavigateTo(); const { profile } = useInstance(); @@ -59,7 +64,7 @@ function PasswordSignInForm() { setAccessToken(response.accessToken, response.accessTokenExpiresAt ? timestampDate(response.accessTokenExpiresAt) : undefined); } await initialize(); - navigateTo("/"); + navigateTo(redirectPath || ROUTES.ROOT, { replace: true }); } catch (error: unknown) { handleError(error, toast.error, { fallbackMessage: "Failed to sign in.", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 841adf60c..a75cfbc5a 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -10,6 +10,7 @@ "create-your-account": "Create your account", "host-tip": "You are registering as the Site Host.", "new-password": "New password", + "protected-memo-notice": "This memo is not public. Sign in to continue.", "repeat-new-password": "Repeat the new password", "sign-in-tip": "Already have an account?", "sign-up-tip": "Don't have an account yet?" @@ -160,6 +161,7 @@ "direction-asc": "Ascending", "direction-desc": "Descending", "display-time": "Display Time", + "failed-to-load": "Failed to load memo.", "filters": { "has-code": "hasCode", "has-link": "hasLink", diff --git a/web/src/locales/zh-Hans.json b/web/src/locales/zh-Hans.json index 22814d205..fd337223e 100644 --- a/web/src/locales/zh-Hans.json +++ b/web/src/locales/zh-Hans.json @@ -10,6 +10,7 @@ "create-your-account": "创建您的账户", "host-tip": "您正在注册为站点管理员。", "new-password": "新密码", + "protected-memo-notice": "此备忘录不是公开的。请先登录后继续。", "repeat-new-password": "重复新密码", "sign-in-tip": "已有账户?", "sign-up-tip": "还没有账户?" @@ -155,6 +156,7 @@ "direction-asc": "正序", "direction-desc": "倒序", "display-time": "展示时间", + "failed-to-load": "加载备忘录失败。", "filters": { "has-code": "有代码", "has-link": "有链接", diff --git a/web/src/locales/zh-Hant.json b/web/src/locales/zh-Hant.json index aada603f1..cfc9426fc 100644 --- a/web/src/locales/zh-Hant.json +++ b/web/src/locales/zh-Hant.json @@ -10,6 +10,7 @@ "create-your-account": "建立您的帳號", "host-tip": "您即將註冊為網站管理員。", "new-password": "新密碼", + "protected-memo-notice": "此備忘錄不是公開的。請先登入後再繼續。", "repeat-new-password": "再次輸入新密碼", "sign-in-tip": "已經有帳戶了嗎?", "sign-up-tip": "還沒有帳戶嗎?" @@ -155,6 +156,7 @@ "direction-asc": "升序", "direction-desc": "降序", "display-time": "顯示時間", + "failed-to-load": "載入備忘錄失敗。", "filters": { "has-code": "有程式碼", "has-link": "有連結", diff --git a/web/src/pages/MemoDetail.tsx b/web/src/pages/MemoDetail.tsx index 8a6627cb3..f83068e8d 100644 --- a/web/src/pages/MemoDetail.tsx +++ b/web/src/pages/MemoDetail.tsx @@ -1,4 +1,4 @@ -import { ConnectError } from "@connectrpc/connect"; +import { Code, ConnectError } from "@connectrpc/connect"; import { ArrowUpLeftFromCircleIcon, MessageCircleIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { toast } from "react-hot-toast"; @@ -14,6 +14,7 @@ import useMediaQuery from "@/hooks/useMediaQuery"; import { useMemo, useMemoComments } from "@/hooks/useMemoQueries"; import useNavigateTo from "@/hooks/useNavigateTo"; import { cn } from "@/lib/utils"; +import { AUTH_REASON_PROTECTED_MEMO, redirectOnAuthFailure } from "@/utils/auth-redirect"; import { useTranslate } from "@/utils/i18n"; const MemoDetail = () => { @@ -21,7 +22,8 @@ const MemoDetail = () => { const md = useMediaQuery("md"); const params = useParams(); const navigateTo = useNavigateTo(); - const { state: locationState } = useLocation(); + const location = useLocation(); + const { state: locationState, hash } = location; const currentUser = useCurrentUser(); const uid = params.uid; const memoName = `${memoNamePrefix}${uid}`; @@ -31,10 +33,36 @@ const MemoDetail = () => { const { data: memo, error, isLoading } = useMemo(memoName, { enabled: !!memoName }); // Handle errors - if (error) { - toast.error((error as ConnectError).message); - navigateTo("/403"); - } + useEffect(() => { + if (!error) { + return; + } + + if (error instanceof ConnectError) { + if (error.code === Code.Unauthenticated) { + redirectOnAuthFailure(true, { + redirect: `${location.pathname}${location.search}${location.hash}`, + reason: AUTH_REASON_PROTECTED_MEMO, + }); + return; + } + + if (error.code === Code.PermissionDenied) { + navigateTo("/403", { replace: true }); + return; + } + + if (error.code === Code.NotFound) { + navigateTo("/404", { replace: true }); + return; + } + + toast.error(error.message); + return; + } + + toast.error(t("memo.failed-to-load")); + }, [error, location.hash, location.pathname, location.search, navigateTo, t]); // Fetch parent memo if exists const { data: parentMemo } = useMemo(memo?.parent || "", { @@ -47,7 +75,6 @@ const MemoDetail = () => { }); const comments = commentsResponse?.memos || []; - const { hash } = useLocation(); useEffect(() => { if (!hash || comments.length === 0) return; const el = document.getElementById(hash.slice(1)); diff --git a/web/src/pages/SignIn.tsx b/web/src/pages/SignIn.tsx index 2553e5072..8a38d71bd 100644 --- a/web/src/pages/SignIn.tsx +++ b/web/src/pages/SignIn.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { toast } from "react-hot-toast"; -import { Link } from "react-router-dom"; +import { Link, useSearchParams } from "react-router-dom"; import AuthFooter from "@/components/AuthFooter"; import PasswordSignInForm from "@/components/PasswordSignInForm"; import { Button } from "@/components/ui/button"; @@ -10,8 +10,9 @@ import { useInstance } from "@/contexts/InstanceContext"; import { absolutifyLink } from "@/helpers/utils"; import useCurrentUser from "@/hooks/useCurrentUser"; import { handleError } from "@/lib/error"; -import { Routes } from "@/router"; +import { ROUTES } from "@/router/routes"; import { IdentityProvider, IdentityProvider_Type } from "@/types/proto/api/v1/idp_service_pb"; +import { AUTH_REASON_PARAM, AUTH_REASON_PROTECTED_MEMO, AUTH_REDIRECT_PARAM, getSafeRedirectPath } from "@/utils/auth-redirect"; import { useTranslate } from "@/utils/i18n"; import { storeOAuthState } from "@/utils/oauth"; @@ -20,13 +21,17 @@ const SignIn = () => { const currentUser = useCurrentUser(); const [identityProviderList, setIdentityProviderList] = useState([]); const { generalSetting: instanceGeneralSetting } = useInstance(); + const [searchParams] = useSearchParams(); + const redirectTarget = getSafeRedirectPath(searchParams.get(AUTH_REDIRECT_PARAM)); + const authReason = searchParams.get(AUTH_REASON_PARAM); + const signUpPath = searchParams.toString() ? `${ROUTES.AUTH}/signup?${searchParams.toString()}` : `${ROUTES.AUTH}/signup`; // Redirect to root page if already signed in. useEffect(() => { if (currentUser?.name) { - window.location.href = Routes.ROOT; + window.location.href = redirectTarget || ROUTES.ROOT; } - }, [currentUser]); + }, [currentUser, redirectTarget]); // Prepare identity provider list. useEffect(() => { @@ -49,7 +54,7 @@ const SignIn = () => { try { // Generate and store secure state parameter with CSRF protection // Also generate PKCE parameters (code_challenge) for enhanced security if available - const { state, codeChallenge } = await storeOAuthState(identityProvider.name); + const { state, codeChallenge } = await storeOAuthState(identityProvider.name, redirectTarget); // Build OAuth authorization URL with secure state // Include PKCE if available (requires HTTPS/localhost for crypto.subtle) @@ -82,15 +87,20 @@ const SignIn = () => {

{instanceGeneralSetting.customProfile?.title || "Memos"}

+ {authReason === AUTH_REASON_PROTECTED_MEMO && ( +
+ {t("auth.protected-memo-notice")} +
+ )} {!instanceGeneralSetting.disallowPasswordAuth ? ( - + ) : ( identityProviderList.length === 0 &&

Password auth is not allowed.

)} {!instanceGeneralSetting.disallowUserRegistration && !instanceGeneralSetting.disallowPasswordAuth && (

{t("auth.sign-up-tip")} - + {t("common.sign-up")}

diff --git a/web/src/pages/SignUp.tsx b/web/src/pages/SignUp.tsx index b051da24e..cf02471c9 100644 --- a/web/src/pages/SignUp.tsx +++ b/web/src/pages/SignUp.tsx @@ -3,7 +3,7 @@ import { timestampDate } from "@bufbuild/protobuf/wkt"; import { LoaderIcon } from "lucide-react"; import { useState } from "react"; import { toast } from "react-hot-toast"; -import { Link } from "react-router-dom"; +import { Link, useSearchParams } from "react-router-dom"; import { setAccessToken } from "@/auth-state"; import AuthFooter from "@/components/AuthFooter"; import { Button } from "@/components/ui/button"; @@ -14,7 +14,9 @@ import { useInstance } from "@/contexts/InstanceContext"; import useLoading from "@/hooks/useLoading"; import useNavigateTo from "@/hooks/useNavigateTo"; import { handleError } from "@/lib/error"; +import { ROUTES } from "@/router/routes"; import { User_Role, UserSchema } from "@/types/proto/api/v1/user_service_pb"; +import { AUTH_REASON_PARAM, AUTH_REASON_PROTECTED_MEMO, AUTH_REDIRECT_PARAM, getSafeRedirectPath } from "@/utils/auth-redirect"; import { useTranslate } from "@/utils/i18n"; const SignUp = () => { @@ -25,6 +27,10 @@ const SignUp = () => { const [password, setPassword] = useState(""); const { initialize: initAuth } = useAuth(); const { generalSetting: instanceGeneralSetting, profile, initialize: initInstance } = useInstance(); + const [searchParams] = useSearchParams(); + const redirectTarget = getSafeRedirectPath(searchParams.get(AUTH_REDIRECT_PARAM)); + const authReason = searchParams.get(AUTH_REASON_PARAM); + const signInPath = searchParams.toString() ? `${ROUTES.AUTH}?${searchParams.toString()}` : ROUTES.AUTH; const handleUsernameInputChanged = (e: React.ChangeEvent) => { const text = e.target.value as string; @@ -72,7 +78,7 @@ const SignUp = () => { await initAuth(); // Refetch instance profile to update the initialized status await initInstance(); - navigateTo("/"); + navigateTo(redirectTarget || ROUTES.ROOT, { replace: true }); } catch (error: unknown) { handleError(error, toast.error, { fallbackMessage: "Sign up failed", @@ -88,6 +94,11 @@ const SignUp = () => {

{instanceGeneralSetting.customProfile?.title || "Memos"}

+ {authReason === AUTH_REASON_PROTECTED_MEMO && ( +
+ {t("auth.protected-memo-notice")} +
+ )} {!instanceGeneralSetting.disallowUserRegistration ? ( <>

{t("auth.create-your-account")}

@@ -140,7 +151,7 @@ const SignUp = () => { ) : (

{t("auth.sign-in-tip")} - + {t("common.sign-in")}

diff --git a/web/src/utils/auth-redirect.ts b/web/src/utils/auth-redirect.ts index 030767c75..7e1e00cb8 100644 --- a/web/src/utils/auth-redirect.ts +++ b/web/src/utils/auth-redirect.ts @@ -9,12 +9,51 @@ const PUBLIC_ROUTES = [ "/memos/", // Individual memo detail pages (dynamic) ] as const; +export const AUTH_REDIRECT_PARAM = "redirect"; +export const AUTH_REASON_PARAM = "reason"; +export const AUTH_REASON_PROTECTED_MEMO = "protected-memo"; + function isPublicRoute(path: string): boolean { return PUBLIC_ROUTES.some((route) => path.startsWith(route)); } -export function redirectOnAuthFailure(forceRedirect = false): void { +export function getSafeRedirectPath(path: string | null | undefined): string | undefined { + if (!path) { + return undefined; + } + + if (!path.startsWith("/") || path.startsWith("//")) { + return undefined; + } + + return path; +} + +export function buildAuthRoute(options?: { redirect?: string | null; reason?: string | null }): string { + const searchParams = new URLSearchParams(); + const redirectPath = getSafeRedirectPath(options?.redirect); + + if (redirectPath) { + searchParams.set(AUTH_REDIRECT_PARAM, redirectPath); + } + + if (options?.reason) { + searchParams.set(AUTH_REASON_PARAM, options.reason); + } + + const search = searchParams.toString(); + return search ? `${ROUTES.AUTH}?${search}` : ROUTES.AUTH; +} + +export function redirectOnAuthFailure( + forceRedirect = false, + options?: { + redirect?: string | null; + reason?: string | null; + }, +): void { const currentPath = window.location.pathname; + const currentRedirectPath = `${window.location.pathname}${window.location.search}${window.location.hash}`; // Already on auth page, nothing to do. if (currentPath.startsWith(ROUTES.AUTH)) { @@ -27,5 +66,10 @@ export function redirectOnAuthFailure(forceRedirect = false): void { } clearAccessToken(); - window.location.replace(ROUTES.AUTH); + window.location.replace( + buildAuthRoute({ + ...options, + redirect: options?.redirect ?? currentRedirectPath, + }), + ); }