Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
canifa_note
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Vũ Hoàng Anh
canifa_note
Commits
7601708a
Unverified
Commit
7601708a
authored
Mar 20, 2026
by
xun zhao
Committed by
GitHub
Mar 20, 2026
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Redirect unauthenticated protected memo access to sign in (#5738)
parent
551ee1d8
Changes
8
Hide whitespace changes
Inline
Side-by-side
Showing
8 changed files
with
124 additions
and
21 deletions
+124
-21
PasswordSignInForm.tsx
web/src/components/PasswordSignInForm.tsx
+7
-2
en.json
web/src/locales/en.json
+2
-0
zh-Hans.json
web/src/locales/zh-Hans.json
+2
-0
zh-Hant.json
web/src/locales/zh-Hant.json
+2
-0
MemoDetail.tsx
web/src/pages/MemoDetail.tsx
+34
-7
SignIn.tsx
web/src/pages/SignIn.tsx
+17
-7
SignUp.tsx
web/src/pages/SignUp.tsx
+14
-3
auth-redirect.ts
web/src/utils/auth-redirect.ts
+46
-2
No files found.
web/src/components/PasswordSignInForm.tsx
View file @
7601708a
...
...
@@ -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."
,
...
...
web/src/locales/en.json
View file @
7601708a
...
...
@@ -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"
,
...
...
web/src/locales/zh-Hans.json
View file @
7601708a
...
...
@@ -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"
:
"有链接"
,
...
...
web/src/locales/zh-Hant.json
View file @
7601708a
...
...
@@ -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"
:
"有連結"
,
...
...
web/src/pages/MemoDetail.tsx
View file @
7601708a
import
{
ConnectError
}
from
"@connectrpc/connect"
;
import
{
Co
de
,
Co
nnectError
}
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
));
...
...
web/src/pages/SignIn.tsx
View file @
7601708a
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
{
R
outes
}
from
"@/router
"
;
import
{
R
OUTES
}
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
>
...
...
web/src/pages/SignUp.tsx
View file @
7601708a
...
...
@@ -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
>
...
...
web/src/utils/auth-redirect.ts
View file @
7601708a
...
...
@@ -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
,
}),
);
}
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment