diff --git a/web/src/beta/features/AccountAndWorkSpaceSetting/hooks.ts b/web/src/beta/features/AccountAndWorkSpaceSetting/hooks.ts new file mode 100644 index 0000000000..e3b3298f96 --- /dev/null +++ b/web/src/beta/features/AccountAndWorkSpaceSetting/hooks.ts @@ -0,0 +1,34 @@ +import { useMeFetcher } from "@reearth/services/api"; +import { useCallback } from "react"; + +export type UpdatePasswordType = { + password: string; + passwordConfirmation: string; +}; + +export default () => { + const { useMeQuery, useUpdatePassword, useDeleteUser } = useMeFetcher(); + + const passwordPolicy = window.REEARTH_CONFIG?.passwordPolicy; + + const { me: data } = useMeQuery(); + + const handleUpdateUserPassword = useCallback( + async ({ password, passwordConfirmation }: UpdatePasswordType) => { + await useUpdatePassword({ password, passwordConfirmation }); + }, + [useUpdatePassword] + ); + + const handleDeleteUser = useCallback(async () => { + const userId = data.id; + if (userId) await useDeleteUser({ userId }); + }, [data.id, useDeleteUser]); + + return { + meData: data, + passwordPolicy, + handleUpdateUserPassword, + handleDeleteUser + }; +}; diff --git a/web/src/beta/features/AccountAndWorkSpaceSetting/index.tsx b/web/src/beta/features/AccountAndWorkSpaceSetting/index.tsx new file mode 100644 index 0000000000..e94259bbba --- /dev/null +++ b/web/src/beta/features/AccountAndWorkSpaceSetting/index.tsx @@ -0,0 +1,138 @@ +import { + DEFAULT_SIDEBAR_WIDTH, + SidebarMenuItem, + SidebarSection, + SidebarVersion, + SidebarWrapper +} from "@reearth/beta/ui/components/Sidebar"; +import { useT } from "@reearth/services/i18n"; +import { styled } from "@reearth/services/theme"; +import { FC, useMemo } from "react"; + +import Navbar from "../Navbar"; + +import useHook from "./hooks"; +import AccountSetting from "./innerPages/AccountSetting"; + +type Props = { + sceneId?: string; + projectId?: string; + workspaceId?: string; + tab: string; +}; + +export const accountSettingTabs = [ + { id: "account", text: "Account", icon: "user" }, + { id: "workspace", text: "Workspace", icon: "users" }, + { id: "members", text: "Members", icon: "usersFour" } +] as const; + +const AccountAndWorkSpaceSetting: FC = ({ tab }) => { + const t = useT(); + const tabs = useMemo( + () => + accountSettingTabs.map((tab) => ({ + id: tab.id, + icon: tab.icon, + text: t(tab.text), + path: `/settings/${tab.id}` + })), + [t] + ); + const { meData, passwordPolicy, handleUpdateUserPassword } = useHook(); + console.log(meData); + const { name, email } = meData; + + return ( + + + + + + + {tabs?.map((t) => ( + + ))} + + + + + + {tab === "account" && ( + + )} + + + + ); +}; + +const Wrapper = styled("div")(({ theme }) => ({ + display: "flex", + flexDirection: "column", + height: "100%", + width: "100%", + color: theme.content.main, + backgroundColor: theme.bg[0], + ["*"]: { + boxSizing: "border-box" + }, + ["* ::-webkit-scrollbar"]: { + width: "8px" + }, + ["* ::-webkit-scrollbar-track"]: { + background: theme.relative.darker, + borderRadius: "10px" + }, + ["* ::-webkit-scrollbar-thumb"]: { + background: theme.relative.light, + borderRadius: "4px" + }, + ["* ::-webkit-scrollbar-thumb:hover"]: { + background: theme.relative.lighter + } +})); + +const MainSection = styled("div")(() => ({ + display: "flex", + flex: 1, + overflow: "auto", + position: "relative" +})); + +const LeftSidePanel = styled("div")(({ theme }) => ({ + width: DEFAULT_SIDEBAR_WIDTH, + height: "100%", + backgroundColor: theme.bg[1], + display: "flex", + padding: `${theme.spacing.large}px 0`, + boxSizing: "border-box" +})); + +const Content = styled("div")(({ theme }) => ({ + position: "relative", + display: "flex", + flexDirection: "column", + width: "100%", + height: "100%", + alignItems: "center", + overflow: "auto", + padding: `${theme.spacing.super}px` +})); + +export default AccountAndWorkSpaceSetting; diff --git a/web/src/beta/features/AccountAndWorkSpaceSetting/innerPages/AccountSetting/PasswordModal/index.tsx b/web/src/beta/features/AccountAndWorkSpaceSetting/innerPages/AccountSetting/PasswordModal/index.tsx new file mode 100644 index 0000000000..14f3802b60 --- /dev/null +++ b/web/src/beta/features/AccountAndWorkSpaceSetting/innerPages/AccountSetting/PasswordModal/index.tsx @@ -0,0 +1,211 @@ +import { Flex } from "@aws-amplify/ui-react"; +import { + Modal, + Button, + ModalPanel, + Typography, + TextInput, + Icon +} from "@reearth/beta/lib/reearth-ui"; +import { metricsSizes } from "@reearth/beta/utils/metrics"; +import { useT } from "@reearth/services/i18n"; +import { styled } from "@reearth/services/theme"; +import React, { useState, useCallback, useEffect } from "react"; + +export type PasswordPolicy = { + tooShort?: RegExp; + tooLong?: RegExp; + whitespace?: RegExp; + lowSecurity?: RegExp; + medSecurity?: RegExp; + highSecurity?: RegExp; +}; + +type Props = { + className?: string; + project?: { + id: string; + name: string; + isArchived: boolean; + }; + workspace?: { + id: string; + name: string; + }; + isVisible: boolean; + archiveProject?: (archived: boolean) => void; + onClose?: () => void; + passwordPolicy?: PasswordPolicy; + updatePassword?: ({ + password, + passwordConfirmation + }: { + password: string; + passwordConfirmation: string; + }) => void; +}; + +const PasswordModal: React.FC = ({ + isVisible, + onClose, + passwordPolicy, + updatePassword +}) => { + const t = useT(); + + const [password, setPassword] = useState(""); + const [regexMessage, setRegexMessage] = useState(); + const [passwordConfirmation, setPasswordConfirmation] = useState(); + const [disabled, setDisabled] = useState(true); + + const handlePasswordChange = useCallback( + (password: string | undefined) => { + setPassword(password ?? ""); + switch (true) { + case passwordPolicy?.whitespace?.test(password ?? ""): + setRegexMessage(t("No whitespace is allowed.")); + break; + case passwordPolicy?.tooShort?.test(password ?? ""): + setRegexMessage(t("Too short.")); + break; + case passwordPolicy?.tooLong?.test(password ?? ""): + setRegexMessage(t("That is terribly long.")); + break; + case passwordPolicy?.highSecurity?.test(password ?? ""): + setRegexMessage(t("That password is great!")); + break; + case passwordPolicy?.medSecurity?.test(password ?? ""): + setRegexMessage(t("That password is better.")); + break; + case passwordPolicy?.lowSecurity?.test(password ?? ""): + setRegexMessage(t("That password is okay.")); + break; + default: + setRegexMessage(t("That password confuses me, but might be okay.")); + break; + } + }, + [t, password] // eslint-disable-line react-hooks/exhaustive-deps + ); + + const handleClose = useCallback(() => { + setPassword(""); + setPasswordConfirmation(""); + onClose?.(); + }, [onClose]); + + const handleSave = useCallback(() => { + if (password === passwordConfirmation) { + updatePassword?.({ password, passwordConfirmation }); + handleClose(); + } + }, [updatePassword, handleClose, password, passwordConfirmation]); + + useEffect(() => { + if ( + password !== passwordConfirmation || + (passwordPolicy?.highSecurity && + !passwordPolicy.highSecurity.test(password)) || + passwordPolicy?.tooShort?.test(password) || + passwordPolicy?.tooLong?.test(password) + ) { + setDisabled(true); + } else { + setDisabled(false); + } + }, [password, passwordConfirmation, passwordPolicy]); + + const isMatchPassword = + password !== passwordConfirmation && passwordConfirmation; + + return ( + + + ]} + > + +
+ + + {t( + `In order to protect your account, make sure your password is unique and strong.` + )} + + + + + {t("New password")} + + + {password ? ( + + {regexMessage} + + ) : undefined} + + + + {t("New password (for confirmation)")} + + + {isMatchPassword ? ( + + + + + "repeatPassword" Passwords need to match. + + ) : undefined} + +
+
+
+
+ ); +}; + +const ModalContentWrapper = styled("div")(({ theme }) => ({ + display: "flex", + flexDirection: "column", + gap: theme.spacing.large, + padding: theme.spacing.large, + background: theme.bg[1] +})); + +const SubText = styled.div` + margin: ${({ theme }) => `${theme.spacing.large}px auto`}; +`; + +const PasswordField = styled(Flex)` + height: 50px; + transition: all 0.2s; + &:has(p ~ p) { + height: 68px; + } +`; + +const PasswordMessage = styled(Typography)` + margin-top: ${metricsSizes["s"]}px; + font-style: italic; + display: flex; +`; + +export default PasswordModal; diff --git a/web/src/beta/features/AccountAndWorkSpaceSetting/innerPages/AccountSetting/index.tsx b/web/src/beta/features/AccountAndWorkSpaceSetting/innerPages/AccountSetting/index.tsx new file mode 100644 index 0000000000..d0c4092378 --- /dev/null +++ b/web/src/beta/features/AccountAndWorkSpaceSetting/innerPages/AccountSetting/index.tsx @@ -0,0 +1,101 @@ +import { + Collapse, + TextInput, + Typography, + IconButton +} from "@reearth/beta/lib/reearth-ui"; +import { InputField } from "@reearth/beta/ui/fields"; +import { PasswordPolicy } from "@reearth/services/config/passwordPolicy"; +import { useT } from "@reearth/services/i18n"; +import { styled } from "@reearth/services/theme"; +import { FC, useState } from "react"; + +import { UpdatePasswordType } from "../../hooks"; +import { InnerPage, SettingsWrapper, SettingsFields } from "../common"; + +import PasswordModal from "./PasswordModal"; + +type Props = { + imformationData: { name?: string; email?: string }; + passwordPolicy?: PasswordPolicy; + onUpdateUserPassword: ({ + password, + passwordConfirmation + }: UpdatePasswordType) => Promise; +}; + +const AccountSetting: FC = ({ + passwordPolicy, + onUpdateUserPassword, + imformationData +}) => { + const t = useT(); + const [onPasswordModalClose, setPasswordModalOnClose] = + useState(true); + + return ( + + + + + + + + + {t("Password")} + + + { + setPasswordModalOnClose(!onPasswordModalClose); + }} + size="medium" + hasBorder={true} + /> + + + + + + + setPasswordModalOnClose(!onPasswordModalClose)} + updatePassword={onUpdateUserPassword} + /> + + ); +}; +export default AccountSetting; + +const PasswordWrapper = styled("div")(() => ({ + display: "flex", + flexDirection: "column", + width: "100%" +})); + +const PasswordInputWrapper = styled("div")(({ theme }) => ({ + display: "flex", + gap: theme.spacing.smallest, + alignItems: "center" +})); diff --git a/web/src/beta/features/AccountAndWorkSpaceSetting/innerPages/common.tsx b/web/src/beta/features/AccountAndWorkSpaceSetting/innerPages/common.tsx new file mode 100644 index 0000000000..c08ab62220 --- /dev/null +++ b/web/src/beta/features/AccountAndWorkSpaceSetting/innerPages/common.tsx @@ -0,0 +1,79 @@ +import { Collapse, Typography } from "@reearth/beta/lib/reearth-ui"; +import { useT } from "@reearth/services/i18n"; +import { styled } from "@reearth/services/theme"; + +export const InnerPage = styled("div")<{ + wide?: boolean; + transparent?: boolean; +}>(({ wide, transparent, theme }) => ({ + boxSizing: "border-box", + display: "flex", + width: "100%", + maxWidth: wide ? 950 : 750, + backgroundColor: transparent ? "none" : theme.bg[1], + borderRadius: theme.radius.normal +})); + +export const InnerSidebar = styled("div")(({ theme }) => ({ + display: "flex", + flexDirection: "column", + width: 213, + borderRight: `1px solid ${theme.outline.weaker}`, + padding: `${theme.spacing.normal}px 0` +})); + +export const SettingsWrapper = styled("div")(({ theme }) => ({ + display: "flex", + flexDirection: "column", + width: "100%", + flex: 1, + ["> div:not(:last-child)"]: { + borderBottom: `1px solid ${theme.outline.weaker}` + } +})); + +export const SettingsFields = styled("div")(({ theme }) => ({ + display: "flex", + flexDirection: "column", + gap: theme.spacing.largest +})); + +export const SettingsRow = styled("div")(({ theme }) => ({ + display: "flex", + justifyContent: "space-between", + flexDirection: "row", + gap: theme.spacing.largest +})); + +export const SettingsRowItem = styled("div")(() => ({ + width: "100%" +})); + +export const Thumbnail = styled("div")<{ src?: string }>(({ src, theme }) => ({ + width: "100%", + paddingBottom: "52.3%", + fontSize: 0, + background: src + ? `url(${src}) center/contain no-repeat` + : theme.relative.dark, + borderRadius: theme.radius.small +})); + +export const ButtonWrapper = styled("div")(({ theme }) => ({ + display: "flex", + justifyContent: "flex-end", + gap: theme.spacing.small +})); + +export const ArchivedSettingNotice: React.FC = () => { + const t = useT(); + return ( + + + {t( + "Most project settings are hidden when the project is archived. Please unarchive the project to view and edit these settings." + )} + + + ); +}; diff --git a/web/src/beta/features/Dashboard/LeftSidePanel/profile.tsx b/web/src/beta/features/Dashboard/LeftSidePanel/profile.tsx index d79e87c963..680b710899 100644 --- a/web/src/beta/features/Dashboard/LeftSidePanel/profile.tsx +++ b/web/src/beta/features/Dashboard/LeftSidePanel/profile.tsx @@ -8,6 +8,7 @@ import { useT } from "@reearth/services/i18n"; import { styled, useTheme } from "@reearth/services/theme"; import { ProjectType } from "@reearth/types"; import { FC } from "react"; +import { useNavigate } from "react-router-dom"; import { Workspace } from "../type"; @@ -37,6 +38,7 @@ export const Profile: FC = ({ }) => { const t = useT(); const theme = useTheme(); + const navigate = useNavigate(); const popupMenu: PopupMenuItem[] = [ { @@ -56,6 +58,12 @@ export const Profile: FC = ({ }; }) }, + { + id: "accountSettings", + title: t("Account Settings"), + icon: "user", + onClick: () => navigate("/settings/account") + }, { id: "signOut", title: t("Log Out"), diff --git a/web/src/beta/features/Navbar/LeftSection/index.tsx b/web/src/beta/features/Navbar/LeftSection/index.tsx index 5b8339501b..fddfd35223 100644 --- a/web/src/beta/features/Navbar/LeftSection/index.tsx +++ b/web/src/beta/features/Navbar/LeftSection/index.tsx @@ -1,10 +1,11 @@ import { IconButton, PopupMenu, - PopupMenuItem + PopupMenuItem, + Typography } from "@reearth/beta/lib/reearth-ui"; import { useT } from "@reearth/services/i18n"; -import { styled } from "@reearth/services/theme"; +import { styled, useTheme } from "@reearth/services/theme"; import { useMemo } from "react"; import { Link } from "react-router-dom"; @@ -32,6 +33,7 @@ const LeftSection: React.FC = ({ onWorkspaceChange }) => { const t = useT(); + const theme = useTheme(); const menuItems: PopupMenuItem[] = useMemo( () => [ @@ -55,6 +57,13 @@ const LeftSection: React.FC = ({ return ( + {page !== "editor" && ( + + + {t("Visualizer")} + + + )} void; @@ -24,7 +25,8 @@ export const IconButton: FC = ({ className, iconRotate, stopPropagationOnClick, - onClick + onClick, + hasBorder }) => { const handleClick = useCallback( (e: MouseEvent) => { @@ -44,6 +46,7 @@ export const IconButton: FC = ({ active={active} iconRotate={iconRotate} onClick={handleClick} + hasBorder={hasBorder} > @@ -51,32 +54,38 @@ export const IconButton: FC = ({ }; const StyledButton = styled("button")<{ - size: "normal" | "small" | "smallest" | "large"; + size: "normal" | "small" | "smallest" | "medium" | "large"; appearance: "primary" | "secondary" | "dangerous" | "simple"; active?: boolean; iconRotate?: string; -}>(({ appearance, size, active, iconRotate, theme }) => ({ + hasBorder?: boolean; +}>(({ appearance, size, active, iconRotate, theme, hasBorder }) => ({ display: "flex", flexDirection: "row", alignItems: "center", justifyContent: "center", + border: hasBorder ? `1px solid ${theme.outline.weak}` : "none", flexShrink: 0, width: size === "smallest" ? "16px" : size === "small" ? "20px" - : size === "large" - ? "36px" - : "24px", + : size === "medium" + ? "28px" + : size === "large" + ? "36px" + : "24px", height: size === "smallest" ? "16px" : size === "small" ? "20px" - : size === "large" - ? "36px" - : "24px", + : size === "medium" + ? "28px" + : size === "large" + ? "36px" + : "24px", borderRadius: size === "small" ? `${theme.radius.small}px` : `${theme.radius.normal}px`, color: active diff --git a/web/src/beta/lib/reearth-ui/components/TextInput/index.tsx b/web/src/beta/lib/reearth-ui/components/TextInput/index.tsx index 383ea58c46..63bc299659 100644 --- a/web/src/beta/lib/reearth-ui/components/TextInput/index.tsx +++ b/web/src/beta/lib/reearth-ui/components/TextInput/index.tsx @@ -23,6 +23,7 @@ export type TextInputProps = { onChange?: (text: string) => void; onBlur?: (text: string) => void; onKeyDown?: (e: KeyboardEvent) => void; + type?: string; }; export const TextInput: FC = ({ @@ -38,7 +39,8 @@ export const TextInput: FC = ({ autoFocus, onChange, onBlur, - onKeyDown + onKeyDown, + type }) => { const [currentValue, setCurrentValue] = useState(value ?? ""); const [isFocused, setIsFocused] = useState(false); @@ -90,6 +92,7 @@ export const TextInput: FC = ({ appearance={appearance} autoFocus={autoFocus} onKeyDown={onKeyDown} + type={type} /> {actions && {actions}} diff --git a/web/src/beta/pages/AccountSettingsPage/index.tsx b/web/src/beta/pages/AccountSettingsPage/index.tsx index 330e41b85a..800aa666dc 100644 --- a/web/src/beta/pages/AccountSettingsPage/index.tsx +++ b/web/src/beta/pages/AccountSettingsPage/index.tsx @@ -1,12 +1,14 @@ -import { Typography } from "@reearth/beta/lib/reearth-ui"; +import AccountAndWorkSpaceSetting from "@reearth/beta/features/AccountAndWorkSpaceSetting"; import { FC } from "react"; -export type Props = { - path?: string; -}; +import Page from "../Page"; -const AccountPage: FC = () => ( - Account page +const AccountSettingPage: FC = () => ( + ( + + )} + /> ); -export default AccountPage; +export default AccountSettingPage; diff --git a/web/src/services/api/meApi.ts b/web/src/services/api/meApi.ts index 0a84a07b94..e1ecaf6b2d 100644 --- a/web/src/services/api/meApi.ts +++ b/web/src/services/api/meApi.ts @@ -1,8 +1,18 @@ -import { useQuery } from "@apollo/client"; -import { GET_ME } from "@reearth/services/gql/queries/user"; +import { useMutation, useQuery } from "@apollo/client"; +import { + GET_ME, + DELETE_ME, + UPDATE_ME +} from "@reearth/services/gql/queries/user"; import { useCallback } from "react"; +import { useT } from "../i18n"; +import { useNotification } from "../state"; + export default () => { + const t = useT(); + const [, setNotification] = useNotification(); + const useMeQuery = useCallback((options?: { skip?: boolean }) => { const { data, ...rest } = useQuery(GET_ME, { ...options }); return { @@ -11,7 +21,65 @@ export default () => { }; }, []); + const [updateMeMutation] = useMutation(UPDATE_ME); + const useUpdatePassword = useCallback( + async ({ + password, + passwordConfirmation + }: { + password: string; + passwordConfirmation: string; + }) => { + const { data, errors } = await updateMeMutation({ + variables: { + password, + passwordConfirmation + } + }); + + if (errors || !data?.updateMe) { + console.log("GraphQL: Failed to update password", errors); + setNotification({ + type: "error", + text: t("Failed to update user password.") + }); + return { status: "error" }; + } + setNotification({ + type: "success", + text: t("Successfully updated user password!") + }); + return { data: data?.updateMe, status: "success" }; + }, + [setNotification, t, updateMeMutation] + ); + + const [deleteMeMutation] = useMutation(DELETE_ME); + const useDeleteUser = useCallback( + async ({ userId }: { userId: string }) => { + const { data, errors } = await deleteMeMutation({ + variables: { userId } + }); + if (errors || !data?.deleteMe) { + console.log("GraphQL: Failed to delete users", errors); + setNotification({ + type: "error", + text: t("Failed to delete user.") + }); + return { status: "error" }; + } + setNotification({ + type: "success", + text: t("Successfully delete user!") + }); + return { data: data.deleteMe, status: "success" }; + }, + [deleteMeMutation, setNotification, t] + ); + return { - useMeQuery + useMeQuery, + useUpdatePassword, + useDeleteUser }; }; diff --git a/web/src/services/routing/index.tsx b/web/src/services/routing/index.tsx index 3061903a31..4f2661bf05 100644 --- a/web/src/services/routing/index.tsx +++ b/web/src/services/routing/index.tsx @@ -1,3 +1,4 @@ +import AccountSettingPage from "@reearth/beta/pages/AccountSettingsPage"; import RootPage from "@reearth/beta/pages/RootPage"; import { styled } from "@reearth/services/theme"; import { lazy } from "react"; @@ -34,6 +35,10 @@ export const AppRoutes = () => { path: "settings/project/:projectId/:tab?/:subId?", element: }, + { + path: "settings/account", + element: + }, { path: "graphql", element: