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 { ContactPerson } from '@/payload-types'
|
||||
import Image, { StaticImageData } from 'next/image'
|
||||
import { SecureEmail } from '@/components/SecureEmail/SecureEmail'
|
||||
import { extractEmailPartsEncoded } from '@/utils/dto/email'
|
||||
|
||||
type ContactPerson2Props = {
|
||||
contact: null | string | undefined | ContactPerson
|
||||
|
|
@ -12,6 +14,8 @@ export const ContactPerson2 = ({contact, photo}: ContactPerson2Props) => {
|
|||
return "Unbekannt"
|
||||
}
|
||||
|
||||
const { user, domain } = contact.email ? extractEmailPartsEncoded(contact.email) : { user: '', domain: '' }
|
||||
|
||||
return (
|
||||
<div className={styles.contact}>
|
||||
{ photo &&
|
||||
|
|
@ -24,9 +28,9 @@ export const ContactPerson2 = ({contact, photo}: ContactPerson2Props) => {
|
|||
}
|
||||
<div>
|
||||
{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;
|
||||
}
|
||||
|
||||
.hover:hover {
|
||||
.contact a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import styles from './styles.module.scss'
|
||||
import { ContactPerson } from '@/payload-types'
|
||||
import Image, { StaticImageData } from 'next/image'
|
||||
import { SecureEmail } from '@/components/SecureEmail/SecureEmail'
|
||||
import { extractEmailPartsEncoded } from '@/utils/dto/email'
|
||||
|
||||
type ContactPersonCardProps = {
|
||||
contact: null | string | undefined | ContactPerson
|
||||
|
|
@ -19,6 +21,13 @@ export const ContactPersonCard = ({
|
|||
return null
|
||||
}
|
||||
|
||||
let user, domain = null;
|
||||
if (contact.email) {
|
||||
let data = extractEmailPartsEncoded(contact.email)
|
||||
user = data.user;
|
||||
domain = data.domain;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div>
|
||||
|
|
@ -38,19 +47,20 @@ export const ContactPersonCard = ({
|
|||
{contact.role && (
|
||||
<h3 className={styles.role}>{contact.role}</h3>
|
||||
)}
|
||||
<div className={styles.info}>
|
||||
<span className={styles.name}>{contact.name}</span>
|
||||
{contact.email && (
|
||||
<a href={`mailto:${contact.email}`} className={styles.link}>
|
||||
{contact.email}
|
||||
</a>
|
||||
<div className={styles.name}>{contact.name}</div>
|
||||
<div className={styles.phoneEmail}>
|
||||
{user && domain && (
|
||||
<span>✉️ <SecureEmail user={user} domain={domain} /></span>
|
||||
)}
|
||||
|
||||
{contact.telephone && (
|
||||
<a href={`tel:${contact.telephone}`} className={styles.link}>
|
||||
<span>
|
||||
📞 <a href={`tel:${contact.telephone}`}>
|
||||
{contact.telephone}
|
||||
</a>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -20,17 +20,16 @@
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
.info {
|
||||
.phoneEmail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.link {
|
||||
.phoneEmail a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
|
||||
|
|
@ -38,3 +37,20 @@
|
|||
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