Skip to content

Commit

Permalink
Forgot password page (#211)
Browse files Browse the repository at this point in the history
* add forgot password page

* styling fixes

* fix truthy error

* Update main.go

* Fix SMTP values

* Update secret-smtp.yaml

* Fix SMTP stuff

* Fix SMTP stuff

* Fix SMTP stuff

* Add missing semicolon eslint rule

* pr fixes

* limit max-width on form title

---------

Co-authored-by: Gokhan Sari <gokhan@sari.me>
  • Loading branch information
iibarbari and th0th authored Nov 19, 2024
1 parent bdb0fc7 commit 8f48177
Show file tree
Hide file tree
Showing 18 changed files with 212 additions and 29 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/deploy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,11 @@ jobs:
--set clickhouse.password="${{ secrets.CLICKHOUSE_PASSWORD }}" \
--set clickhouse.user="${{ secrets.CLICKHOUSE_USER }}" \
--set ghcrAuth="${{ secrets.GHCR_AUTH }}" \
--set poeticmetric.smtp.password="${{ secrets.SMTP_PASSWORD }}" \
--set poeticmetric.smtp.user="${{ secrets.SMTP_USER }}" \
--set postgres.password="${{ secrets.POSTGRES_PASSWORD }}" \
--set postgres.user="${{ secrets.POSTGRES_USER }}" \
--set redis.password="${{ secrets.REDIS_PASSWORD }}" \
--set smtp.password="${{ secrets.SMTP_PASSWORD }}" \
--set smtp.user="${{ secrets.SMTP_USER }}" \
--values etc/${{ inputs.environment }}/values.yaml
- name: Rollout restart workloads
Expand Down
13 changes: 7 additions & 6 deletions backend/pkg/poeticmetric/env_service.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package poeticmetric

import (
"net/mail"
"net/smtp"

"gorm.io/gorm"
Expand All @@ -20,7 +21,7 @@ type EnvService interface {
RestApiBasePath() string
SmtpAddr() string
SmtpAuth() smtp.Auth
SmtpFrom() string
SmtpFrom() *mail.Address
}

type EnvServiceVars struct {
Expand Down Expand Up @@ -49,9 +50,9 @@ type EnvServiceVars struct {
RedisPort int `env:"REDIS_PORT,notEmpty,required"`

// SMTP
SmtpFrom string `env:"SMTP_FROM,notEmpty,required"`
SmtpHost string `env:"SMTP_HOST,notEmpty,required"`
SmtpPassword string `env:"SMTP_PASSWORD"`
SmtpPort string `env:"SMTP_PORT,notEmpty,required"`
SmtpUser string `env:"SMTP_USER"`
SmtpFromAddress string `env:"SMTP_FROM_ADDRESS,notEmpty,required"`
SmtpHost string `env:"SMTP_HOST,notEmpty,required"`
SmtpPassword string `env:"SMTP_PASSWORD"`
SmtpPort string `env:"SMTP_PORT,notEmpty,required"`
SmtpUser string `env:"SMTP_USER"`
}
4 changes: 2 additions & 2 deletions backend/pkg/service/email/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ func (s *service) Send(params poeticmetric.EmailServiceSendParams) error {
smtpMessageTemplateBuffer := bytes.Buffer{}
err = smtpMessageTemplate.Execute(&smtpMessageTemplateBuffer, smtpMessageParams{
Body: templateBuffer.String(),
From: s.envService.SmtpFrom(),
From: s.envService.SmtpFrom().String(),
Subject: params.Subject,
To: params.To.String(),
})
Expand All @@ -87,7 +87,7 @@ func (s *service) Send(params poeticmetric.EmailServiceSendParams) error {
err = smtp.SendMail(
s.envService.SmtpAddr(),
s.envService.SmtpAuth(),
s.envService.SmtpFrom(),
s.envService.SmtpFrom().Address,
[]string{params.To.Address},
smtpMessageTemplateBuffer.Bytes(),
)
Expand Down
8 changes: 6 additions & 2 deletions backend/pkg/service/env/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package env

import (
"fmt"
"net/mail"
"net/smtp"
"os"

Expand Down Expand Up @@ -117,8 +118,11 @@ func (s *service) SmtpAuth() smtp.Auth {
return smtp.PlainAuth("", s.vars.SmtpUser, s.vars.SmtpPassword, s.vars.SmtpHost)
}

func (s *service) SmtpFrom() string {
return fmt.Sprintf("PoeticMetric <%s>", s.vars.SmtpFrom)
func (s *service) SmtpFrom() *mail.Address {
return &mail.Address{
Name: "PoeticMetric",
Address: s.vars.SmtpFromAddress,
}
}

var Logger = zerolog.New(os.Stdout).With().Timestamp().Logger()
2 changes: 1 addition & 1 deletion chart/templates/configmap-smtp.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ metadata:
{{ include "poeticmetric.labels" . | nindent 4 }}
name: {{ include "poeticmetric.fullname" . }}-smtp
data:
FROM: {{ .Values.poeticmetric.smtp.from | quote }}
FROM_ADDRESS: {{ .Values.poeticmetric.smtp.fromAddress | quote }}
HOST: {{ .Values.poeticmetric.smtp.host | quote }}
PORT: {{ .Values.poeticmetric.smtp.port | quote }}
4 changes: 2 additions & 2 deletions chart/templates/secret-smtp.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ metadata:
{{ include "poeticmetric.labels" . | nindent 4 }}
name: {{ include "poeticmetric.fullname" . }}-smtp
data:
PASSWORD: {{ .Values.poeticmetric.smtp.password }}
USER: {{ .Values.poeticmetric.smtp.user }}
PASSWORD: {{ .Values.poeticmetric.smtp.password | b64enc }}
USER: {{ .Values.poeticmetric.smtp.user | b64enc }}
2 changes: 1 addition & 1 deletion chart/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ poeticmetric:
replicas: 1
resources: {}
smtp:
from: PoeticMetric <poeticmetric@poeticmetric.com>
fromAddress: poeticmetric@poeticmetric.com
host: ""
password: ""
port: 587
Expand Down
2 changes: 1 addition & 1 deletion etc/development/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ poeticmetric:
annotations:
cert-manager.io/cluster-issuer: self-signed
smtp:
from: poeticmetric@dev.poeticmetric.com
fromAddress: poeticmetric@dev.poeticmetric.com
host: poeticmetric-mailpit
port: "1025"
postgres:
Expand Down
2 changes: 1 addition & 1 deletion etc/staging/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ poeticmetric:
cert-manager.io/cluster-issuer: letsencrypt-prod-poeticmetric
resources: {}
smtp:
from: PoeticMetric <poeticmetric@staging.poeticmetric.com>
fromAddress: poeticmetric@staging.poeticmetric.com
host: email-smtp.eu-west-1.amazonaws.com
port: 587
redis:
Expand Down
1 change: 1 addition & 0 deletions frontend/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export default [
},
rules: {
"@stylistic/ts/member-delimiter-style": ["error"],
"@stylistic/ts/semi": ["error"],
},
},

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/ActivityOverlay/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import styles from "./ActivityOverlay.module.css";

export type ActivityOverlayProps = Overwrite<PropsWithoutRef<JSX.IntrinsicElements["div"]>, {
isActive: boolean;
}>
}>;

export default function ActivityOverlay({ children, className, isActive, ...props }: ActivityOverlayProps) {
return (
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/App/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Bootstrap from "~/components/Bootstrap";
import Error from "~/components/Error";
import Home from "~/components/Home";
import Manifesto from "~/components/Manifesto";
import PasswordRecovery from "~/components/PasswordRecovery";
import SignIn from "~/components/SignIn";

export default function App() {
Expand All @@ -14,6 +15,7 @@ export default function App() {
<Route component={Bootstrap} path="/bootstrap" />
<Route component={Home} path="/" />
<Route component={Manifesto} path="/manifesto" />
<Route component={PasswordRecovery} path="/forgot-password" />
<Route component={SignIn} path="/sign-in" />

<Route>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import clsx from "clsx";
import { PropsWithoutRef, ReactNode, JSX, CSSProperties } from "react";
import { PropsWithoutRef, ReactNode, JSX } from "react";
import styles from "./FormTitle.module.css";
import { IconChevronLeft } from "@tabler/icons-react";

export type AuthenticationHeaderProps = Overwrite<Omit<PropsWithoutRef<JSX.IntrinsicElements["div"]>, "children">, {
export type FormTitleProps = Overwrite<Omit<PropsWithoutRef<JSX.IntrinsicElements["div"]>, "children">, {
actions?: ReactNode;
description: ReactNode;
maxWidth?: CSSProperties["maxWidth"];
maxWidth?: "fit-content" | "28rem";
showGoBack?: boolean;
summary: ReactNode;
title: ReactNode;
}>
}>;

export default function FormTitle(
{
Expand All @@ -22,7 +22,7 @@ export default function FormTitle(
summary,
title,
...props
}: AuthenticationHeaderProps) {
}: FormTitleProps) {
return (
<div
{...props}
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/Markdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import styles from "./Markdown.module.css";

export type MarkdownProps = {
content: string;
}
};

export default function Markdown({ content }: MarkdownProps) {
return (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
@import "~/styles/media.css";

.card {
margin-inline: auto;
margin-top: 2rem;
max-width: 28rem;
}

.sign-in-link {
font-size: 0.875rem;
text-align: center;
}

@media (--min-md) and (orientation: landscape) {
.layout {
display: grid;
flex-grow: 1;
grid-template-rows: repeat(3, 1fr);

> :global(.container) {
align-self: center;
grid-row: 2 span / 3;
}
}
}
147 changes: 147 additions & 0 deletions frontend/src/components/PasswordRecovery/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { IconX } from "@tabler/icons-react";
import clsx from "clsx";
import { useEffect, useState } from "react";
import { useErrorBoundary } from "react-error-boundary";
import { useForm } from "react-hook-form";
import { Link, useSearch } from "wouter";
import ActivityOverlay from "~/components/ActivityOverlay";
import FormTitle from "~/components/FormTitle";
import Layout from "~/components/Layout";
import Title from "~/components/Title";
import useUser from "~/hooks/useUser";
import { api } from "~/lib/api";
import { setErrors } from "~/lib/form";
import styles from "./PasswordRecovery.module.css";

type Form = {
userEmail: string;
};

type State = {
isAlreadySignedIn: boolean;
isEmailSent: boolean;
};

export default function PasswordRecovery() {
const { showBoundary } = useErrorBoundary();
const searchParams = useSearch();
const [state, setState] = useState<State>({ isAlreadySignedIn: false, isEmailSent: false });
const user = useUser();
const { clearErrors, formState: { errors, isSubmitting }, handleSubmit, register, setError } = useForm<Form>({
defaultValues: {
userEmail: new URLSearchParams(searchParams).get("email") || "",
},
});

useEffect(() => {
if (user) {
setState((prev) => ({ ...prev, isAlreadySignedIn: true }));
}
}, [user]);

async function submit(data: Form) {
try {
const response = await api.post("/authentication/send-user-password-recovery-email", {
email: data.userEmail,
});
const responseJson = await response.json();

if (response.ok) {
setState((prev) => ({ ...prev, isEmailSent: true }));
} else {
setErrors(setError, responseJson);
}
} catch (error) {
showBoundary(error);
}
}

return (
<>
<Title>Forgot password?</Title>

<Layout className={styles.layout}>
{state.isAlreadySignedIn ? (
<div className="container">
<FormTitle
actions={(
<Link className="button button-lg button-blue" to="/settings">
Go to settings
</Link>
)}
description="You can reset your password from user settings."
summary="Password recovery"
title="You are already in!"
/>
</div>
) : state.isEmailSent ? (
<div className="container">
<FormTitle
actions={(
<Link className="button button-lg button-blue" to="/sign-in">
Return to sign in
</Link>
)}
description="If the e-mail address exists in our database, you will receive a reset link. Check your inbox and follow the instructions."
maxWidth="28rem"
showGoBack={false}
summary="Password recovery"
title="Check your inbox"
/>
</div>
) : (
<div className="container">
<FormTitle
description="Enter your email address and we will send you a link to reset your password."
maxWidth="28rem"
showGoBack={false}
summary="Password recovery"
title="Forgot password?"
/>

<div className={clsx("card", styles.card)}>
<ActivityOverlay isActive={isSubmitting}>
<form className="card-body" onSubmit={handleSubmit(submit)}>
<fieldset className="fieldset" disabled={isSubmitting}>
{errors.root ? (
<div className="alert alert-danger">
<IconX className="icon" size={24} />

{errors.root.message}
</div>
) : null}

<div className="form-group">
<label className="form-label" htmlFor="input-user-email">E-mail address</label>

<input
className={clsx("input", errors.userEmail || errors.root && "input-invalid")}
id="input-user-email"
required
type="email"
{...register("userEmail", { onChange: () => clearErrors() })}
/>

{!!errors.userEmail ? (
<div className="form-error">{errors.userEmail.message}</div>
) : null}
</div>

<button className="button button-blue" type="submit">Continue</button>
</fieldset>
</form>

<div className="card-footer">
<p className={styles.signInLink}>
{"Remember your password? "}
<Link className="link link-animate" to="/sign-in">Sign in</Link>
</p>
</div>
</ActivityOverlay>
</div>
</div>
)}
</Layout>
</>
);
}
Loading

0 comments on commit 8f48177

Please sign in to comment.