Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(web): add account page to change password #1155

Merged
merged 24 commits into from
Oct 1, 2024
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions web/src/beta/features/AccountAndWorkSpaceSetting/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
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) => {
try {
await useUpdatePassword({ password, passwordConfirmation });
} catch (error) {
console.error("Failed to update password:", error);
}
},
[useUpdatePassword]
);

const handleDeleteUser = useCallback(async () => {
try {
const userId = data.id;
if (userId) await useDeleteUser({ userId });
} catch (error) {
console.error("Failed to delete user:", error);
}
}, [data.id, useDeleteUser]);

return {
meData: data,
passwordPolicy,
handleUpdateUserPassword,
handleDeleteUser
};
};
132 changes: 132 additions & 0 deletions web/src/beta/features/AccountAndWorkSpaceSetting/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
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<Props> = ({ 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();
const { name, email } = meData;

return (
<Wrapper>
<Navbar workspaceId={meData.myTeam?.id} page="settings" />
airslice marked this conversation as resolved.
Show resolved Hide resolved
<MainSection>
<LeftSidePanel>
<SidebarWrapper>
<SidebarSection>
{tabs?.map((t) => (
<SidebarMenuItem
key={t.id}
path={t.path}
text={t.text}
active={t.id === tab}
icon={t.icon}
/>
))}
</SidebarSection>
<SidebarVersion />
</SidebarWrapper>
</LeftSidePanel>
<Content>
{tab === "account" && (
<AccountSetting
onUpdateUserPassword={handleUpdateUserPassword}
passwordPolicy={passwordPolicy}
informationData={{ name, email }}
/>
)}
</Content>
</MainSection>
</Wrapper>
);
};

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"
mkumbobeaty marked this conversation as resolved.
Show resolved Hide resolved
},
["* ::-webkit-scrollbar-thumb"]: {
background: theme.relative.light,
borderRadius: "4px"
mkumbobeaty marked this conversation as resolved.
Show resolved Hide resolved
},
["* ::-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;
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
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 = {
isVisible: boolean;
onClose?: () => void;
passwordPolicy?: PasswordPolicy;
updatePassword?: ({
airslice marked this conversation as resolved.
Show resolved Hide resolved
password,
passwordConfirmation
}: {
password: string;
passwordConfirmation: string;
}) => void;
};

const PasswordModal: React.FC<Props> = ({
isVisible,
onClose,
passwordPolicy,
updatePassword
}) => {
const t = useT();

const [password, setPassword] = useState("");
const [regexMessage, setRegexMessage] = useState<string | undefined | null>();
const [passwordConfirmation, setPasswordConfirmation] = useState<string>();
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, passwordPolicy] // eslint-disable-line react-hooks/exhaustive-deps
airslice marked this conversation as resolved.
Show resolved Hide resolved
);

const handleClose = useCallback(() => {
setPassword("");
setPasswordConfirmation("");
onClose?.();
}, [onClose]);

const handleSave = useCallback(() => {
if (password === passwordConfirmation) {
updatePassword?.({ password, passwordConfirmation });
handleClose();
}
}, [updatePassword, handleClose, password, passwordConfirmation]);
ZTongci marked this conversation as resolved.
Show resolved Hide resolved

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 (
<Modal size="small" visible={isVisible}>
<ModalPanel
title={t("Change password")}
onCancel={handleClose}
actions={[
<Button
key="Change password"
title={t("Change password")}
appearance="dangerous"
disabled={disabled}
onClick={handleSave}
/>
]}
>
<ModalContentWrapper>
<div>
<SubText>
<Typography size="body" weight="bold">
{t(
`In order to protect your account, make sure your password is unique and strong.`
)}
</Typography>
</SubText>
<PasswordField direction="column">
<Typography size="body" weight="bold">
{t("New password")}
</Typography>
<TextInput
value={password}
onChange={handlePasswordChange}
type="password"
/>
{password ? (
<PasswordMessage size="body" weight="bold">
{regexMessage}
</PasswordMessage>
airslice marked this conversation as resolved.
Show resolved Hide resolved
) : undefined}
</PasswordField>
<PasswordField direction="column">
<Typography size="body" weight="bold">
{t("New password (for confirmation)")}
</Typography>
<TextInput
value={passwordConfirmation}
onChange={setPasswordConfirmation}
type="password"
/>
{isMatchPassword ? (
<PasswordMessage size="body" weight="regular" color="red">
airslice marked this conversation as resolved.
Show resolved Hide resolved
<span>
<Icon icon="warning" size="large" color="red" />
</span>
{t('"repeatPassword" Passwords need to match')}
</PasswordMessage>
) : undefined}
</PasswordField>
</div>
</ModalContentWrapper>
</ModalPanel>
</Modal>
);
};

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`};
airslice marked this conversation as resolved.
Show resolved Hide resolved
`;

const PasswordField = styled(Flex)`
height: 50px;
transition: all 0.2s;
&:has(p ~ p) {
height: 68px;
}
ZTongci marked this conversation as resolved.
Show resolved Hide resolved
`;

const PasswordMessage = styled(Typography)`
margin-top: ${metricsSizes["s"]}px;
font-style: italic;
display: flex;
`;

export default PasswordModal;
Loading
Loading