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

Forgot password page #211

Merged
merged 12 commits into from
Nov 19, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
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
Loading