mirror of
https://github.com/usememos/memos.git
synced 2025-04-02 20:10:45 +02:00
chore: update access token ui
This commit is contained in:
parent
33d9c13b7e
commit
f5802a7d82
@ -98,7 +98,7 @@ func (s *APIV1Service) SignIn(c echo.Context) error {
|
|||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Incorrect login credentials, please try again")
|
return echo.NewHTTPError(http.StatusUnauthorized, "Incorrect login credentials, please try again")
|
||||||
}
|
}
|
||||||
|
|
||||||
accessToken, err := auth.GenerateAccessToken(user.Email, user.ID, time.Now().Add(auth.AccessTokenDuration), s.Secret)
|
accessToken, err := auth.GenerateAccessToken(user.Username, user.ID, time.Now().Add(auth.AccessTokenDuration), s.Secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate tokens, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate tokens, err: %s", err)).SetInternal(err)
|
||||||
}
|
}
|
||||||
@ -222,7 +222,7 @@ func (s *APIV1Service) SignInSSO(c echo.Context) error {
|
|||||||
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("User has been archived with username %s", userInfo.Identifier))
|
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("User has been archived with username %s", userInfo.Identifier))
|
||||||
}
|
}
|
||||||
|
|
||||||
accessToken, err := auth.GenerateAccessToken(user.Email, user.ID, time.Now().Add(auth.AccessTokenDuration), s.Secret)
|
accessToken, err := auth.GenerateAccessToken(user.Username, user.ID, time.Now().Add(auth.AccessTokenDuration), s.Secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate tokens, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate tokens, err: %s", err)).SetInternal(err)
|
||||||
}
|
}
|
||||||
@ -318,7 +318,7 @@ func (s *APIV1Service) SignUp(c echo.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
|
||||||
}
|
}
|
||||||
accessToken, err := auth.GenerateAccessToken(user.Email, user.ID, time.Now().Add(auth.AccessTokenDuration), s.Secret)
|
accessToken, err := auth.GenerateAccessToken(user.Username, user.ID, time.Now().Add(auth.AccessTokenDuration), s.Secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate tokens, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate tokens, err: %s", err)).SetInternal(err)
|
||||||
}
|
}
|
||||||
|
@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/usememos/memos/api/auth"
|
"github.com/usememos/memos/api/auth"
|
||||||
"github.com/usememos/memos/common/util"
|
"github.com/usememos/memos/common/util"
|
||||||
|
storepb "github.com/usememos/memos/proto/gen/store"
|
||||||
"github.com/usememos/memos/store"
|
"github.com/usememos/memos/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -66,13 +67,15 @@ func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) e
|
|||||||
return next(c)
|
return next(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
println("path", path)
|
||||||
|
|
||||||
// Skip validation for server status endpoints.
|
// Skip validation for server status endpoints.
|
||||||
if util.HasPrefixes(path, "/api/v1/ping", "/api/v1/idp", "/api/v1/status", "/api/v1/user") && path != "/api/v1/user/me" && method == http.MethodGet {
|
if util.HasPrefixes(path, "/api/v1/ping", "/api/v1/idp", "/api/v1/status", "/api/v1/user") && path != "/api/v1/user/me" && method == http.MethodGet {
|
||||||
return next(c)
|
return next(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
token := findAccessToken(c)
|
accessToken := findAccessToken(c)
|
||||||
if token == "" {
|
if accessToken == "" {
|
||||||
// Allow the user to access the public endpoints.
|
// Allow the user to access the public endpoints.
|
||||||
if util.HasPrefixes(path, "/o") {
|
if util.HasPrefixes(path, "/o") {
|
||||||
return next(c)
|
return next(c)
|
||||||
@ -85,7 +88,7 @@ func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) e
|
|||||||
}
|
}
|
||||||
|
|
||||||
claims := &auth.ClaimsMessage{}
|
claims := &auth.ClaimsMessage{}
|
||||||
_, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
|
_, err := jwt.ParseWithClaims(accessToken, claims, func(t *jwt.Token) (any, error) {
|
||||||
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
|
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
|
||||||
return nil, errors.Errorf("unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
|
return nil, errors.Errorf("unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
|
||||||
}
|
}
|
||||||
@ -98,6 +101,7 @@ func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) e
|
|||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
RemoveTokensAndCookies(c)
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, errors.Wrap(err, "Invalid or expired access token"))
|
return echo.NewHTTPError(http.StatusUnauthorized, errors.Wrap(err, "Invalid or expired access token"))
|
||||||
}
|
}
|
||||||
if !audienceContains(claims.Audience, auth.AccessTokenAudienceName) {
|
if !audienceContains(claims.Audience, auth.AccessTokenAudienceName) {
|
||||||
@ -110,6 +114,15 @@ func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) e
|
|||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Malformed ID in the token.")
|
return echo.NewHTTPError(http.StatusUnauthorized, "Malformed ID in the token.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
accessTokens, err := server.Store.GetUserAccessTokens(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get user access tokens.").WithInternal(err)
|
||||||
|
}
|
||||||
|
if !validateAccessToken(accessToken, accessTokens) {
|
||||||
|
RemoveTokensAndCookies(c)
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid access token.")
|
||||||
|
}
|
||||||
|
|
||||||
// Even if there is no error, we still need to make sure the user still exists.
|
// Even if there is no error, we still need to make sure the user still exists.
|
||||||
user, err := server.Store.GetUser(ctx, &store.FindUser{
|
user, err := server.Store.GetUser(ctx, &store.FindUser{
|
||||||
ID: &userID,
|
ID: &userID,
|
||||||
@ -127,13 +140,16 @@ func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) e
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIV1Service) defaultAuthSkipper(c echo.Context) bool {
|
func (*APIV1Service) defaultAuthSkipper(c echo.Context) bool {
|
||||||
path := c.Path()
|
path := c.Path()
|
||||||
|
return util.HasPrefixes(path, "/api/v1/auth")
|
||||||
|
}
|
||||||
|
|
||||||
// Skip auth.
|
func validateAccessToken(accessTokenString string, userAccessTokens []*storepb.AccessTokensUserSetting_AccessToken) bool {
|
||||||
if util.HasPrefixes(path, "/api/v1/auth") {
|
for _, userAccessToken := range userAccessTokens {
|
||||||
return true
|
if accessTokenString == userAccessToken.AccessToken {
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/golang-jwt/jwt/v4"
|
"github.com/golang-jwt/jwt/v4"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/usememos/memos/api/auth"
|
"github.com/usememos/memos/api/auth"
|
||||||
|
storepb "github.com/usememos/memos/proto/gen/store"
|
||||||
"github.com/usememos/memos/store"
|
"github.com/usememos/memos/store"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
@ -44,12 +45,12 @@ func (in *GRPCAuthInterceptor) AuthenticationInterceptor(ctx context.Context, re
|
|||||||
if !ok {
|
if !ok {
|
||||||
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")
|
||||||
}
|
}
|
||||||
accessTokenStr, err := getTokenFromMetadata(md)
|
accessToken, err := getTokenFromMetadata(md)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Unauthenticated, err.Error())
|
return nil, status.Errorf(codes.Unauthenticated, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
username, err := in.authenticate(ctx, accessTokenStr)
|
username, err := in.authenticate(ctx, accessToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if isUnauthorizeAllowedMethod(serverInfo.FullMethod) {
|
if isUnauthorizeAllowedMethod(serverInfo.FullMethod) {
|
||||||
return handler(ctx, request)
|
return handler(ctx, request)
|
||||||
@ -74,12 +75,12 @@ func (in *GRPCAuthInterceptor) AuthenticationInterceptor(ctx context.Context, re
|
|||||||
return handler(childCtx, request)
|
return handler(childCtx, request)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (in *GRPCAuthInterceptor) authenticate(ctx context.Context, accessTokenStr string) (string, error) {
|
func (in *GRPCAuthInterceptor) authenticate(ctx context.Context, accessToken string) (string, error) {
|
||||||
if accessTokenStr == "" {
|
if accessToken == "" {
|
||||||
return "", status.Errorf(codes.Unauthenticated, "access token not found")
|
return "", status.Errorf(codes.Unauthenticated, "access token not found")
|
||||||
}
|
}
|
||||||
claims := &auth.ClaimsMessage{}
|
claims := &auth.ClaimsMessage{}
|
||||||
_, err := jwt.ParseWithClaims(accessTokenStr, claims, func(t *jwt.Token) (any, error) {
|
_, err := jwt.ParseWithClaims(accessToken, claims, func(t *jwt.Token) (any, error) {
|
||||||
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
|
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
|
||||||
return nil, status.Errorf(codes.Unauthenticated, "unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
|
return nil, status.Errorf(codes.Unauthenticated, "unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
|
||||||
}
|
}
|
||||||
@ -115,6 +116,14 @@ func (in *GRPCAuthInterceptor) authenticate(ctx context.Context, accessTokenStr
|
|||||||
return "", errors.Errorf("user %q is archived", username)
|
return "", errors.Errorf("user %q is archived", username)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
accessTokens, err := in.Store.GetUserAccessTokens(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrapf(err, "failed to get user access tokens")
|
||||||
|
}
|
||||||
|
if !validateAccessToken(accessToken, accessTokens) {
|
||||||
|
return "", status.Errorf(codes.Unauthenticated, "invalid access token")
|
||||||
|
}
|
||||||
|
|
||||||
return username, nil
|
return username, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -148,3 +157,12 @@ func audienceContains(audience jwt.ClaimStrings, token string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateAccessToken(accessTokenString string, userAccessTokens []*storepb.AccessTokensUserSetting_AccessToken) bool {
|
||||||
|
for _, userAccessToken := range userAccessTokens {
|
||||||
|
if accessTokenString == userAccessToken.AccessToken {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
@ -167,7 +167,7 @@ func (s *UserService) CreateUserAccessToken(ctx context.Context, request *apiv2p
|
|||||||
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
accessToken, err := auth.GenerateAccessToken(user.Email, user.ID, request.UserAccessToken.ExpiresAt.AsTime(), s.Secret)
|
accessToken, err := auth.GenerateAccessToken(user.Username, user.ID, request.UserAccessToken.ExpiresAt.AsTime(), s.Secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "failed to generate access token: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to generate access token: %v", err)
|
||||||
}
|
}
|
||||||
|
@ -7,3 +7,7 @@ import (
|
|||||||
func getUserSettingCacheKey(userID int32, key string) string {
|
func getUserSettingCacheKey(userID int32, key string) string {
|
||||||
return fmt.Sprintf("%d-%s", userID, key)
|
return fmt.Sprintf("%d-%s", userID, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getUserSettingV1CacheKey(userID int32, key string) string {
|
||||||
|
return fmt.Sprintf("%d-%s-v1", userID, key)
|
||||||
|
}
|
||||||
|
@ -136,7 +136,7 @@ func (s *Store) UpsertUserSettingV1(ctx context.Context, upsert *storepb.UserSet
|
|||||||
}
|
}
|
||||||
|
|
||||||
userSettingMessage := upsert
|
userSettingMessage := upsert
|
||||||
s.userSettingCache.Store(getUserSettingCacheKey(userSettingMessage.UserId, userSettingMessage.Key.String()), userSettingMessage)
|
s.userSettingCache.Store(getUserSettingV1CacheKey(userSettingMessage.UserId, userSettingMessage.Key.String()), userSettingMessage)
|
||||||
return userSettingMessage, nil
|
return userSettingMessage, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -195,14 +195,14 @@ func (s *Store) ListUserSettingsV1(ctx context.Context, find *FindUserSettingV1)
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, userSetting := range userSettingList {
|
for _, userSetting := range userSettingList {
|
||||||
s.userSettingCache.Store(getUserSettingCacheKey(userSetting.UserId, userSetting.Key.String()), userSetting)
|
s.userSettingCache.Store(getUserSettingV1CacheKey(userSetting.UserId, userSetting.Key.String()), userSetting)
|
||||||
}
|
}
|
||||||
return userSettingList, nil
|
return userSettingList, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) GetUserSettingV1(ctx context.Context, find *FindUserSettingV1) (*storepb.UserSetting, error) {
|
func (s *Store) GetUserSettingV1(ctx context.Context, find *FindUserSettingV1) (*storepb.UserSetting, error) {
|
||||||
if find.UserID != nil {
|
if find.UserID != nil {
|
||||||
if cache, ok := s.userSettingCache.Load(getUserSettingCacheKey(*find.UserID, find.Key.String())); ok {
|
if cache, ok := s.userSettingCache.Load(getUserSettingV1CacheKey(*find.UserID, find.Key.String())); ok {
|
||||||
return cache.(*storepb.UserSetting), nil
|
return cache.(*storepb.UserSetting), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -217,7 +217,7 @@ func (s *Store) GetUserSettingV1(ctx context.Context, find *FindUserSettingV1) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
userSetting := list[0]
|
userSetting := list[0]
|
||||||
s.userSettingCache.Store(getUserSettingCacheKey(userSetting.UserId, userSetting.Key.String()), userSetting)
|
s.userSettingCache.Store(getUserSettingV1CacheKey(userSetting.UserId, userSetting.Key.String()), userSetting)
|
||||||
return userSetting, nil
|
return userSetting, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
145
web/src/components/CreateAccessTokenDialog.tsx
Normal file
145
web/src/components/CreateAccessTokenDialog.tsx
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import { Button, Input, Radio, RadioGroup } from "@mui/joy";
|
||||||
|
import axios from "axios";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { toast } from "react-hot-toast";
|
||||||
|
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||||
|
import useLoading from "@/hooks/useLoading";
|
||||||
|
import { useTranslate } from "@/utils/i18n";
|
||||||
|
import { generateDialog } from "./Dialog";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
interface Props extends DialogProps {
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expirationOptions = [
|
||||||
|
{
|
||||||
|
label: "8 hours",
|
||||||
|
value: 3600 * 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "1 month",
|
||||||
|
value: 3600 * 24 * 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Never",
|
||||||
|
value: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
description: string;
|
||||||
|
expiration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreateAccessTokenDialog: React.FC<Props> = (props: Props) => {
|
||||||
|
const { destroy, onConfirm } = props;
|
||||||
|
const t = useTranslate();
|
||||||
|
const currentUser = useCurrentUser();
|
||||||
|
const [state, setState] = useState({
|
||||||
|
description: "",
|
||||||
|
expiration: 3600 * 8,
|
||||||
|
});
|
||||||
|
const requestState = useLoading(false);
|
||||||
|
|
||||||
|
const setPartialState = (partialState: Partial<State>) => {
|
||||||
|
setState({
|
||||||
|
...state,
|
||||||
|
...partialState,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDescriptionInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPartialState({
|
||||||
|
description: e.target.value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRoleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPartialState({
|
||||||
|
expiration: Number(e.target.value),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveBtnClick = async () => {
|
||||||
|
if (!state.description) {
|
||||||
|
toast.error("Description is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.post(`/api/v2/users/${currentUser.id}/access_tokens`, {
|
||||||
|
description: state.description,
|
||||||
|
expiresAt: new Date(Date.now() + state.expiration * 1000),
|
||||||
|
});
|
||||||
|
|
||||||
|
onConfirm();
|
||||||
|
destroy();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error(error.response.data.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="dialog-header-container">
|
||||||
|
<p className="title-text">Create access token</p>
|
||||||
|
<button className="btn close-btn" onClick={() => destroy()}>
|
||||||
|
<Icon.X />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="dialog-content-container !w-80">
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2">
|
||||||
|
Description <span className="text-red-600">*</span>
|
||||||
|
</span>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="text"
|
||||||
|
placeholder="Some description"
|
||||||
|
value={state.description}
|
||||||
|
onChange={handleDescriptionInputChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2">
|
||||||
|
Expiration <span className="text-red-600">*</span>
|
||||||
|
</span>
|
||||||
|
<div className="w-full flex flex-row justify-start items-center text-base">
|
||||||
|
<RadioGroup orientation="horizontal" value={state.expiration} onChange={handleRoleInputChange}>
|
||||||
|
{expirationOptions.map((option) => (
|
||||||
|
<Radio key={option.value} value={option.value} checked={state.expiration === option.value} label={option.label} />
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-row justify-end items-center mt-4 space-x-2">
|
||||||
|
<Button color="neutral" variant="plain" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={destroy}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button color="primary" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={handleSaveBtnClick}>
|
||||||
|
{t("common.create")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function showCreateAccessTokenDialog(onConfirm: () => void) {
|
||||||
|
generateDialog(
|
||||||
|
{
|
||||||
|
className: "create-access-token-dialog",
|
||||||
|
dialogName: "create-access-token-dialog",
|
||||||
|
},
|
||||||
|
CreateAccessTokenDialog,
|
||||||
|
{
|
||||||
|
onConfirm,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default showCreateAccessTokenDialog;
|
148
web/src/components/Settings/AccessTokenSection.tsx
Normal file
148
web/src/components/Settings/AccessTokenSection.tsx
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import { Button, IconButton } from "@mui/joy";
|
||||||
|
import axios from "axios";
|
||||||
|
import copy from "copy-to-clipboard";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { toast } from "react-hot-toast";
|
||||||
|
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||||
|
import { ListUserAccessTokensResponse, UserAccessToken } from "@/types/proto/api/v2/user_service_pb";
|
||||||
|
import { useTranslate } from "@/utils/i18n";
|
||||||
|
import showCreateAccessTokenDialog from "../CreateAccessTokenDialog";
|
||||||
|
import { showCommonDialog } from "../Dialog/CommonDialog";
|
||||||
|
import Icon from "../Icon";
|
||||||
|
|
||||||
|
const listAccessTokens = async (username: string) => {
|
||||||
|
const { data } = await axios.get<ListUserAccessTokensResponse>(`/api/v2/users/${username}/access_tokens`);
|
||||||
|
return data.accessTokens;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AccessTokenSection = () => {
|
||||||
|
const t = useTranslate();
|
||||||
|
const currentUser = useCurrentUser();
|
||||||
|
const [userAccessTokens, setUserAccessTokens] = useState<UserAccessToken[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listAccessTokens(currentUser.username).then((accessTokens) => {
|
||||||
|
setUserAccessTokens(accessTokens);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateAccessTokenDialogConfirm = async () => {
|
||||||
|
const accessTokens = await listAccessTokens(currentUser.username);
|
||||||
|
setUserAccessTokens(accessTokens);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyAccessToken = (accessToken: string) => {
|
||||||
|
copy(accessToken);
|
||||||
|
toast.success("Access token copied to clipboard");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAccessToken = async (accessToken: string) => {
|
||||||
|
showCommonDialog({
|
||||||
|
title: "Delete Access Token",
|
||||||
|
content: `Are you sure to delete access token \`${getFormatedAccessToken(accessToken)}\`? You cannot undo this action.`,
|
||||||
|
style: "danger",
|
||||||
|
dialogName: "delete-access-token-dialog",
|
||||||
|
onConfirm: async () => {
|
||||||
|
await axios.delete(`/api/v2/users/${currentUser.id}/access_tokens/${accessToken}`);
|
||||||
|
setUserAccessTokens(userAccessTokens.filter((token) => token.accessToken !== accessToken));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFormatedAccessToken = (accessToken: string) => {
|
||||||
|
return `${accessToken.slice(0, 4)}****${accessToken.slice(-4)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mt-8 w-full flex flex-col justify-start items-start space-y-4">
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="sm:flex sm:items-center">
|
||||||
|
<div className="sm:flex-auto">
|
||||||
|
<p className="text-sm mt-4 first:mt-2 mb-3 font-mono text-gray-500 dark:text-gray-400">Access Tokens</p>
|
||||||
|
<p className="mt-2 text-sm text-gray-700">A list of all access tokens for your account.</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="neutral"
|
||||||
|
onClick={() => {
|
||||||
|
showCreateAccessTokenDialog(handleCreateAccessTokenDialogConfirm);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("common.create")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flow-root">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="inline-block min-w-full py-2 align-middle">
|
||||||
|
<table className="min-w-full divide-y divide-gray-300">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-400">
|
||||||
|
Token
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 dark:text-gray-400">
|
||||||
|
Description
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-400">
|
||||||
|
Created At
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-400">
|
||||||
|
Expires At
|
||||||
|
</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">
|
||||||
|
{userAccessTokens.map((userAccessToken) => (
|
||||||
|
<tr key={userAccessToken.accessToken}>
|
||||||
|
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-900 dark:text-gray-400 flex flex-row justify-start items-center gap-x-1">
|
||||||
|
<span className="font-mono">{getFormatedAccessToken(userAccessToken.accessToken)}</span>
|
||||||
|
<IconButton
|
||||||
|
color="neutral"
|
||||||
|
variant="plain"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => copyAccessToken(userAccessToken.accessToken)}
|
||||||
|
>
|
||||||
|
<Icon.Clipboard className="w-4 h-auto text-gray-400" />
|
||||||
|
</IconButton>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm text-gray-900 dark:text-gray-400">
|
||||||
|
{userAccessToken.description}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{String(userAccessToken.issuedAt)}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{String(userAccessToken.expiresAt ?? "Never")}
|
||||||
|
</td>
|
||||||
|
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm">
|
||||||
|
<IconButton
|
||||||
|
color="danger"
|
||||||
|
variant="plain"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
handleDeleteAccessToken(userAccessToken.accessToken);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon.Trash className="w-4 h-auto" />
|
||||||
|
</IconButton>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AccessTokenSection;
|
@ -4,6 +4,7 @@ import { useTranslate } from "@/utils/i18n";
|
|||||||
import showChangePasswordDialog from "../ChangePasswordDialog";
|
import showChangePasswordDialog from "../ChangePasswordDialog";
|
||||||
import showUpdateAccountDialog from "../UpdateAccountDialog";
|
import showUpdateAccountDialog from "../UpdateAccountDialog";
|
||||||
import UserAvatar from "../UserAvatar";
|
import UserAvatar from "../UserAvatar";
|
||||||
|
import AccessTokenSection from "./AccessTokenSection";
|
||||||
|
|
||||||
const MyAccountSection = () => {
|
const MyAccountSection = () => {
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
@ -12,7 +13,7 @@ const MyAccountSection = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="section-container account-section-container">
|
<div className="section-container account-section-container">
|
||||||
<p className="title-text">{t("setting.account-section.title")}</p>
|
<p className="text-sm mt-4 first:mt-2 mb-3 font-mono text-gray-500 dark:text-gray-400">{t("setting.account-section.title")}</p>
|
||||||
<div className="flex flex-row justify-start items-center">
|
<div className="flex flex-row justify-start items-center">
|
||||||
<UserAvatar className="mr-2" avatarUrl={user.avatarUrl} />
|
<UserAvatar className="mr-2" avatarUrl={user.avatarUrl} />
|
||||||
<span className="text-2xl leading-10 font-medium">{user.nickname}</span>
|
<span className="text-2xl leading-10 font-medium">{user.nickname}</span>
|
||||||
@ -27,6 +28,8 @@ const MyAccountSection = () => {
|
|||||||
{t("setting.account-section.change-password")}
|
{t("setting.account-section.change-password")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AccessTokenSection />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -42,7 +42,7 @@ const EmbedMemo = () => {
|
|||||||
return (
|
return (
|
||||||
<section className="w-full h-full flex flex-row justify-start items-start p-2">
|
<section className="w-full h-full flex flex-row justify-start items-start p-2">
|
||||||
{!loadingState.isLoading && (
|
{!loadingState.isLoading && (
|
||||||
<main className="w-full max-w-lg mx-auto my-auto shadow px-4 py-4 rounded-lg">
|
<div className="w-full max-w-lg mx-auto my-auto shadow px-4 py-4 rounded-lg">
|
||||||
<div className="w-full flex flex-col justify-start items-start">
|
<div className="w-full flex flex-col justify-start items-start">
|
||||||
<div className="w-full mb-2 flex flex-row justify-start items-center text-sm text-gray-400 dark:text-gray-300">
|
<div className="w-full mb-2 flex flex-row justify-start items-center text-sm text-gray-400 dark:text-gray-300">
|
||||||
<span>{getDateTimeString(state.memo.displayTs)}</span>
|
<span>{getDateTimeString(state.memo.displayTs)}</span>
|
||||||
@ -53,7 +53,7 @@ const EmbedMemo = () => {
|
|||||||
<MemoContent className="memo-content" content={state.memo.content} onMemoContentClick={() => undefined} />
|
<MemoContent className="memo-content" content={state.memo.content} onMemoContentClick={() => undefined} />
|
||||||
<MemoResourceListView resourceList={state.memo.resourceList} />
|
<MemoResourceListView resourceList={state.memo.resourceList} />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
@ -90,7 +90,7 @@ const Explore = () => {
|
|||||||
<section className="w-full max-w-3xl min-h-full flex flex-col justify-start items-center px-4 sm:px-2 sm:pt-4 pb-8 bg-zinc-100 dark:bg-zinc-800">
|
<section className="w-full max-w-3xl min-h-full flex flex-col justify-start items-center px-4 sm:px-2 sm:pt-4 pb-8 bg-zinc-100 dark:bg-zinc-800">
|
||||||
<MobileHeader showSearch={false} />
|
<MobileHeader showSearch={false} />
|
||||||
{!loadingState.isLoading && (
|
{!loadingState.isLoading && (
|
||||||
<main className="relative w-full h-auto flex flex-col justify-start items-start">
|
<div className="relative w-full h-auto flex flex-col justify-start items-start">
|
||||||
<MemoFilter />
|
<MemoFilter />
|
||||||
{sortedMemos.map((memo) => {
|
{sortedMemos.map((memo) => {
|
||||||
return <Memo key={`${memo.id}-${memo.displayTs}`} memo={memo} />;
|
return <Memo key={`${memo.id}-${memo.displayTs}`} memo={memo} />;
|
||||||
@ -107,7 +107,7 @@ const Explore = () => {
|
|||||||
{t("memo.fetch-more")}
|
{t("memo.fetch-more")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</main>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
@ -13,15 +13,13 @@ const Home = () => {
|
|||||||
const user = useCurrentUser();
|
const user = useCurrentUser();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (!user) {
|
||||||
return;
|
const systemStatus = globalStore.state.systemStatus;
|
||||||
}
|
if (systemStatus.disablePublicMemos) {
|
||||||
|
window.location.href = "/auth";
|
||||||
const systemStatus = globalStore.state.systemStatus;
|
} else {
|
||||||
if (systemStatus.disablePublicMemos) {
|
window.location.href = "/explore";
|
||||||
window.location.href = "/auth";
|
}
|
||||||
} else {
|
|
||||||
window.location.href = "/explore";
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
@ -47,9 +47,9 @@ const MemoDetail = () => {
|
|||||||
{!loadingState.isLoading &&
|
{!loadingState.isLoading &&
|
||||||
(memo ? (
|
(memo ? (
|
||||||
<>
|
<>
|
||||||
<main className="relative flex-grow max-w-2xl w-full min-h-full flex flex-col justify-start items-start px-4">
|
<div className="relative flex-grow max-w-2xl w-full min-h-full flex flex-col justify-start items-start px-4">
|
||||||
<Memo memo={memo} />
|
<Memo memo={memo} />
|
||||||
</main>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
@ -49,7 +49,7 @@ const Setting = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="w-full min-h-full flex flex-col md:flex-row justify-start items-start px-4 sm:px-2 sm:pt-4 pb-8 bg-zinc-100 dark:bg-zinc-800">
|
<section className="w-full max-w-3xl min-h-full flex flex-col md:flex-row justify-start items-start px-4 sm:px-2 sm:pt-4 pb-8 bg-zinc-100 dark:bg-zinc-800">
|
||||||
<MobileHeader showSearch={false} />
|
<MobileHeader showSearch={false} />
|
||||||
<div className="setting-page-wrapper">
|
<div className="setting-page-wrapper">
|
||||||
<div className="section-selector-container">
|
<div className="section-selector-container">
|
||||||
@ -100,7 +100,7 @@ const Setting = () => {
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="section-content-container">
|
<div className="section-content-container sm:max-w-[calc(100%-14rem)]">
|
||||||
<Select
|
<Select
|
||||||
className="block mb-2 sm:!hidden"
|
className="block mb-2 sm:!hidden"
|
||||||
value={state.selectedSection}
|
value={state.selectedSection}
|
||||||
|
@ -38,7 +38,7 @@ const UserProfile = () => {
|
|||||||
{!loadingState.isLoading &&
|
{!loadingState.isLoading &&
|
||||||
(user ? (
|
(user ? (
|
||||||
<>
|
<>
|
||||||
<main className="relative flex-grow max-w-2xl w-full min-h-full flex flex-col justify-start items-start px-4">
|
<div className="relative flex-grow max-w-2xl w-full min-h-full flex flex-col justify-start items-start px-4">
|
||||||
<div className="w-full flex flex-row justify-start items-start">
|
<div className="w-full flex flex-row justify-start items-start">
|
||||||
<div className="flex-grow shrink w-full">
|
<div className="flex-grow shrink w-full">
|
||||||
<div className="w-full flex flex-col justify-start items-center py-8">
|
<div className="w-full flex flex-col justify-start items-center py-8">
|
||||||
@ -53,7 +53,7 @@ const UserProfile = () => {
|
|||||||
<MemoList />
|
<MemoList />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user