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
4e3a4e36
Commit
4e3a4e36
authored
Jun 23, 2025
by
Johnny
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: implement user sessions
parent
6e4d1d91
Changes
14
Expand all
Hide whitespace changes
Inline
Side-by-side
Showing
14 changed files
with
703 additions
and
123 deletions
+703
-123
user_service.proto
proto/api/v1/user_service.proto
+0
-3
user_service.pb.go
proto/gen/api/v1/user_service.pb.go
+4
-14
apidocs.swagger.yaml
proto/gen/apidocs.swagger.yaml
+0
-3
user_setting.pb.go
proto/gen/store/user_setting.pb.go
+4
-14
user_setting.proto
proto/store/user_setting.proto
+0
-2
acl.go
server/router/api/v1/acl.go
+92
-48
auth.go
server/router/api/v1/auth.go
+30
-2
auth_service.go
server/router/api/v1/auth_service.go
+199
-21
auth_service_client_info_test.go
server/router/api/v1/auth_service_client_info_test.go
+179
-0
user_service.go
server/router/api/v1/user_service.go
+0
-1
MyAccountSection.tsx
web/src/components/Settings/MyAccountSection.tsx
+2
-0
UserSessionsSection.tsx
web/src/components/Settings/UserSessionsSection.tsx
+177
-0
en.json
web/src/locales/en.json
+15
-0
user_service.ts
web/src/types/proto/api/v1/user_service.ts
+1
-15
No files found.
proto/api/v1/user_service.proto
View file @
4e3a4e36
...
@@ -511,9 +511,6 @@ message UserSession {
...
@@ -511,9 +511,6 @@ message UserSession {
// Optional. Browser name and version (e.g., "Chrome 119.0").
// Optional. Browser name and version (e.g., "Chrome 119.0").
string
browser
=
5
[(
google.api.field_behavior
)
=
OPTIONAL
];
string
browser
=
5
[(
google.api.field_behavior
)
=
OPTIONAL
];
// Optional. Geographic location (country code, e.g., "US").
string
country
=
6
[(
google.api.field_behavior
)
=
OPTIONAL
];
}
}
}
}
...
...
proto/gen/api/v1/user_service.pb.go
View file @
4e3a4e36
...
@@ -1868,9 +1868,7 @@ type UserSession_ClientInfo struct {
...
@@ -1868,9 +1868,7 @@ type UserSession_ClientInfo struct {
// Optional. Operating system (e.g., "iOS 17.0", "Windows 11").
// Optional. Operating system (e.g., "iOS 17.0", "Windows 11").
Os
string
`protobuf:"bytes,4,opt,name=os,proto3" json:"os,omitempty"`
Os
string
`protobuf:"bytes,4,opt,name=os,proto3" json:"os,omitempty"`
// Optional. Browser name and version (e.g., "Chrome 119.0").
// Optional. Browser name and version (e.g., "Chrome 119.0").
Browser
string
`protobuf:"bytes,5,opt,name=browser,proto3" json:"browser,omitempty"`
Browser
string
`protobuf:"bytes,5,opt,name=browser,proto3" json:"browser,omitempty"`
// Optional. Geographic location (country code, e.g., "US").
Country
string
`protobuf:"bytes,6,opt,name=country,proto3" json:"country,omitempty"`
unknownFields
protoimpl
.
UnknownFields
unknownFields
protoimpl
.
UnknownFields
sizeCache
protoimpl
.
SizeCache
sizeCache
protoimpl
.
SizeCache
}
}
...
@@ -1940,13 +1938,6 @@ func (x *UserSession_ClientInfo) GetBrowser() string {
...
@@ -1940,13 +1938,6 @@ func (x *UserSession_ClientInfo) GetBrowser() string {
return
""
return
""
}
}
func
(
x
*
UserSession_ClientInfo
)
GetCountry
()
string
{
if
x
!=
nil
{
return
x
.
Country
}
return
""
}
var
File_api_v1_user_service_proto
protoreflect
.
FileDescriptor
var
File_api_v1_user_service_proto
protoreflect
.
FileDescriptor
const
file_api_v1_user_service_proto_rawDesc
=
""
+
const
file_api_v1_user_service_proto_rawDesc
=
""
+
...
@@ -2084,7 +2075,7 @@ const file_api_v1_user_service_proto_rawDesc = "" +
...
@@ -2084,7 +2075,7 @@ const file_api_v1_user_service_proto_rawDesc = "" +
"
\x0f
access_token_id
\x18\x03
\x01
(
\t
B
\x03\xe0
A
\x01
R
\r
accessTokenId
\"
X
\n
"
+
"
\x0f
access_token_id
\x18\x03
\x01
(
\t
B
\x03\xe0
A
\x01
R
\r
accessTokenId
\"
X
\n
"
+
"
\x1c
DeleteUserAccessTokenRequest
\x12
8
\n
"
+
"
\x1c
DeleteUserAccessTokenRequest
\x12
8
\n
"
+
"
\x04
name
\x18\x01
\x01
(
\t
B$
\xe0
A
\x02\xfa
A
\x1e\n
"
+
"
\x04
name
\x18\x01
\x01
(
\t
B$
\xe0
A
\x02\xfa
A
\x1e\n
"
+
"
\x1c
memos.api.v1/UserAccessTokenR
\x04
name
\"\x
f5
\x04\n
"
+
"
\x1c
memos.api.v1/UserAccessTokenR
\x04
name
\"\x
d6
\x04\n
"
+
"
\v
UserSession
\x12\x17\n
"
+
"
\v
UserSession
\x12\x17\n
"
+
"
\x04
name
\x18\x01
\x01
(
\t
B
\x03\xe0
A
\b
R
\x04
name
\x12\"\n
"
+
"
\x04
name
\x18\x01
\x01
(
\t
B
\x03\xe0
A
\b
R
\x04
name
\x12\"\n
"
+
"
\n
"
+
"
\n
"
+
...
@@ -2095,7 +2086,7 @@ const file_api_v1_user_service_proto_rawDesc = "" +
...
@@ -2095,7 +2086,7 @@ const file_api_v1_user_service_proto_rawDesc = "" +
"expireTime
\x12
M
\n
"
+
"expireTime
\x12
M
\n
"
+
"
\x12
last_accessed_time
\x18\x05
\x01
(
\v
2
\x1a
.google.protobuf.TimestampB
\x03\xe0
A
\x03
R
\x10
lastAccessedTime
\x12
J
\n
"
+
"
\x12
last_accessed_time
\x18\x05
\x01
(
\v
2
\x1a
.google.protobuf.TimestampB
\x03\xe0
A
\x03
R
\x10
lastAccessedTime
\x12
J
\n
"
+
"
\v
client_info
\x18\x06
\x01
(
\v
2$.memos.api.v1.UserSession.ClientInfoB
\x03\xe0
A
\x03
R
\n
"
+
"
\v
client_info
\x18\x06
\x01
(
\v
2$.memos.api.v1.UserSession.ClientInfoB
\x03\xe0
A
\x03
R
\n
"
+
"clientInfo
\x1a\x
c3
\x01\n
"
+
"clientInfo
\x1a\x
a4
\x01\n
"
+
"
\n
"
+
"
\n
"
+
"ClientInfo
\x12\x1d\n
"
+
"ClientInfo
\x12\x1d\n
"
+
"
\n
"
+
"
\n
"
+
...
@@ -2105,8 +2096,7 @@ const file_api_v1_user_service_proto_rawDesc = "" +
...
@@ -2105,8 +2096,7 @@ const file_api_v1_user_service_proto_rawDesc = "" +
"
\v
device_type
\x18\x03
\x01
(
\t
B
\x03\xe0
A
\x01
R
\n
"
+
"
\v
device_type
\x18\x03
\x01
(
\t
B
\x03\xe0
A
\x01
R
\n
"
+
"deviceType
\x12\x13\n
"
+
"deviceType
\x12\x13\n
"
+
"
\x02
os
\x18\x04
\x01
(
\t
B
\x03\xe0
A
\x01
R
\x02
os
\x12\x1d\n
"
+
"
\x02
os
\x18\x04
\x01
(
\t
B
\x03\xe0
A
\x01
R
\x02
os
\x12\x1d\n
"
+
"
\a
browser
\x18\x05
\x01
(
\t
B
\x03\xe0
A
\x01
R
\a
browser
\x12\x1d\n
"
+
"
\a
browser
\x18\x05
\x01
(
\t
B
\x03\xe0
A
\x01
R
\a
browser:D
\xea
AA
\n
"
+
"
\a
country
\x18\x06
\x01
(
\t
B
\x03\xe0
A
\x01
R
\a
country:D
\xea
AA
\n
"
+
"
\x18
memos.api.v1/UserSession
\x12\x1f
users/{user}/sessions/{session}
\x1a\x04
name
\"
L
\n
"
+
"
\x18
memos.api.v1/UserSession
\x12\x1f
users/{user}/sessions/{session}
\x1a\x04
name
\"
L
\n
"
+
"
\x17
ListUserSessionsRequest
\x12
1
\n
"
+
"
\x17
ListUserSessionsRequest
\x12
1
\n
"
+
"
\x06
parent
\x18\x01
\x01
(
\t
B
\x19\xe0
A
\x02\xfa
A
\x13\n
"
+
"
\x06
parent
\x18\x01
\x01
(
\t
B
\x19\xe0
A
\x02\xfa
A
\x13\n
"
+
...
...
proto/gen/apidocs.swagger.yaml
View file @
4e3a4e36
...
@@ -4340,9 +4340,6 @@ definitions:
...
@@ -4340,9 +4340,6 @@ definitions:
browser
:
browser
:
type
:
string
type
:
string
description
:
Optional. Browser name and version (e.g., "Chrome 119.0").
description
:
Optional. Browser name and version (e.g., "Chrome 119.0").
country
:
type
:
string
description
:
Optional. Geographic location (country code, e.g., "US").
v1UserStats
:
v1UserStats
:
type
:
object
type
:
object
properties
:
properties
:
...
...
proto/gen/store/user_setting.pb.go
View file @
4e3a4e36
...
@@ -590,9 +590,7 @@ type SessionsUserSetting_ClientInfo struct {
...
@@ -590,9 +590,7 @@ type SessionsUserSetting_ClientInfo struct {
// Optional. Operating system (e.g., "iOS 17.0", "Windows 11").
// Optional. Operating system (e.g., "iOS 17.0", "Windows 11").
Os
string
`protobuf:"bytes,4,opt,name=os,proto3" json:"os,omitempty"`
Os
string
`protobuf:"bytes,4,opt,name=os,proto3" json:"os,omitempty"`
// Optional. Browser name and version (e.g., "Chrome 119.0").
// Optional. Browser name and version (e.g., "Chrome 119.0").
Browser
string
`protobuf:"bytes,5,opt,name=browser,proto3" json:"browser,omitempty"`
Browser
string
`protobuf:"bytes,5,opt,name=browser,proto3" json:"browser,omitempty"`
// Optional. Geographic location (country code, e.g., "US").
Country
string
`protobuf:"bytes,6,opt,name=country,proto3" json:"country,omitempty"`
unknownFields
protoimpl
.
UnknownFields
unknownFields
protoimpl
.
UnknownFields
sizeCache
protoimpl
.
SizeCache
sizeCache
protoimpl
.
SizeCache
}
}
...
@@ -662,13 +660,6 @@ func (x *SessionsUserSetting_ClientInfo) GetBrowser() string {
...
@@ -662,13 +660,6 @@ func (x *SessionsUserSetting_ClientInfo) GetBrowser() string {
return
""
return
""
}
}
func
(
x
*
SessionsUserSetting_ClientInfo
)
GetCountry
()
string
{
if
x
!=
nil
{
return
x
.
Country
}
return
""
}
var
File_store_user_setting_proto
protoreflect
.
FileDescriptor
var
File_store_user_setting_proto
protoreflect
.
FileDescriptor
const
file_store_user_setting_proto_rawDesc
=
""
+
const
file_store_user_setting_proto_rawDesc
=
""
+
...
@@ -696,7 +687,7 @@ const file_store_user_setting_proto_rawDesc = "" +
...
@@ -696,7 +687,7 @@ const file_store_user_setting_proto_rawDesc = "" +
"
\b
Shortcut
\x12\x0e\n
"
+
"
\b
Shortcut
\x12\x0e\n
"
+
"
\x02
id
\x18\x01
\x01
(
\t
R
\x02
id
\x12\x14\n
"
+
"
\x02
id
\x18\x01
\x01
(
\t
R
\x02
id
\x12\x14\n
"
+
"
\x05
title
\x18\x02
\x01
(
\t
R
\x05
title
\x12\x16\n
"
+
"
\x05
title
\x18\x02
\x01
(
\t
R
\x05
title
\x12\x16\n
"
+
"
\x06
filter
\x18\x03
\x01
(
\t
R
\x06
filter
\"\x
ca
\x04\n
"
+
"
\x06
filter
\x18\x03
\x01
(
\t
R
\x06
filter
\"\x
b0
\x04\n
"
+
"
\x13
SessionsUserSetting
\x12
D
\n
"
+
"
\x13
SessionsUserSetting
\x12
D
\n
"
+
"
\b
sessions
\x18\x01
\x03
(
\v
2(.memos.store.SessionsUserSetting.SessionR
\b
sessions
\x1a\xba\x02\n
"
+
"
\b
sessions
\x18\x01
\x03
(
\v
2(.memos.store.SessionsUserSetting.SessionR
\b
sessions
\x1a\xba\x02\n
"
+
"
\a
Session
\x12\x1d\n
"
+
"
\a
Session
\x12\x1d\n
"
+
...
@@ -708,7 +699,7 @@ const file_store_user_setting_proto_rawDesc = "" +
...
@@ -708,7 +699,7 @@ const file_store_user_setting_proto_rawDesc = "" +
"expireTime
\x12
H
\n
"
+
"expireTime
\x12
H
\n
"
+
"
\x12
last_accessed_time
\x18\x04
\x01
(
\v
2
\x1a
.google.protobuf.TimestampR
\x10
lastAccessedTime
\x12
L
\n
"
+
"
\x12
last_accessed_time
\x18\x04
\x01
(
\v
2
\x1a
.google.protobuf.TimestampR
\x10
lastAccessedTime
\x12
L
\n
"
+
"
\v
client_info
\x18\x05
\x01
(
\v
2+.memos.store.SessionsUserSetting.ClientInfoR
\n
"
+
"
\v
client_info
\x18\x05
\x01
(
\v
2+.memos.store.SessionsUserSetting.ClientInfoR
\n
"
+
"clientInfo
\x1a\x
af
\x01\n
"
+
"clientInfo
\x1a\x
95
\x01\n
"
+
"
\n
"
+
"
\n
"
+
"ClientInfo
\x12\x1d\n
"
+
"ClientInfo
\x12\x1d\n
"
+
"
\n
"
+
"
\n
"
+
...
@@ -718,8 +709,7 @@ const file_store_user_setting_proto_rawDesc = "" +
...
@@ -718,8 +709,7 @@ const file_store_user_setting_proto_rawDesc = "" +
"
\v
device_type
\x18\x03
\x01
(
\t
R
\n
"
+
"
\v
device_type
\x18\x03
\x01
(
\t
R
\n
"
+
"deviceType
\x12\x0e\n
"
+
"deviceType
\x12\x0e\n
"
+
"
\x02
os
\x18\x04
\x01
(
\t
R
\x02
os
\x12\x18\n
"
+
"
\x02
os
\x18\x04
\x01
(
\t
R
\x02
os
\x12\x18\n
"
+
"
\a
browser
\x18\x05
\x01
(
\t
R
\a
browser
\x12\x18\n
"
+
"
\a
browser
\x18\x05
\x01
(
\t
R
\a
browser*
\x93\x01\n
"
+
"
\a
country
\x18\x06
\x01
(
\t
R
\a
country*
\x93\x01\n
"
+
"
\x0e
UserSettingKey
\x12
\n
"
+
"
\x0e
UserSettingKey
\x12
\n
"
+
"
\x1c
USER_SETTING_KEY_UNSPECIFIED
\x10\x00\x12\x11\n
"
+
"
\x1c
USER_SETTING_KEY_UNSPECIFIED
\x10\x00\x12\x11\n
"
+
"
\r
ACCESS_TOKENS
\x10\x01\x12\n
"
+
"
\r
ACCESS_TOKENS
\x10\x01\x12\n
"
+
...
...
proto/store/user_setting.proto
View file @
4e3a4e36
...
@@ -80,8 +80,6 @@ message SessionsUserSetting {
...
@@ -80,8 +80,6 @@ message SessionsUserSetting {
string
os
=
4
;
string
os
=
4
;
// Optional. Browser name and version (e.g., "Chrome 119.0").
// Optional. Browser name and version (e.g., "Chrome 119.0").
string
browser
=
5
;
string
browser
=
5
;
// Optional. Geographic location (country code, e.g., "US").
string
country
=
6
;
}
}
repeated
Session
sessions
=
1
;
repeated
Session
sessions
=
1
;
...
...
server/router/api/v1/acl.go
View file @
4e3a4e36
...
@@ -52,22 +52,38 @@ func (in *GRPCAuthInterceptor) AuthenticationInterceptor(ctx context.Context, re
...
@@ -52,22 +52,38 @@ func (in *GRPCAuthInterceptor) AuthenticationInterceptor(ctx context.Context, re
return
nil
,
status
.
Errorf
(
codes
.
Unauthenticated
,
"failed to parse metadata from incoming context"
)
return
nil
,
status
.
Errorf
(
codes
.
Unauthenticated
,
"failed to parse metadata from incoming context"
)
}
}
// Try to get access token from either Authorization header or cookie
// Try to authenticate via session ID (from cookie) first
accessToken
,
err
:=
getTokenFromMetadata
(
md
)
if
sessionCookieValue
,
err
:=
getSessionIDFromMetadata
(
md
);
err
==
nil
&&
sessionCookieValue
!=
""
{
if
err
!=
nil
{
user
,
err
:=
in
.
authenticateBySession
(
ctx
,
sessionCookieValue
)
return
nil
,
status
.
Errorf
(
codes
.
Unauthenticated
,
"failed to get access token: %v"
,
err
)
if
err
==
nil
&&
user
!=
nil
{
// Extract just the sessionID part for context storage
_
,
sessionID
,
parseErr
:=
ParseSessionCookieValue
(
sessionCookieValue
)
if
parseErr
!=
nil
{
return
nil
,
status
.
Errorf
(
codes
.
Internal
,
"failed to parse session cookie: %v"
,
parseErr
)
}
return
in
.
handleAuthenticatedRequest
(
ctx
,
request
,
serverInfo
,
handler
,
user
,
sessionID
,
""
)
}
}
}
// Authenticate using access token (which also validates sessions when it's from cookie)
// Try to authenticate via JWT access token (from Authorization header)
user
,
err
:=
in
.
authenticateByAccessToken
(
ctx
,
accessToken
)
if
accessToken
,
err
:=
getAccessTokenFromMetadata
(
md
);
err
==
nil
&&
accessToken
!=
""
{
if
err
!=
nil
{
user
,
err
:=
in
.
authenticateByJWT
(
ctx
,
accessToken
)
// Check if this method is in the allowlist first
if
err
==
nil
&&
user
!=
nil
{
if
isUnauthorizeAllowedMethod
(
serverInfo
.
FullMethod
)
{
return
in
.
handleAuthenticatedRequest
(
ctx
,
request
,
serverInfo
,
handler
,
user
,
""
,
accessToken
)
return
handler
(
ctx
,
request
)
}
}
return
nil
,
err
}
}
// If no valid authentication found, check if this method is in the allowlist (public endpoints)
if
isUnauthorizeAllowedMethod
(
serverInfo
.
FullMethod
)
{
return
handler
(
ctx
,
request
)
}
// If authentication is required but not found, reject the request
return
nil
,
status
.
Errorf
(
codes
.
Unauthenticated
,
"authentication required"
)
}
// handleAuthenticatedRequest processes an authenticated request with the given user and auth info.
func
(
in
*
GRPCAuthInterceptor
)
handleAuthenticatedRequest
(
ctx
context
.
Context
,
request
any
,
serverInfo
*
grpc
.
UnaryServerInfo
,
handler
grpc
.
UnaryHandler
,
user
*
store
.
User
,
sessionID
,
accessToken
string
)
(
any
,
error
)
{
// Check user status
// Check user status
if
user
.
RowStatus
==
store
.
Archived
{
if
user
.
RowStatus
==
store
.
Archived
{
return
nil
,
errors
.
Errorf
(
"user %q is archived"
,
user
.
Username
)
return
nil
,
errors
.
Errorf
(
"user %q is archived"
,
user
.
Username
)
...
@@ -79,22 +95,21 @@ func (in *GRPCAuthInterceptor) AuthenticationInterceptor(ctx context.Context, re
...
@@ -79,22 +95,21 @@ func (in *GRPCAuthInterceptor) AuthenticationInterceptor(ctx context.Context, re
// Set context values
// Set context values
ctx
=
context
.
WithValue
(
ctx
,
userIDContextKey
,
user
.
ID
)
ctx
=
context
.
WithValue
(
ctx
,
userIDContextKey
,
user
.
ID
)
// Determine if this came from cookie (session) or header (API token)
if
sessionID
!=
""
{
if
_
,
headerErr
:=
getAccessTokenFromMetadata
(
md
);
headerErr
!=
nil
{
// Session-based authentication
// Came from cookie, treat as session
ctx
=
context
.
WithValue
(
ctx
,
sessionIDContextKey
,
sessionID
)
ctx
=
context
.
WithValue
(
ctx
,
sessionIDContextKey
,
accessToken
)
// Update session last accessed time
// Update session last accessed time
_
=
in
.
updateSessionLastAccessed
(
ctx
,
user
.
ID
,
accessToken
)
_
=
in
.
updateSessionLastAccessed
(
ctx
,
user
.
ID
,
sessionID
)
}
else
{
}
else
if
accessToken
!=
""
{
//
Came from Authorization header, treat as API toke
n
//
JWT access token-based authenticatio
n
ctx
=
context
.
WithValue
(
ctx
,
accessTokenContextKey
,
accessToken
)
ctx
=
context
.
WithValue
(
ctx
,
accessTokenContextKey
,
accessToken
)
}
}
return
handler
(
ctx
,
request
)
return
handler
(
ctx
,
request
)
}
}
// authenticateBy
AccessToken authenticates a user using access token from Authorization header or cookie
.
// authenticateBy
JWT authenticates a user using JWT access token from Authorization header
.
func
(
in
*
GRPCAuthInterceptor
)
authenticateBy
AccessToken
(
ctx
context
.
Context
,
accessToken
string
)
(
*
store
.
User
,
error
)
{
func
(
in
*
GRPCAuthInterceptor
)
authenticateBy
JWT
(
ctx
context
.
Context
,
accessToken
string
)
(
*
store
.
User
,
error
)
{
if
accessToken
==
""
{
if
accessToken
==
""
{
return
nil
,
status
.
Errorf
(
codes
.
Unauthenticated
,
"access token not found"
)
return
nil
,
status
.
Errorf
(
codes
.
Unauthenticated
,
"access token not found"
)
}
}
...
@@ -114,7 +129,7 @@ func (in *GRPCAuthInterceptor) authenticateByAccessToken(ctx context.Context, ac
...
@@ -114,7 +129,7 @@ func (in *GRPCAuthInterceptor) authenticateByAccessToken(ctx context.Context, ac
return
nil
,
status
.
Errorf
(
codes
.
Unauthenticated
,
"Invalid or expired access token"
)
return
nil
,
status
.
Errorf
(
codes
.
Unauthenticated
,
"Invalid or expired access token"
)
}
}
//
We either have a valid access token or we will attempt to generate new access token.
//
Get user from JWT claims
userID
,
err
:=
util
.
ConvertStringToInt32
(
claims
.
Subject
)
userID
,
err
:=
util
.
ConvertStringToInt32
(
claims
.
Subject
)
if
err
!=
nil
{
if
err
!=
nil
{
return
nil
,
errors
.
Wrap
(
err
,
"malformed ID in the token"
)
return
nil
,
errors
.
Wrap
(
err
,
"malformed ID in the token"
)
...
@@ -132,6 +147,7 @@ func (in *GRPCAuthInterceptor) authenticateByAccessToken(ctx context.Context, ac
...
@@ -132,6 +147,7 @@ func (in *GRPCAuthInterceptor) authenticateByAccessToken(ctx context.Context, ac
return
nil
,
errors
.
Errorf
(
"user %q is archived"
,
userID
)
return
nil
,
errors
.
Errorf
(
"user %q is archived"
,
userID
)
}
}
// Validate that this access token exists in the user's access tokens
accessTokens
,
err
:=
in
.
Store
.
GetUserAccessTokens
(
ctx
,
user
.
ID
)
accessTokens
,
err
:=
in
.
Store
.
GetUserAccessTokens
(
ctx
,
user
.
ID
)
if
err
!=
nil
{
if
err
!=
nil
{
return
nil
,
errors
.
Wrapf
(
err
,
"failed to get user access tokens"
)
return
nil
,
errors
.
Wrapf
(
err
,
"failed to get user access tokens"
)
...
@@ -140,10 +156,43 @@ func (in *GRPCAuthInterceptor) authenticateByAccessToken(ctx context.Context, ac
...
@@ -140,10 +156,43 @@ func (in *GRPCAuthInterceptor) authenticateByAccessToken(ctx context.Context, ac
return
nil
,
status
.
Errorf
(
codes
.
Unauthenticated
,
"invalid access token"
)
return
nil
,
status
.
Errorf
(
codes
.
Unauthenticated
,
"invalid access token"
)
}
}
// For tokens that might be used as session IDs (from cookies), also validate session existence
return
user
,
nil
// This is a best-effort check - if sessions can't be retrieved or token isn't a session, that's ok
}
if
sessions
,
err
:=
in
.
Store
.
GetUserSessions
(
ctx
,
user
.
ID
);
err
==
nil
{
validateUserSession
(
accessToken
,
sessions
)
// Result doesn't matter for API tokens
// authenticateBySession authenticates a user using session ID from cookie.
func
(
in
*
GRPCAuthInterceptor
)
authenticateBySession
(
ctx
context
.
Context
,
sessionCookieValue
string
)
(
*
store
.
User
,
error
)
{
if
sessionCookieValue
==
""
{
return
nil
,
status
.
Errorf
(
codes
.
Unauthenticated
,
"session cookie value not found"
)
}
// Parse the cookie value to extract userID and sessionID
userID
,
sessionID
,
err
:=
ParseSessionCookieValue
(
sessionCookieValue
)
if
err
!=
nil
{
return
nil
,
status
.
Errorf
(
codes
.
Unauthenticated
,
"invalid session cookie format: %v"
,
err
)
}
// Get the user directly using the userID from the cookie
user
,
err
:=
in
.
Store
.
GetUser
(
ctx
,
&
store
.
FindUser
{
ID
:
&
userID
,
})
if
err
!=
nil
{
return
nil
,
errors
.
Wrap
(
err
,
"failed to get user"
)
}
if
user
==
nil
{
return
nil
,
status
.
Errorf
(
codes
.
Unauthenticated
,
"user not found"
)
}
if
user
.
RowStatus
==
store
.
Archived
{
return
nil
,
status
.
Errorf
(
codes
.
Unauthenticated
,
"user is archived"
)
}
// Get user sessions and validate the sessionID
sessions
,
err
:=
in
.
Store
.
GetUserSessions
(
ctx
,
userID
)
if
err
!=
nil
{
return
nil
,
errors
.
Wrap
(
err
,
"failed to get user sessions"
)
}
if
!
validateUserSession
(
sessionID
,
sessions
)
{
return
nil
,
status
.
Errorf
(
codes
.
Unauthenticated
,
"invalid or expired session"
)
}
}
return
user
,
nil
return
user
,
nil
...
@@ -168,6 +217,24 @@ func validateUserSession(sessionID string, userSessions []*storepb.SessionsUserS
...
@@ -168,6 +217,24 @@ func validateUserSession(sessionID string, userSessions []*storepb.SessionsUserS
return
false
return
false
}
}
// getSessionIDFromMetadata extracts session cookie value from cookie.
func
getSessionIDFromMetadata
(
md
metadata
.
MD
)
(
string
,
error
)
{
// Check the cookie header for session cookie value
var
sessionCookieValue
string
for
_
,
t
:=
range
append
(
md
.
Get
(
"grpcgateway-cookie"
),
md
.
Get
(
"cookie"
)
...
)
{
header
:=
http
.
Header
{}
header
.
Add
(
"Cookie"
,
t
)
request
:=
http
.
Request
{
Header
:
header
}
if
v
,
_
:=
request
.
Cookie
(
SessionCookieName
);
v
!=
nil
{
sessionCookieValue
=
v
.
Value
}
}
if
sessionCookieValue
==
""
{
return
""
,
errors
.
New
(
"session cookie not found"
)
}
return
sessionCookieValue
,
nil
}
// getAccessTokenFromMetadata extracts access token from Authorization header.
// getAccessTokenFromMetadata extracts access token from Authorization header.
func
getAccessTokenFromMetadata
(
md
metadata
.
MD
)
(
string
,
error
)
{
func
getAccessTokenFromMetadata
(
md
metadata
.
MD
)
(
string
,
error
)
{
// Check the HTTP request Authorization header.
// Check the HTTP request Authorization header.
...
@@ -182,29 +249,6 @@ func getAccessTokenFromMetadata(md metadata.MD) (string, error) {
...
@@ -182,29 +249,6 @@ func getAccessTokenFromMetadata(md metadata.MD) (string, error) {
return
authHeaderParts
[
1
],
nil
return
authHeaderParts
[
1
],
nil
}
}
func
getTokenFromMetadata
(
md
metadata
.
MD
)
(
string
,
error
)
{
// Check the HTTP request header first.
authorizationHeaders
:=
md
.
Get
(
"Authorization"
)
if
len
(
authorizationHeaders
)
>
0
{
authHeaderParts
:=
strings
.
Fields
(
authorizationHeaders
[
0
])
if
len
(
authHeaderParts
)
!=
2
||
strings
.
ToLower
(
authHeaderParts
[
0
])
!=
"bearer"
{
return
""
,
errors
.
New
(
"authorization header format must be Bearer {token}"
)
}
return
authHeaderParts
[
1
],
nil
}
// Check the cookie header.
var
accessToken
string
for
_
,
t
:=
range
append
(
md
.
Get
(
"grpcgateway-cookie"
),
md
.
Get
(
"cookie"
)
...
)
{
header
:=
http
.
Header
{}
header
.
Add
(
"Cookie"
,
t
)
request
:=
http
.
Request
{
Header
:
header
}
if
v
,
_
:=
request
.
Cookie
(
AccessTokenCookieName
);
v
!=
nil
{
accessToken
=
v
.
Value
}
}
return
accessToken
,
nil
}
func
validateAccessToken
(
accessTokenString
string
,
userAccessTokens
[]
*
storepb
.
AccessTokensUserSetting_AccessToken
)
bool
{
func
validateAccessToken
(
accessTokenString
string
,
userAccessTokens
[]
*
storepb
.
AccessTokensUserSetting_AccessToken
)
bool
{
for
_
,
userAccessToken
:=
range
userAccessTokens
{
for
_
,
userAccessToken
:=
range
userAccessTokens
{
if
accessTokenString
==
userAccessToken
.
AccessToken
{
if
accessTokenString
==
userAccessToken
.
AccessToken
{
...
...
server/router/api/v1/auth.go
View file @
4e3a4e36
...
@@ -2,9 +2,12 @@ package v1
...
@@ -2,9 +2,12 @@ package v1
import
(
import
(
"fmt"
"fmt"
"strings"
"time"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/golang-jwt/jwt/v5"
"github.com/usememos/memos/internal/util"
)
)
const
(
const
(
...
@@ -20,8 +23,8 @@ const (
...
@@ -20,8 +23,8 @@ const (
// CookieExpDuration expires slightly earlier than the jwt expiration. Client would be logged out if the user
// CookieExpDuration expires slightly earlier than the jwt expiration. Client would be logged out if the user
// cookie expires, thus the client would always logout first before attempting to make a request with the expired jwt.
// cookie expires, thus the client would always logout first before attempting to make a request with the expired jwt.
CookieExpDuration
=
AccessTokenDuration
-
1
*
time
.
Minute
CookieExpDuration
=
AccessTokenDuration
-
1
*
time
.
Minute
//
AccessTokenCookieName is the cookie name of access token
.
//
SessionCookieName is the cookie name of user session ID
.
AccessTokenCookieName
=
"memos.access-toke
n"
SessionCookieName
=
"user_sessio
n"
)
)
type
ClaimsMessage
struct
{
type
ClaimsMessage
struct
{
...
@@ -61,3 +64,28 @@ func generateToken(username string, userID int32, audience string, expirationTim
...
@@ -61,3 +64,28 @@ func generateToken(username string, userID int32, audience string, expirationTim
return
tokenString
,
nil
return
tokenString
,
nil
}
}
// GenerateSessionID generates a unique session ID using UUIDv4.
func
GenerateSessionID
()
(
string
,
error
)
{
return
util
.
GenUUID
(),
nil
}
// BuildSessionCookieValue builds the session cookie value in format {userID}-{sessionID}.
func
BuildSessionCookieValue
(
userID
int32
,
sessionID
string
)
string
{
return
fmt
.
Sprintf
(
"%d-%s"
,
userID
,
sessionID
)
}
// ParseSessionCookieValue parses the session cookie value to extract userID and sessionID.
func
ParseSessionCookieValue
(
cookieValue
string
)
(
int32
,
string
,
error
)
{
parts
:=
strings
.
SplitN
(
cookieValue
,
"-"
,
2
)
if
len
(
parts
)
!=
2
{
return
0
,
""
,
fmt
.
Errorf
(
"invalid session cookie format"
)
}
userID
,
err
:=
util
.
ConvertStringToInt32
(
parts
[
0
])
if
err
!=
nil
{
return
0
,
""
,
fmt
.
Errorf
(
"invalid user ID in session cookie: %v"
,
err
)
}
return
userID
,
parts
[
1
],
nil
}
server/router/api/v1/auth_service.go
View file @
4e3a4e36
This diff is collapsed.
Click to expand it.
server/router/api/v1/auth_service_client_info_test.go
0 → 100644
View file @
4e3a4e36
package
v1
import
(
"context"
"testing"
"google.golang.org/grpc/metadata"
storepb
"github.com/usememos/memos/proto/gen/store"
)
func
TestParseUserAgent
(
t
*
testing
.
T
)
{
service
:=
&
APIV1Service
{}
tests
:=
[]
struct
{
name
string
userAgent
string
expectedDevice
string
expectedOS
string
expectedBrowser
string
}{
{
name
:
"Chrome on Windows"
,
userAgent
:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36"
,
expectedDevice
:
"desktop"
,
expectedOS
:
"Windows 10/11"
,
expectedBrowser
:
"Chrome 119.0.0.0"
,
},
{
name
:
"Safari on macOS"
,
userAgent
:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"
,
expectedDevice
:
"desktop"
,
expectedOS
:
"macOS 10.15.7"
,
expectedBrowser
:
"Safari 17.0"
,
},
{
name
:
"Chrome on Android Mobile"
,
userAgent
:
"Mozilla/5.0 (Linux; Android 13; SM-G998B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36"
,
expectedDevice
:
"mobile"
,
expectedOS
:
"Android 13"
,
expectedBrowser
:
"Chrome 119.0.0.0"
,
},
{
name
:
"Safari on iPhone"
,
userAgent
:
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
,
expectedDevice
:
"mobile"
,
expectedOS
:
"iOS 17.0"
,
expectedBrowser
:
"Safari 17.0"
,
},
{
name
:
"Firefox on Windows"
,
userAgent
:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0"
,
expectedDevice
:
"desktop"
,
expectedOS
:
"Windows 10/11"
,
expectedBrowser
:
"Firefox 119.0"
,
},
{
name
:
"Edge on Windows"
,
userAgent
:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0"
,
expectedDevice
:
"desktop"
,
expectedOS
:
"Windows 10/11"
,
expectedBrowser
:
"Edge 119.0.0.0"
,
},
{
name
:
"iPad Safari"
,
userAgent
:
"Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
,
expectedDevice
:
"tablet"
,
expectedOS
:
"iOS 17.0"
,
expectedBrowser
:
"Safari 17.0"
,
},
}
for
_
,
tt
:=
range
tests
{
t
.
Run
(
tt
.
name
,
func
(
t
*
testing
.
T
)
{
clientInfo
:=
&
storepb
.
SessionsUserSetting_ClientInfo
{}
service
.
parseUserAgent
(
tt
.
userAgent
,
clientInfo
)
if
clientInfo
.
DeviceType
!=
tt
.
expectedDevice
{
t
.
Errorf
(
"Expected device type %s, got %s"
,
tt
.
expectedDevice
,
clientInfo
.
DeviceType
)
}
if
clientInfo
.
Os
!=
tt
.
expectedOS
{
t
.
Errorf
(
"Expected OS %s, got %s"
,
tt
.
expectedOS
,
clientInfo
.
Os
)
}
if
clientInfo
.
Browser
!=
tt
.
expectedBrowser
{
t
.
Errorf
(
"Expected browser %s, got %s"
,
tt
.
expectedBrowser
,
clientInfo
.
Browser
)
}
})
}
}
func
TestExtractClientInfo
(
t
*
testing
.
T
)
{
service
:=
&
APIV1Service
{}
// Test with metadata containing user agent and IP
md
:=
metadata
.
New
(
map
[
string
]
string
{
"user-agent"
:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36"
,
"x-forwarded-for"
:
"203.0.113.1, 198.51.100.1"
,
"x-real-ip"
:
"203.0.113.1"
,
})
ctx
:=
metadata
.
NewIncomingContext
(
context
.
Background
(),
md
)
clientInfo
:=
service
.
extractClientInfo
(
ctx
)
if
clientInfo
.
UserAgent
==
""
{
t
.
Error
(
"Expected user agent to be set"
)
}
if
clientInfo
.
IpAddress
!=
"203.0.113.1"
{
t
.
Errorf
(
"Expected IP address to be 203.0.113.1, got %s"
,
clientInfo
.
IpAddress
)
}
if
clientInfo
.
DeviceType
!=
"desktop"
{
t
.
Errorf
(
"Expected device type to be desktop, got %s"
,
clientInfo
.
DeviceType
)
}
if
clientInfo
.
Os
!=
"Windows 10/11"
{
t
.
Errorf
(
"Expected OS to be Windows 10/11, got %s"
,
clientInfo
.
Os
)
}
if
clientInfo
.
Browser
!=
"Chrome 119.0.0.0"
{
t
.
Errorf
(
"Expected browser to be Chrome 119.0.0.0, got %s"
,
clientInfo
.
Browser
)
}
}
// TestClientInfoExamples demonstrates the enhanced client info extraction with various user agents
func
TestClientInfoExamples
(
t
*
testing
.
T
)
{
service
:=
&
APIV1Service
{}
examples
:=
[]
struct
{
description
string
userAgent
string
}{
{
description
:
"Modern Chrome on Windows 11"
,
userAgent
:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
,
},
{
description
:
"Safari on iPhone 15 Pro"
,
userAgent
:
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1"
,
},
{
description
:
"Chrome on Samsung Galaxy"
,
userAgent
:
"Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
,
},
{
description
:
"Firefox on Ubuntu"
,
userAgent
:
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/120.0"
,
},
{
description
:
"Edge on Windows 10"
,
userAgent
:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0"
,
},
{
description
:
"Safari on iPad Air"
,
userAgent
:
"Mozilla/5.0 (iPad; CPU OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1"
,
},
}
for
_
,
example
:=
range
examples
{
t
.
Run
(
example
.
description
,
func
(
t
*
testing
.
T
)
{
clientInfo
:=
&
storepb
.
SessionsUserSetting_ClientInfo
{}
service
.
parseUserAgent
(
example
.
userAgent
,
clientInfo
)
t
.
Logf
(
"User Agent: %s"
,
example
.
userAgent
)
t
.
Logf
(
"Device Type: %s"
,
clientInfo
.
DeviceType
)
t
.
Logf
(
"Operating System: %s"
,
clientInfo
.
Os
)
t
.
Logf
(
"Browser: %s"
,
clientInfo
.
Browser
)
t
.
Logf
(
"---"
)
// Ensure all fields are populated
if
clientInfo
.
DeviceType
==
""
{
t
.
Error
(
"Device type should not be empty"
)
}
if
clientInfo
.
Os
==
""
{
t
.
Error
(
"OS should not be empty"
)
}
if
clientInfo
.
Browser
==
""
{
t
.
Error
(
"Browser should not be empty"
)
}
})
}
}
server/router/api/v1/user_service.go
View file @
4e3a4e36
...
@@ -627,7 +627,6 @@ func (s *APIV1Service) ListUserSessions(ctx context.Context, request *v1pb.ListU
...
@@ -627,7 +627,6 @@ func (s *APIV1Service) ListUserSessions(ctx context.Context, request *v1pb.ListU
DeviceType
:
userSession
.
ClientInfo
.
DeviceType
,
DeviceType
:
userSession
.
ClientInfo
.
DeviceType
,
Os
:
userSession
.
ClientInfo
.
Os
,
Os
:
userSession
.
ClientInfo
.
Os
,
Browser
:
userSession
.
ClientInfo
.
Browser
,
Browser
:
userSession
.
ClientInfo
.
Browser
,
Country
:
userSession
.
ClientInfo
.
Country
,
}
}
}
}
...
...
web/src/components/Settings/MyAccountSection.tsx
View file @
4e3a4e36
...
@@ -7,6 +7,7 @@ import showUpdateAccountDialog from "../UpdateAccountDialog";
...
@@ -7,6 +7,7 @@ import showUpdateAccountDialog from "../UpdateAccountDialog";
import
UserAvatar
from
"../UserAvatar"
;
import
UserAvatar
from
"../UserAvatar"
;
import
{
Popover
,
PopoverContent
,
PopoverTrigger
}
from
"../ui/Popover"
;
import
{
Popover
,
PopoverContent
,
PopoverTrigger
}
from
"../ui/Popover"
;
import
AccessTokenSection
from
"./AccessTokenSection"
;
import
AccessTokenSection
from
"./AccessTokenSection"
;
import
UserSessionsSection
from
"./UserSessionsSection"
;
const
MyAccountSection
=
()
=>
{
const
MyAccountSection
=
()
=>
{
const
t
=
useTranslate
();
const
t
=
useTranslate
();
...
@@ -48,6 +49,7 @@ const MyAccountSection = () => {
...
@@ -48,6 +49,7 @@ const MyAccountSection = () => {
</
div
>
</
div
>
<
AccessTokenSection
/>
<
AccessTokenSection
/>
<
UserSessionsSection
/>
</
div
>
</
div
>
);
);
};
};
...
...
web/src/components/Settings/UserSessionsSection.tsx
0 → 100644
View file @
4e3a4e36
import
{
Button
}
from
"@usememos/mui"
;
import
{
ClockIcon
,
MonitorIcon
,
SmartphoneIcon
,
TabletIcon
,
TrashIcon
,
WifiIcon
}
from
"lucide-react"
;
import
{
useEffect
,
useState
}
from
"react"
;
import
{
toast
}
from
"react-hot-toast"
;
import
{
userServiceClient
}
from
"@/grpcweb"
;
import
useCurrentUser
from
"@/hooks/useCurrentUser"
;
import
{
UserSession
}
from
"@/types/proto/api/v1/user_service"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
import
LearnMore
from
"../LearnMore"
;
const
listUserSessions
=
async
(
parent
:
string
)
=>
{
const
{
sessions
}
=
await
userServiceClient
.
listUserSessions
({
parent
});
return
sessions
.
sort
((
a
,
b
)
=>
(
b
.
lastAccessedTime
?.
getTime
()
??
0
)
-
(
a
.
lastAccessedTime
?.
getTime
()
??
0
));
};
const
UserSessionsSection
=
()
=>
{
const
t
=
useTranslate
();
const
currentUser
=
useCurrentUser
();
const
[
userSessions
,
setUserSessions
]
=
useState
<
UserSession
[]
>
([]);
useEffect
(()
=>
{
listUserSessions
(
currentUser
.
name
).
then
((
sessions
)
=>
{
setUserSessions
(
sessions
);
});
},
[]);
const
handleRevokeSession
=
async
(
userSession
:
UserSession
)
=>
{
const
formattedSessionId
=
getFormattedSessionId
(
userSession
.
sessionId
);
const
confirmed
=
window
.
confirm
(
t
(
"setting.user-sessions-section.session-revocation"
,
{
sessionId
:
formattedSessionId
}));
if
(
confirmed
)
{
await
userServiceClient
.
revokeUserSession
({
name
:
userSession
.
name
});
setUserSessions
(
userSessions
.
filter
((
session
)
=>
session
.
sessionId
!==
userSession
.
sessionId
));
toast
.
success
(
t
(
"setting.user-sessions-section.session-revoked"
));
}
};
const
getFormattedSessionId
=
(
sessionId
:
string
)
=>
{
return
`
${
sessionId
.
slice
(
0
,
8
)}
...
${
sessionId
.
slice
(
-
8
)}
`
;
};
const
getDeviceIcon
=
(
deviceType
:
string
)
=>
{
switch
(
deviceType
?.
toLowerCase
())
{
case
"mobile"
:
return
<
SmartphoneIcon
className=
"w-4 h-4 text-gray-500"
/>;
case
"tablet"
:
return
<
TabletIcon
className=
"w-4 h-4 text-gray-500"
/>;
case
"desktop"
:
default
:
return
<
MonitorIcon
className=
"w-4 h-4 text-gray-500"
/>;
}
};
const
formatLocation
=
(
clientInfo
:
UserSession
[
"clientInfo"
])
=>
{
if
(
!
clientInfo
)
return
"Unknown"
;
const
parts
=
[];
if
(
clientInfo
.
ipAddress
)
parts
.
push
(
clientInfo
.
ipAddress
);
return
parts
.
length
>
0
?
parts
.
join
(
" • "
)
:
"Unknown"
;
};
const
formatDeviceInfo
=
(
clientInfo
:
UserSession
[
"clientInfo"
])
=>
{
if
(
!
clientInfo
)
return
"Unknown Device"
;
const
parts
=
[];
if
(
clientInfo
.
os
)
parts
.
push
(
clientInfo
.
os
);
if
(
clientInfo
.
browser
)
parts
.
push
(
clientInfo
.
browser
);
return
parts
.
length
>
0
?
parts
.
join
(
" • "
)
:
"Unknown Device"
;
};
const
isCurrentSession
=
(
session
:
UserSession
)
=>
{
// A simple heuristic: the most recently accessed session is likely the current one
if
(
userSessions
.
length
===
0
)
return
false
;
const
mostRecent
=
userSessions
[
0
];
return
session
.
sessionId
===
mostRecent
.
sessionId
;
};
return
(
<
div
className=
"mt-6 w-full flex flex-col justify-start items-start space-y-4"
>
<
div
className=
"w-full"
>
<
div
className=
"sm:flex sm:items-center sm:justify-between"
>
<
div
className=
"sm:flex-auto space-y-1"
>
<
p
className=
"flex flex-row justify-start items-center font-medium text-gray-700 dark:text-gray-400"
>
{
t
(
"setting.user-sessions-section.title"
)
}
<
LearnMore
className=
"ml-2"
url=
"https://usememos.com/docs/security/sessions"
/>
</
p
>
<
p
className=
"text-sm text-gray-700 dark:text-gray-500"
>
{
t
(
"setting.user-sessions-section.description"
)
}
</
p
>
</
div
>
</
div
>
<
div
className=
"w-full mt-2 flow-root"
>
<
div
className=
"overflow-x-auto"
>
<
div
className=
"inline-block min-w-full border border-zinc-200 rounded-lg align-middle dark:border-zinc-600"
>
<
table
className=
"min-w-full divide-y divide-gray-300 dark:divide-zinc-600"
>
<
thead
>
<
tr
>
<
th
scope=
"col"
className=
"px-3 py-2 text-left text-sm font-semibold text-gray-900 dark:text-gray-400"
>
{
t
(
"setting.user-sessions-section.device"
)
}
</
th
>
<
th
scope=
"col"
className=
"py-2 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 dark:text-gray-400"
>
{
t
(
"setting.user-sessions-section.location"
)
}
</
th
>
<
th
scope=
"col"
className=
"px-3 py-2 text-left text-sm font-semibold text-gray-900 dark:text-gray-400"
>
{
t
(
"setting.user-sessions-section.last-active"
)
}
</
th
>
<
th
scope=
"col"
className=
"px-3 py-2 text-left text-sm font-semibold text-gray-900 dark:text-gray-400"
>
{
t
(
"setting.user-sessions-section.expires"
)
}
</
th
>
<
th
scope=
"col"
className=
"relative py-3.5 pl-3 pr-4"
>
<
span
className=
"sr-only"
>
{
t
(
"common.delete"
)
}
</
span
>
</
th
>
</
tr
>
</
thead
>
<
tbody
className=
"divide-y divide-gray-200 dark:divide-zinc-700"
>
{
userSessions
.
map
((
userSession
)
=>
(
<
tr
key=
{
userSession
.
sessionId
}
>
<
td
className=
"whitespace-nowrap px-3 py-2 text-sm text-gray-900 dark:text-gray-400"
>
<
div
className=
"flex items-center space-x-3"
>
{
getDeviceIcon
(
userSession
.
clientInfo
?.
deviceType
||
""
)
}
<
div
className=
"flex flex-col"
>
<
span
className=
"font-medium"
>
{
formatDeviceInfo
(
userSession
.
clientInfo
)
}
{
isCurrentSession
(
userSession
)
&&
(
<
span
className=
"ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100"
>
<
WifiIcon
className=
"w-3 h-3 mr-1"
/>
{
t
(
"setting.user-sessions-section.current"
)
}
</
span
>
)
}
</
span
>
<
span
className=
"text-xs text-gray-500 font-mono"
>
{
getFormattedSessionId
(
userSession
.
sessionId
)
}
</
span
>
</
div
>
</
div
>
</
td
>
<
td
className=
"whitespace-nowrap py-2 pl-4 pr-3 text-sm text-gray-900 dark:text-gray-400"
>
{
formatLocation
(
userSession
.
clientInfo
)
}
</
td
>
<
td
className=
"whitespace-nowrap px-3 py-2 text-sm text-gray-500 dark:text-gray-400"
>
<
div
className=
"flex items-center space-x-1"
>
<
ClockIcon
className=
"w-4 h-4"
/>
<
span
>
{
userSession
.
lastAccessedTime
?.
toLocaleString
()
}
</
span
>
</
div
>
</
td
>
<
td
className=
"whitespace-nowrap px-3 py-2 text-sm text-gray-500 dark:text-gray-400"
>
{
userSession
.
expireTime
?.
toLocaleString
()
??
t
(
"setting.user-sessions-section.never"
)
}
</
td
>
<
td
className=
"relative whitespace-nowrap py-2 pl-3 pr-4 text-right text-sm"
>
<
Button
variant=
"plain"
disabled=
{
isCurrentSession
(
userSession
)
}
onClick=
{
()
=>
{
handleRevokeSession
(
userSession
);
}
}
title=
{
isCurrentSession
(
userSession
)
?
t
(
"setting.user-sessions-section.cannot-revoke-current"
)
:
t
(
"setting.user-sessions-section.revoke-session"
)
}
>
<
TrashIcon
className=
{
`w-4 h-auto ${isCurrentSession(userSession) ? "text-gray-400" : "text-red-600"}`
}
/>
</
Button
>
</
td
>
</
tr
>
))
}
</
tbody
>
</
table
>
{
userSessions
.
length
===
0
&&
(
<
div
className=
"text-center py-8 text-gray-500 dark:text-gray-400"
>
{
t
(
"setting.user-sessions-section.no-sessions"
)
}
</
div
>
)
}
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
);
};
export
default
UserSessionsSection
;
web/src/locales/en.json
View file @
4e3a4e36
...
@@ -251,6 +251,21 @@
...
@@ -251,6 +251,21 @@
"title"
:
"Access Tokens"
,
"title"
:
"Access Tokens"
,
"token"
:
"Token"
"token"
:
"Token"
},
},
"user-sessions-section"
:
{
"title"
:
"Active Sessions"
,
"description"
:
"A list of all active sessions for your account. You can revoke any session except the current one."
,
"device"
:
"Device"
,
"location"
:
"Location"
,
"last-active"
:
"Last Active"
,
"expires"
:
"Expires"
,
"current"
:
"Current"
,
"never"
:
"Never"
,
"session-revocation"
:
"Are you sure to revoke session {{sessionId}}? You will need to sign in again on that device."
,
"session-revoked"
:
"Session revoked successfully"
,
"revoke-session"
:
"Revoke session"
,
"cannot-revoke-current"
:
"Cannot revoke current session"
,
"no-sessions"
:
"No active sessions found"
},
"account-section"
:
{
"account-section"
:
{
"change-password"
:
"Change password"
,
"change-password"
:
"Change password"
,
"email-note"
:
"Optional"
,
"email-note"
:
"Optional"
,
...
...
web/src/types/proto/api/v1/user_service.ts
View file @
4e3a4e36
...
@@ -394,8 +394,6 @@ export interface UserSession_ClientInfo {
...
@@ -394,8 +394,6 @@ export interface UserSession_ClientInfo {
os
:
string
;
os
:
string
;
/** Optional. Browser name and version (e.g., "Chrome 119.0"). */
/** Optional. Browser name and version (e.g., "Chrome 119.0"). */
browser
:
string
;
browser
:
string
;
/** Optional. Geographic location (country code, e.g., "US"). */
country
:
string
;
}
}
export
interface
ListUserSessionsRequest
{
export
interface
ListUserSessionsRequest
{
...
@@ -2222,7 +2220,7 @@ export const UserSession: MessageFns<UserSession> = {
...
@@ -2222,7 +2220,7 @@ export const UserSession: MessageFns<UserSession> = {
};
};
function
createBaseUserSession_ClientInfo
():
UserSession_ClientInfo
{
function
createBaseUserSession_ClientInfo
():
UserSession_ClientInfo
{
return
{
userAgent
:
""
,
ipAddress
:
""
,
deviceType
:
""
,
os
:
""
,
browser
:
""
,
country
:
""
};
return
{
userAgent
:
""
,
ipAddress
:
""
,
deviceType
:
""
,
os
:
""
,
browser
:
""
};
}
}
export
const
UserSession_ClientInfo
:
MessageFns
<
UserSession_ClientInfo
>
=
{
export
const
UserSession_ClientInfo
:
MessageFns
<
UserSession_ClientInfo
>
=
{
...
@@ -2242,9 +2240,6 @@ export const UserSession_ClientInfo: MessageFns<UserSession_ClientInfo> = {
...
@@ -2242,9 +2240,6 @@ export const UserSession_ClientInfo: MessageFns<UserSession_ClientInfo> = {
if
(
message
.
browser
!==
""
)
{
if
(
message
.
browser
!==
""
)
{
writer
.
uint32
(
42
).
string
(
message
.
browser
);
writer
.
uint32
(
42
).
string
(
message
.
browser
);
}
}
if
(
message
.
country
!==
""
)
{
writer
.
uint32
(
50
).
string
(
message
.
country
);
}
return
writer
;
return
writer
;
},
},
...
@@ -2295,14 +2290,6 @@ export const UserSession_ClientInfo: MessageFns<UserSession_ClientInfo> = {
...
@@ -2295,14 +2290,6 @@ export const UserSession_ClientInfo: MessageFns<UserSession_ClientInfo> = {
message
.
browser
=
reader
.
string
();
message
.
browser
=
reader
.
string
();
continue
;
continue
;
}
}
case
6
:
{
if
(
tag
!==
50
)
{
break
;
}
message
.
country
=
reader
.
string
();
continue
;
}
}
}
if
((
tag
&
7
)
===
4
||
tag
===
0
)
{
if
((
tag
&
7
)
===
4
||
tag
===
0
)
{
break
;
break
;
...
@@ -2322,7 +2309,6 @@ export const UserSession_ClientInfo: MessageFns<UserSession_ClientInfo> = {
...
@@ -2322,7 +2309,6 @@ export const UserSession_ClientInfo: MessageFns<UserSession_ClientInfo> = {
message
.
deviceType
=
object
.
deviceType
??
""
;
message
.
deviceType
=
object
.
deviceType
??
""
;
message
.
os
=
object
.
os
??
""
;
message
.
os
=
object
.
os
??
""
;
message
.
browser
=
object
.
browser
??
""
;
message
.
browser
=
object
.
browser
??
""
;
message
.
country
=
object
.
country
??
""
;
return
message
;
return
message
;
},
},
};
};
...
...
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