feature: obfuscate email addresses for improved security
This commit is contained in:
parent
f175afaf19
commit
da56be94a3
7 changed files with 145 additions and 15 deletions
|
|
@ -1,6 +1,8 @@
|
||||||
import styles from "./styles.module.scss"
|
import styles from "./styles.module.scss"
|
||||||
import { ContactPerson } from '@/payload-types'
|
import { ContactPerson } from '@/payload-types'
|
||||||
import Image, { StaticImageData } from 'next/image'
|
import Image, { StaticImageData } from 'next/image'
|
||||||
|
import { SecureEmail } from '@/components/SecureEmail/SecureEmail'
|
||||||
|
import { extractEmailPartsEncoded } from '@/utils/dto/email'
|
||||||
|
|
||||||
type ContactPerson2Props = {
|
type ContactPerson2Props = {
|
||||||
contact: null | string | undefined | ContactPerson
|
contact: null | string | undefined | ContactPerson
|
||||||
|
|
@ -12,6 +14,8 @@ export const ContactPerson2 = ({contact, photo}: ContactPerson2Props) => {
|
||||||
return "Unbekannt"
|
return "Unbekannt"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { user, domain } = contact.email ? extractEmailPartsEncoded(contact.email) : { user: '', domain: '' }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.contact}>
|
<div className={styles.contact}>
|
||||||
{ photo &&
|
{ photo &&
|
||||||
|
|
@ -24,9 +28,9 @@ export const ContactPerson2 = ({contact, photo}: ContactPerson2Props) => {
|
||||||
}
|
}
|
||||||
<div>
|
<div>
|
||||||
{contact.name}
|
{contact.name}
|
||||||
{contact.email &&
|
{user && domain &&
|
||||||
<>
|
<>
|
||||||
<br/> <a href={`mailto:${contact.email}`} className={styles.hover}>{contact.email}</a>
|
<br/> ✉️ <SecureEmail user={user} domain={domain} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hover:hover {
|
.contact a:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import styles from './styles.module.scss'
|
import styles from './styles.module.scss'
|
||||||
import { ContactPerson } from '@/payload-types'
|
import { ContactPerson } from '@/payload-types'
|
||||||
import Image, { StaticImageData } from 'next/image'
|
import Image, { StaticImageData } from 'next/image'
|
||||||
|
import { SecureEmail } from '@/components/SecureEmail/SecureEmail'
|
||||||
|
import { extractEmailPartsEncoded } from '@/utils/dto/email'
|
||||||
|
|
||||||
type ContactPersonCardProps = {
|
type ContactPersonCardProps = {
|
||||||
contact: null | string | undefined | ContactPerson
|
contact: null | string | undefined | ContactPerson
|
||||||
|
|
@ -19,6 +21,13 @@ export const ContactPersonCard = ({
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let user, domain = null;
|
||||||
|
if (contact.email) {
|
||||||
|
let data = extractEmailPartsEncoded(contact.email)
|
||||||
|
user = data.user;
|
||||||
|
domain = data.domain;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.card}>
|
<div className={styles.card}>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -38,19 +47,20 @@ export const ContactPersonCard = ({
|
||||||
{contact.role && (
|
{contact.role && (
|
||||||
<h3 className={styles.role}>{contact.role}</h3>
|
<h3 className={styles.role}>{contact.role}</h3>
|
||||||
)}
|
)}
|
||||||
<div className={styles.info}>
|
<div className={styles.name}>{contact.name}</div>
|
||||||
<span className={styles.name}>{contact.name}</span>
|
<div className={styles.phoneEmail}>
|
||||||
{contact.email && (
|
{user && domain && (
|
||||||
<a href={`mailto:${contact.email}`} className={styles.link}>
|
<span>✉️ <SecureEmail user={user} domain={domain} /></span>
|
||||||
{contact.email}
|
|
||||||
</a>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{contact.telephone && (
|
{contact.telephone && (
|
||||||
<a href={`tel:${contact.telephone}`} className={styles.link}>
|
<span>
|
||||||
|
📞 <a href={`tel:${contact.telephone}`}>
|
||||||
{contact.telephone}
|
{contact.telephone}
|
||||||
</a>
|
</a>
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -20,21 +20,37 @@
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info {
|
.phoneEmail {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
gap: 15px;
|
||||||
gap: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.name {
|
.name {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link {
|
.phoneEmail a {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 576px) {
|
||||||
|
.role {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phoneEmail {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
23
src/components/SecureEmail/SecureEmail.stories.tsx
Normal file
23
src/components/SecureEmail/SecureEmail.stories.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { SecureEmail } from './SecureEmail'
|
||||||
|
|
||||||
|
const meta: Meta<typeof SecureEmail> = {
|
||||||
|
component: SecureEmail,
|
||||||
|
}
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof SecureEmail>;
|
||||||
|
export default meta
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
user: btoa('pastoor'),
|
||||||
|
domain: btoa('parochie.nl'),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithLabel: Story = {
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
label: 'Send email'
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/components/SecureEmail/SecureEmail.tsx
Normal file
59
src/components/SecureEmail/SecureEmail.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
'use client';
|
||||||
|
import React, { useRef } from 'react';
|
||||||
|
|
||||||
|
interface SecureEmailProps {
|
||||||
|
/**
|
||||||
|
* base64-encoded email, e.g. btoa('pastoor')
|
||||||
|
*/
|
||||||
|
user: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* base64-encoded domain, e.g. btoa('parochie.nl')
|
||||||
|
*/
|
||||||
|
domain: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a secure email link that requires user interaction to reveal the actual "mailto:" address,
|
||||||
|
* preventing the address from being easily scraped by bots.
|
||||||
|
*
|
||||||
|
* @param {Object} props - The properties for the SecureEmail component.
|
||||||
|
* @param {string} props.user - The Base64-encoded user portion of the email address.
|
||||||
|
* @param {string} props.domain - The Base64-encoded domain portion of the email address.
|
||||||
|
* @param {string} [props.label='E-Mail schreiben'] - The label text displayed for the email link.
|
||||||
|
* @return {JSX.Element} A React component that renders an email link.
|
||||||
|
*/
|
||||||
|
export function SecureEmail({ user, domain, label = 'E-Mail schreiben' }: SecureEmailProps) {
|
||||||
|
const ref = useRef<HTMLAnchorElement>(null);
|
||||||
|
|
||||||
|
// Decode and write the real mailto: into the href, imperatively (not via state),
|
||||||
|
// so it's in place synchronously before the browser navigates.
|
||||||
|
const reveal = () => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el || el.href.startsWith('mailto:')) return;
|
||||||
|
el.href = `mailto:${atob(user)}@${atob(domain)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fallback: if a click somehow lands before reveal fired (some touch flows),
|
||||||
|
// assemble the address then and navigate manually.
|
||||||
|
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||||
|
if (!ref.current?.href.startsWith('mailto:')) {
|
||||||
|
e.preventDefault();
|
||||||
|
reveal();
|
||||||
|
window.location.href = ref.current!.href;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
ref={ref}
|
||||||
|
href="#"
|
||||||
|
onMouseEnter={reveal}
|
||||||
|
onFocus={reveal}
|
||||||
|
onTouchStart={reveal}
|
||||||
|
onClick={handleClick}>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
src/utils/dto/email.ts
Normal file
18
src/utils/dto/email.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
/**
|
||||||
|
* Extracts and encodes the user and domain parts of an email address.
|
||||||
|
*
|
||||||
|
* This function splits the given email string into two parts: the user (local-part) and the domain.
|
||||||
|
* Each part is then Base64 encoded and returned as an object.
|
||||||
|
*
|
||||||
|
* Used for obfuscating email addresses to prevent direct exposure.
|
||||||
|
*
|
||||||
|
* @param {string} email - The email address to be processed.
|
||||||
|
* @returns {{ user: string, domain: string }} An object containing the Base64 encoded user and domain parts of the email.
|
||||||
|
*/
|
||||||
|
export const extractEmailPartsEncoded = (email: string): { user: string, domain: string } => {
|
||||||
|
const [user, domain] = email.split('@');
|
||||||
|
return {
|
||||||
|
user: btoa(user),
|
||||||
|
domain: btoa(domain)
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue