mirror of
https://github.com/usememos/memos.git
synced 2025-03-20 20:50:07 +01: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")
|
||||
}
|
||||
|
||||
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") {
|
||||
return true
|
||||
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,15 +13,13 @@ const Home = () => {
|
||||
const user = useCurrentUser();
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const systemStatus = globalStore.state.systemStatus;
|
||||
if (systemStatus.disablePublicMemos) {
|
||||
window.location.href = "/auth";
|
||||
} else {
|
||||
window.location.href = "/explore";
|
||||
if (!user) {
|
||||
const systemStatus = globalStore.state.systemStatus;
|
||||
if (systemStatus.disablePublicMemos) {
|
||||
window.location.href = "/auth";
|
||||
} else {
|
||||
window.location.href = "/explore";
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
@ -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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
Loading…
x
Reference in New Issue
Block a user