chore: update access token ui

This commit is contained in:
Steven 2023-09-15 08:18:30 +08:00
parent 33d9c13b7e
commit f5802a7d82
15 changed files with 373 additions and 41 deletions

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
}

View 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;

View 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;

View File

@ -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>
</>
);

View File

@ -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>
);

View File

@ -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>
);

View File

@ -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";
}
}
}, []);

View File

@ -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>
</>
) : (
<>

View File

@ -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}

View File

@ -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>
</>
) : (
<>