Skip to content

Commit

Permalink
change email feature
Browse files Browse the repository at this point in the history
  • Loading branch information
chennisden committed Jun 13, 2024
1 parent c847993 commit ab6e412
Show file tree
Hide file tree
Showing 9 changed files with 173 additions and 3 deletions.
2 changes: 2 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import YourRobots from "pages/YourRobots";
import { Container } from "react-bootstrap";
import { BrowserRouter as Router, Route, Routes } from "react-router-dom";
import "./App.css";
import ChangeEmail from "pages/ChangeEmail";

const App = () => {
return (
Expand All @@ -52,6 +53,7 @@ const App = () => {
path="/reset-password/:token"
element={<ResetPassword />}
/>
<Route path="/change-email/:token" element={<ChangeEmail />} />
<Route path="/robots/" element={<Robots />} />
<Route path="/robots/add" element={<NewRobot />} />
<Route path="/parts/add" element={<NewPart />} />
Expand Down
30 changes: 28 additions & 2 deletions frontend/src/components/nav/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { api } from "hooks/api";
import { useAuthentication } from "hooks/auth";
import { useEffect, useState } from "react";
import { Col, Offcanvas, Row } from "react-bootstrap";
import { FormEvent, useEffect, useState } from "react";
import { Button, Col, Form, Offcanvas, Row } from "react-bootstrap";
import { Link } from "react-router-dom";

interface Props {
Expand All @@ -16,6 +16,8 @@ const Sidebar = ({ show, onHide }: Props) => {
const auth = useAuthentication();
const auth_api = new api(auth.api);

const [newEmail, setNewEmail] = useState<string>("");

const sendVerifyEmail = async () => {
try {
await auth_api.send_verify_email();
Expand All @@ -24,6 +26,15 @@ const Sidebar = ({ show, onHide }: Props) => {
}
};

const sendChangeEmail = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
try {
await auth_api.send_change_email(newEmail);
} catch (error) {
console.error(error);
}
};

useEffect(() => {
(async () => {
if (needToCall) {
Expand Down Expand Up @@ -63,6 +74,21 @@ const Sidebar = ({ show, onHide }: Props) => {
</a>
</p>
)}
<Form onSubmit={sendChangeEmail}>
<label htmlFor="new-email">New email</label>
<Form.Control
id="new-email"
autoComplete="email"
className="mb-3"
type="text"
onChange={(e) => {
setNewEmail(e.target.value);
}}
value={newEmail}
required
/>
<Button type="submit">Change Email</Button>
</Form>
</Row>
<Row style={{ marginTop: "auto" }} />
<Row>
Expand Down
33 changes: 33 additions & 0 deletions frontend/src/hooks/api.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,39 @@ export class api {
}
}

public async change_email(code: string): Promise<void> {
try {
await this.api.post("/users/change-email/" + code);
} catch (error) {
if (axios.isAxiosError(error)) {
console.error("Error changing email:", error.response?.data);
throw new Error(
error.response?.data?.detail ||
"Error changing email with code " + code,
);
} else {
console.error("Unexpected error:", error);
throw new Error("Unexpected error");
}
}
}

public async send_change_email(new_email: string): Promise<void> {
try {
await this.api.post("/users/change-email", { new_email });
} catch (error) {
if (axios.isAxiosError(error)) {
console.error("Error sending change email:", error.response?.data);
throw new Error(
error.response?.data?.detail || "Error sending change email",
);
} else {
console.error("Unexpected error:", error);
throw new Error("Unexpected error");
}
}
}

public async login(email: string, password: string): Promise<void> {
try {
await this.api.post("/users/login/", { email, password });
Expand Down
36 changes: 36 additions & 0 deletions frontend/src/pages/ChangeEmail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { api } from "hooks/api";
import { useAuthentication } from "hooks/auth";
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";

const ChangeEmail = () => {
const auth = useAuthentication();
const auth_api = new api(auth.api);
const { token } = useParams();
const [needToSend, setNeedToSend] = useState<boolean>(true);
const [message, setMessage] = useState<string>("");
useEffect(() => {
(async () => {
if (needToSend) {
setNeedToSend(false);
if (token !== undefined) {
try {
await auth_api.change_email(token);
setMessage("Successfully changed email.");
} catch (error) {
setMessage("Verification token invalid.");
}
} else {
setMessage("No token provided");
}
}
})();
}, [auth_api]);
return (
<>
<h1>Email Verification</h1>
<p>{message}</p>
</>
);
};
export default ChangeEmail;
7 changes: 7 additions & 0 deletions store/app/crud/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ async def __aenter__(self) -> Self:
db=settings.redis.reset_password_db,
)

self.change_email_kv = Redis(
host=settings.redis.host,
password=settings.redis.password,
port=settings.redis.port,
db=settings.redis.change_email_db,
)

return self

async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: # noqa: ANN401
Expand Down
20 changes: 20 additions & 0 deletions store/app/crud/users.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Defines CRUD interface for user API."""

import asyncio
import json
import warnings

from boto3.dynamodb.conditions import Key
Expand Down Expand Up @@ -96,6 +97,25 @@ async def use_reset_password_token(self, token: str, new_password: str) -> None:
)
await self.delete_reset_password_token(token)

async def add_change_email_token(self, token: str, user_id: str, new_email: str, lifetime: int) -> None:
await self.change_email_kv.setex(
hash_token(token), lifetime, json.dumps({"user_id": user_id, "new_email": new_email})
)

async def use_change_email_token(self, token: str) -> None:
data = await self.change_email_kv.get(hash_token(token))
if data is None:
raise ValueError("Provided token is invalid")
data = json.loads(data)
await (await self.db.Table("Users")).update_item(
Key={"user_id": data["user_id"]},
AttributeUpdates={
"email": {"Value": data["new_email"], "Action": "PUT"},
"verified": {"Value": True, "Action": "PUT"},
},
)
await self.change_email_kv.delete(hash_token(token))


async def test_adhoc() -> None:
async with UserCrud() as crud:
Expand Down
35 changes: 34 additions & 1 deletion store/app/routers/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from store.app.crypto import check_password, new_token
from store.app.db import Crud
from store.app.model import User
from store.app.utils.email import send_delete_email, send_reset_password_email, send_verify_email
from store.app.utils.email import send_change_email, send_delete_email, send_reset_password_email, send_verify_email
from store.settings import settings

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -154,6 +154,39 @@ async def reset_password_user_endpoint(
return True


class NewEmail(BaseModel):
new_email: str


@users_router.post("/change-email")
async def send_change_email_user_endpoint(
data: NewEmail,
crud: Annotated[Crud, Depends(Crud.get)],
token: Annotated[str, Depends(get_session_token)],
) -> bool:
user_id = await crud.get_user_id_from_session_token(token)
if user_id is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
user = await crud.get_user(user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
change_email_token = new_token()
"""Sends a verification email to the new email address."""
# Magic number: 1 hour
await crud.add_change_email_token(change_email_token, user.user_id, data.new_email, 60 * 60)
await send_change_email(data.new_email, change_email_token)
return True

@users_router.post("/change-email/{token}")
async def change_email_user_endpoint(
token: str,
crud: Annotated[Crud, Depends(Crud.get)],
) -> bool:
"""Changes the user's email address."""
await crud.use_change_email_token(token)
return True


class UserLogin(BaseModel):
email: str
password: str
Expand Down
12 changes: 12 additions & 0 deletions store/app/utils/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,18 @@ async def send_reset_password_email(email: str, token: str) -> None:
await send_email(subject="Reset Password", body=body, to=email)


async def send_change_email(email: str, token: str) -> None:
body = textwrap.dedent(
f"""
<h1><code>K-Scale Labs</code></h1>
<h2><code>change your email</code></h2>
<p>Click <a href="{settings.site.homepage}/change-email/{token}">here</a> to change your email.</p>
"""
)

await send_email(subject="Change Email", body=body, to=email)


async def send_delete_email(email: str) -> None:
body = textwrap.dedent(
"""
Expand Down
1 change: 1 addition & 0 deletions store/settings/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class RedisSettings:
session_db: int = field(default=0)
verify_email_db: int = field(default=1)
reset_password_db: int = field(default=2)
change_email_db: int = field(default=3)


@dataclass
Expand Down

0 comments on commit ab6e412

Please sign in to comment.