mirror of https://github.com/usememos/memos.git
Redirect unauthenticated protected memo access to sign in (#5738)
This commit is contained in:
parent
551ee1d81f
commit
7601708ae4
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "有链接",
|
||||
|
|
|
|||
|
|
@ -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": "有連結",
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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<IdentityProvider[]>([]);
|
||||
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 = () => {
|
|||
<img className="h-14 w-auto rounded-full shadow" src={instanceGeneralSetting.customProfile?.logoUrl || "/logo.webp"} alt="" />
|
||||
<p className="ml-2 text-5xl text-foreground opacity-80">{instanceGeneralSetting.customProfile?.title || "Memos"}</p>
|
||||
</div>
|
||||
{authReason === AUTH_REASON_PROTECTED_MEMO && (
|
||||
<div className="w-full mb-4 rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
|
||||
{t("auth.protected-memo-notice")}
|
||||
</div>
|
||||
)}
|
||||
{!instanceGeneralSetting.disallowPasswordAuth ? (
|
||||
<PasswordSignInForm />
|
||||
<PasswordSignInForm redirectPath={redirectTarget} />
|
||||
) : (
|
||||
identityProviderList.length === 0 && <p className="w-full text-2xl mt-2 text-muted-foreground">Password auth is not allowed.</p>
|
||||
)}
|
||||
{!instanceGeneralSetting.disallowUserRegistration && !instanceGeneralSetting.disallowPasswordAuth && (
|
||||
<p className="w-full mt-4 text-sm">
|
||||
<span className="text-muted-foreground">{t("auth.sign-up-tip")}</span>
|
||||
<Link to="/auth/signup" className="cursor-pointer ml-2 text-primary hover:underline" viewTransition>
|
||||
<Link to={signUpPath} className="cursor-pointer ml-2 text-primary hover:underline" viewTransition>
|
||||
{t("common.sign-up")}
|
||||
</Link>
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -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<HTMLInputElement>) => {
|
||||
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 = () => {
|
|||
<img className="h-14 w-auto rounded-full shadow" src={instanceGeneralSetting.customProfile?.logoUrl || "/logo.webp"} alt="" />
|
||||
<p className="ml-2 text-5xl text-foreground opacity-80">{instanceGeneralSetting.customProfile?.title || "Memos"}</p>
|
||||
</div>
|
||||
{authReason === AUTH_REASON_PROTECTED_MEMO && (
|
||||
<div className="w-full mb-4 rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
|
||||
{t("auth.protected-memo-notice")}
|
||||
</div>
|
||||
)}
|
||||
{!instanceGeneralSetting.disallowUserRegistration ? (
|
||||
<>
|
||||
<p className="w-full text-2xl mt-2 text-muted-foreground">{t("auth.create-your-account")}</p>
|
||||
|
|
@ -140,7 +151,7 @@ const SignUp = () => {
|
|||
) : (
|
||||
<p className="w-full mt-4 text-sm">
|
||||
<span className="text-muted-foreground">{t("auth.sign-in-tip")}</span>
|
||||
<Link to="/auth" className="cursor-pointer ml-2 text-primary hover:underline" viewTransition>
|
||||
<Link to={signInPath} className="cursor-pointer ml-2 text-primary hover:underline" viewTransition>
|
||||
{t("common.sign-in")}
|
||||
</Link>
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue