mirror of
				https://github.com/usememos/memos.git
				synced 2025-06-05 22:09:59 +02:00 
			
		
		
		
	chore: update access token ui
This commit is contained in:
		| @@ -98,7 +98,7 @@ func (s *APIV1Service) SignIn(c echo.Context) error { | ||||
| 		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 { | ||||
| 		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)) | ||||
| 	} | ||||
|  | ||||
| 	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 { | ||||
| 		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 { | ||||
| 		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 { | ||||
| 		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/usememos/memos/api/auth" | ||||
| 	"github.com/usememos/memos/common/util" | ||||
| 	storepb "github.com/usememos/memos/proto/gen/store" | ||||
| 	"github.com/usememos/memos/store" | ||||
| ) | ||||
|  | ||||
| @@ -66,13 +67,15 @@ func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) e | ||||
| 			return next(c) | ||||
| 		} | ||||
|  | ||||
| 		println("path", path) | ||||
|  | ||||
| 		// 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 { | ||||
| 			return next(c) | ||||
| 		} | ||||
|  | ||||
| 		token := findAccessToken(c) | ||||
| 		if token == "" { | ||||
| 		accessToken := findAccessToken(c) | ||||
| 		if accessToken == "" { | ||||
| 			// Allow the user to access the public endpoints. | ||||
| 			if util.HasPrefixes(path, "/o") { | ||||
| 				return next(c) | ||||
| @@ -85,7 +88,7 @@ func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) e | ||||
| 		} | ||||
|  | ||||
| 		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 { | ||||
| 				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 { | ||||
| 			RemoveTokensAndCookies(c) | ||||
| 			return echo.NewHTTPError(http.StatusUnauthorized, errors.Wrap(err, "Invalid or expired access token")) | ||||
| 		} | ||||
| 		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.") | ||||
| 		} | ||||
|  | ||||
| 		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. | ||||
| 		user, err := server.Store.GetUser(ctx, &store.FindUser{ | ||||
| 			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() | ||||
| 	return util.HasPrefixes(path, "/api/v1/auth") | ||||
| } | ||||
|  | ||||
| 	// Skip auth. | ||||
| 	if util.HasPrefixes(path, "/api/v1/auth") { | ||||
| func validateAccessToken(accessTokenString string, userAccessTokens []*storepb.AccessTokensUserSetting_AccessToken) bool { | ||||
| 	for _, userAccessToken := range userAccessTokens { | ||||
| 		if accessTokenString == userAccessToken.AccessToken { | ||||
| 			return true | ||||
| 		} | ||||
|  | ||||
| 	} | ||||
| 	return false | ||||
| } | ||||
|   | ||||
| @@ -8,6 +8,7 @@ import ( | ||||
| 	"github.com/golang-jwt/jwt/v4" | ||||
| 	"github.com/pkg/errors" | ||||
| 	"github.com/usememos/memos/api/auth" | ||||
| 	storepb "github.com/usememos/memos/proto/gen/store" | ||||
| 	"github.com/usememos/memos/store" | ||||
| 	"google.golang.org/grpc" | ||||
| 	"google.golang.org/grpc/codes" | ||||
| @@ -44,12 +45,12 @@ func (in *GRPCAuthInterceptor) AuthenticationInterceptor(ctx context.Context, re | ||||
| 	if !ok { | ||||
| 		return nil, status.Errorf(codes.Unauthenticated, "failed to parse metadata from incoming context") | ||||
| 	} | ||||
| 	accessTokenStr, err := getTokenFromMetadata(md) | ||||
| 	accessToken, err := getTokenFromMetadata(md) | ||||
| 	if err != nil { | ||||
| 		return nil, status.Errorf(codes.Unauthenticated, err.Error()) | ||||
| 	} | ||||
|  | ||||
| 	username, err := in.authenticate(ctx, accessTokenStr) | ||||
| 	username, err := in.authenticate(ctx, accessToken) | ||||
| 	if err != nil { | ||||
| 		if isUnauthorizeAllowedMethod(serverInfo.FullMethod) { | ||||
| 			return handler(ctx, request) | ||||
| @@ -74,12 +75,12 @@ func (in *GRPCAuthInterceptor) AuthenticationInterceptor(ctx context.Context, re | ||||
| 	return handler(childCtx, request) | ||||
| } | ||||
|  | ||||
| func (in *GRPCAuthInterceptor) authenticate(ctx context.Context, accessTokenStr string) (string, error) { | ||||
| 	if accessTokenStr == "" { | ||||
| func (in *GRPCAuthInterceptor) authenticate(ctx context.Context, accessToken string) (string, error) { | ||||
| 	if accessToken == "" { | ||||
| 		return "", status.Errorf(codes.Unauthenticated, "access token not found") | ||||
| 	} | ||||
| 	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 { | ||||
| 			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) | ||||
| 	} | ||||
|  | ||||
| 	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 | ||||
| } | ||||
|  | ||||
| @@ -148,3 +157,12 @@ func audienceContains(audience jwt.ClaimStrings, token string) bool { | ||||
| 	} | ||||
| 	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) | ||||
| 	} | ||||
|  | ||||
| 	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 { | ||||
| 		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 { | ||||
| 	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 | ||||
| 	s.userSettingCache.Store(getUserSettingCacheKey(userSettingMessage.UserId, userSettingMessage.Key.String()), userSettingMessage) | ||||
| 	s.userSettingCache.Store(getUserSettingV1CacheKey(userSettingMessage.UserId, userSettingMessage.Key.String()), userSettingMessage) | ||||
| 	return userSettingMessage, nil | ||||
| } | ||||
|  | ||||
| @@ -195,14 +195,14 @@ func (s *Store) ListUserSettingsV1(ctx context.Context, find *FindUserSettingV1) | ||||
| 	} | ||||
|  | ||||
| 	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 | ||||
| } | ||||
|  | ||||
| func (s *Store) GetUserSettingV1(ctx context.Context, find *FindUserSettingV1) (*storepb.UserSetting, error) { | ||||
| 	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 | ||||
| 		} | ||||
| 	} | ||||
| @@ -217,7 +217,7 @@ func (s *Store) GetUserSettingV1(ctx context.Context, find *FindUserSettingV1) ( | ||||
| 	} | ||||
|  | ||||
| 	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 | ||||
| } | ||||
|  | ||||
|   | ||||
							
								
								
									
										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 showUpdateAccountDialog from "../UpdateAccountDialog"; | ||||
| import UserAvatar from "../UserAvatar"; | ||||
| import AccessTokenSection from "./AccessTokenSection"; | ||||
|  | ||||
| const MyAccountSection = () => { | ||||
|   const t = useTranslate(); | ||||
| @@ -12,7 +13,7 @@ const MyAccountSection = () => { | ||||
|   return ( | ||||
|     <> | ||||
|       <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"> | ||||
|           <UserAvatar className="mr-2" avatarUrl={user.avatarUrl} /> | ||||
|           <span className="text-2xl leading-10 font-medium">{user.nickname}</span> | ||||
| @@ -27,6 +28,8 @@ const MyAccountSection = () => { | ||||
|             {t("setting.account-section.change-password")} | ||||
|           </Button> | ||||
|         </div> | ||||
|  | ||||
|         <AccessTokenSection /> | ||||
|       </div> | ||||
|     </> | ||||
|   ); | ||||
|   | ||||
| @@ -42,7 +42,7 @@ const EmbedMemo = () => { | ||||
|   return ( | ||||
|     <section className="w-full h-full flex flex-row justify-start items-start p-2"> | ||||
|       {!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 mb-2 flex flex-row justify-start items-center text-sm text-gray-400 dark:text-gray-300"> | ||||
|               <span>{getDateTimeString(state.memo.displayTs)}</span> | ||||
| @@ -53,7 +53,7 @@ const EmbedMemo = () => { | ||||
|             <MemoContent className="memo-content" content={state.memo.content} onMemoContentClick={() => undefined} /> | ||||
|             <MemoResourceListView resourceList={state.memo.resourceList} /> | ||||
|           </div> | ||||
|         </main> | ||||
|         </div> | ||||
|       )} | ||||
|     </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"> | ||||
|       <MobileHeader showSearch={false} /> | ||||
|       {!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 /> | ||||
|           {sortedMemos.map((memo) => { | ||||
|             return <Memo key={`${memo.id}-${memo.displayTs}`} memo={memo} />; | ||||
| @@ -107,7 +107,7 @@ const Explore = () => { | ||||
|               {t("memo.fetch-more")} | ||||
|             </p> | ||||
|           )} | ||||
|         </main> | ||||
|         </div> | ||||
|       )} | ||||
|     </section> | ||||
|   ); | ||||
|   | ||||
| @@ -13,16 +13,14 @@ const Home = () => { | ||||
|   const user = useCurrentUser(); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (user) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (!user) { | ||||
|       const systemStatus = globalStore.state.systemStatus; | ||||
|       if (systemStatus.disablePublicMemos) { | ||||
|         window.location.href = "/auth"; | ||||
|       } else { | ||||
|         window.location.href = "/explore"; | ||||
|       } | ||||
|     } | ||||
|   }, []); | ||||
|  | ||||
|   return ( | ||||
|   | ||||
| @@ -47,9 +47,9 @@ const MemoDetail = () => { | ||||
|           {!loadingState.isLoading && | ||||
|             (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} /> | ||||
|                 </main> | ||||
|                 </div> | ||||
|               </> | ||||
|             ) : ( | ||||
|               <> | ||||
|   | ||||
| @@ -49,7 +49,7 @@ const Setting = () => { | ||||
|   }; | ||||
|  | ||||
|   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} /> | ||||
|       <div className="setting-page-wrapper"> | ||||
|         <div className="section-selector-container"> | ||||
| @@ -100,7 +100,7 @@ const Setting = () => { | ||||
|             </> | ||||
|           ) : null} | ||||
|         </div> | ||||
|         <div className="section-content-container"> | ||||
|         <div className="section-content-container sm:max-w-[calc(100%-14rem)]"> | ||||
|           <Select | ||||
|             className="block mb-2 sm:!hidden" | ||||
|             value={state.selectedSection} | ||||
|   | ||||
| @@ -38,7 +38,7 @@ const UserProfile = () => { | ||||
|           {!loadingState.isLoading && | ||||
|             (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="flex-grow shrink w-full"> | ||||
|                       <div className="w-full flex flex-col justify-start items-center py-8"> | ||||
| @@ -53,7 +53,7 @@ const UserProfile = () => { | ||||
|                       <MemoList /> | ||||
|                     </div> | ||||
|                   </div> | ||||
|                 </main> | ||||
|                 </div> | ||||
|               </> | ||||
|             ) : ( | ||||
|               <> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user