Merge pull request #25 from MealCraft/feature/magic_link_user_login
Feature/magic link user login
This commit is contained in:
commit
000e531615
15 changed files with 403 additions and 28 deletions
|
|
@ -1,6 +1,8 @@
|
||||||
FROM library/python:3.12-bullseye
|
FROM library/python:3.12-bullseye
|
||||||
# Personal access token 'mist _runner'
|
# Personal access token 'mist _runner'
|
||||||
ENV GITHUB_PAT=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
|
ENV GITHUB_PAT=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
|
||||||
|
ENV TERRAFORM_VERSION=1.6.6
|
||||||
|
|
||||||
|
|
||||||
ARG USER=vscode
|
ARG USER=vscode
|
||||||
ARG DEBIAN_FRONTEND=noninteractive
|
ARG DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
@ -37,15 +39,12 @@ RUN ./aws/install
|
||||||
|
|
||||||
# Install terraform
|
# Install terraform
|
||||||
RUN apt-get update && sudo apt-get install -y gnupg software-properties-common
|
RUN apt-get update && sudo apt-get install -y gnupg software-properties-common
|
||||||
RUN wget -O- https://apt.releases.hashicorp.com/gpg | \
|
RUN wget https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip \
|
||||||
gpg --dearmor | \
|
&& unzip terraform_${TERRAFORM_VERSION}_linux_amd64.zip \
|
||||||
sudo tee /usr/share/keyrings/hashicorp-archive-keyring.gpg > /dev/null
|
&& mv terraform /usr/local/bin/terraform \
|
||||||
RUN echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] \
|
&& rm terraform_${TERRAFORM_VERSION}_linux_amd64.zip
|
||||||
https://apt.releases.hashicorp.com $(lsb_release -cs) main" | \
|
|
||||||
tee /etc/apt/sources.list.d/hashicorp.list
|
RUN terraform version
|
||||||
RUN apt update
|
|
||||||
RUN apt-get install terraform
|
|
||||||
RUN terraform -install-autocomplete
|
|
||||||
|
|
||||||
|
|
||||||
# Set the working directory
|
# Set the working directory
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,8 @@
|
||||||
"4ops.terraform",
|
"4ops.terraform",
|
||||||
"fabiospampinato.vscode-todo-plus",
|
"fabiospampinato.vscode-todo-plus",
|
||||||
"jgclark.vscode-todo-highlight",
|
"jgclark.vscode-todo-highlight",
|
||||||
"corentinartaud.pdfpreview"
|
"corentinartaud.pdfpreview",
|
||||||
|
"github.vscode-github-actions"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
67
.github/workflows/ses-juntekim.yml
vendored
Normal file
67
.github/workflows/ses-juntekim.yml
vendored
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
name: SES - juntekim.com [Simple Email Service]
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- "aws_environment/ses-juntekim/**"
|
||||||
|
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
TF_VERSION: "1.6.6"
|
||||||
|
WORKING_DIR: "aws_environment/ses-juntekim"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
terraform:
|
||||||
|
name: Terraform SES
|
||||||
|
runs-on: mealcraft-runners
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install modern Node.js
|
||||||
|
run: |
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||||
|
sudo apt-get install -y nodejs
|
||||||
|
node --version
|
||||||
|
|
||||||
|
- name: Setup Terraform
|
||||||
|
uses: hashicorp/setup-terraform@v3
|
||||||
|
with:
|
||||||
|
terraform_version: ${{ env.TF_VERSION }}
|
||||||
|
|
||||||
|
- name: Install AWS CLI
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y awscli
|
||||||
|
|
||||||
|
# Optional but recommended once
|
||||||
|
- name: Verify AWS identity
|
||||||
|
run: aws sts get-caller-identity
|
||||||
|
|
||||||
|
- name: Terraform Init
|
||||||
|
working-directory: ${{ env.WORKING_DIR }}
|
||||||
|
run: terraform init
|
||||||
|
|
||||||
|
- name: Terraform Validate
|
||||||
|
working-directory: ${{ env.WORKING_DIR }}
|
||||||
|
run: terraform validate
|
||||||
|
|
||||||
|
- name: Terraform Plan
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
working-directory: ${{ env.WORKING_DIR }}
|
||||||
|
run: terraform plan -input=false
|
||||||
|
|
||||||
|
- name: Terraform Apply
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
working-directory: ${{ env.WORKING_DIR }}
|
||||||
|
run: terraform apply -auto-approve -input=false
|
||||||
1
aws_environment/ses-juntekim/README.md
Normal file
1
aws_environment/ses-juntekim/README.md
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Files to set up a simple email service in AWS
|
||||||
9
aws_environment/ses-juntekim/backend.tf
Normal file
9
aws_environment/ses-juntekim/backend.tf
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
terraform {
|
||||||
|
backend "s3" {
|
||||||
|
bucket = "juntekim-terraform-state"
|
||||||
|
key = "ses/terraform.tfstate"
|
||||||
|
region = "eu-west-2"
|
||||||
|
dynamodb_table = "terraform-locks"
|
||||||
|
encrypt = true
|
||||||
|
}
|
||||||
|
}
|
||||||
25
aws_environment/ses-juntekim/iam.tf
Normal file
25
aws_environment/ses-juntekim/iam.tf
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
resource "aws_iam_user" "ses_smtp" {
|
||||||
|
name = "ses-smtp-${replace(var.email_domain, ".", "-")}"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_user_policy" "ses_policy" {
|
||||||
|
user = aws_iam_user.ses_smtp.name
|
||||||
|
|
||||||
|
policy = jsonencode({
|
||||||
|
Version = "2012-10-17"
|
||||||
|
Statement = [
|
||||||
|
{
|
||||||
|
Effect = "Allow"
|
||||||
|
Action = [
|
||||||
|
"ses:SendEmail",
|
||||||
|
"ses:SendRawEmail"
|
||||||
|
]
|
||||||
|
Resource = "*"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_access_key" "ses_smtp" {
|
||||||
|
user = aws_iam_user.ses_smtp.name
|
||||||
|
}
|
||||||
30
aws_environment/ses-juntekim/outputs.tf
Normal file
30
aws_environment/ses-juntekim/outputs.tf
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
output "domain_verification_record" {
|
||||||
|
value = {
|
||||||
|
name = "_amazonses.${var.email_domain}"
|
||||||
|
type = "TXT"
|
||||||
|
value = aws_ses_domain_identity.this.verification_token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output "dkim_records" {
|
||||||
|
value = [
|
||||||
|
for token in aws_ses_domain_dkim.this.dkim_tokens : {
|
||||||
|
name = "${token}._domainkey.${var.email_domain}"
|
||||||
|
type = "CNAME"
|
||||||
|
value = "${token}.dkim.amazonses.com"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
output "mail_from_domain" {
|
||||||
|
value = aws_ses_domain_mail_from.this.mail_from_domain
|
||||||
|
}
|
||||||
|
|
||||||
|
output "smtp_username" {
|
||||||
|
value = aws_iam_access_key.ses_smtp.id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "smtp_secret" {
|
||||||
|
value = aws_iam_access_key.ses_smtp.secret
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
14
aws_environment/ses-juntekim/provider.tf
Normal file
14
aws_environment/ses-juntekim/provider.tf
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
terraform {
|
||||||
|
required_version = ">= 1.5"
|
||||||
|
|
||||||
|
required_providers {
|
||||||
|
aws = {
|
||||||
|
source = "hashicorp/aws"
|
||||||
|
version = "~> 5.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
provider "aws" {
|
||||||
|
region = var.aws_region
|
||||||
|
}
|
||||||
12
aws_environment/ses-juntekim/ses.tf
Normal file
12
aws_environment/ses-juntekim/ses.tf
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
resource "aws_ses_domain_identity" "this" {
|
||||||
|
domain = var.email_domain
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_ses_domain_dkim" "this" {
|
||||||
|
domain = aws_ses_domain_identity.this.domain
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_ses_domain_mail_from" "this" {
|
||||||
|
domain = aws_ses_domain_identity.this.domain
|
||||||
|
mail_from_domain = "${var.mail_from_subdomain}.${var.email_domain}"
|
||||||
|
}
|
||||||
1
aws_environment/ses-juntekim/terraform.tfvars
Normal file
1
aws_environment/ses-juntekim/terraform.tfvars
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
email_domain = "juntekim.com"
|
||||||
14
aws_environment/ses-juntekim/variables.tf
Normal file
14
aws_environment/ses-juntekim/variables.tf
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
variable "aws_region" {
|
||||||
|
type = string
|
||||||
|
default = "eu-west-2"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "email_domain" {
|
||||||
|
type = string
|
||||||
|
description = "Domain used for SES email sending"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "mail_from_subdomain" {
|
||||||
|
type = string
|
||||||
|
default = "mail"
|
||||||
|
}
|
||||||
1
db/atlas/stripe_invoice/add_new_migration.sh
Normal file
1
db/atlas/stripe_invoice/add_new_migration.sh
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
atlas migrate new add_used_at_to_login_tokens
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
ALTER TABLE login_tokens
|
||||||
|
ADD COLUMN IF NOT EXISTS used_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_login_tokens_unused
|
||||||
|
ON login_tokens (id)
|
||||||
|
WHERE used_at IS NULL;
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
h1:ELwFHTBDb63mdRBhmjXMMSpy05pUSVxH03zuUuHYAto=
|
h1:dTHZRXvfJ8E0dSqq2PAuMLfFFRSDvt3OzgJKEGeXz2g=
|
||||||
0001_init.sql h1:gzb02ZbjrrJkXOC+2qIZsngnj7A+29O2/b4awScPlPs=
|
0001_init.sql h1:gzb02ZbjrrJkXOC+2qIZsngnj7A+29O2/b4awScPlPs=
|
||||||
0002_auth.sql h1:4NhBu26dIBMy9gxMxM3tf6Z2CS2kfKlGjFBj07T/aBw=
|
0002_auth.sql h1:4NhBu26dIBMy9gxMxM3tf6Z2CS2kfKlGjFBj07T/aBw=
|
||||||
0003_stripe_xero.sql h1:E2bcdUDnondsXwbdIwVlZqR4DQwzcoDiyeRFJwVxXwg=
|
0003_stripe_xero.sql h1:E2bcdUDnondsXwbdIwVlZqR4DQwzcoDiyeRFJwVxXwg=
|
||||||
0004_login_tokens.sql h1:rj1KcWu/0znh2YvtI7JV8Z2nwtL5rZzONbPwX1P+/PI=
|
0004_login_tokens.sql h1:rj1KcWu/0znh2YvtI7JV8Z2nwtL5rZzONbPwX1P+/PI=
|
||||||
|
20251228182659_add_used_at_to_login_tokens.sql h1:/0puYQvwBFzpfSKjiZj2XR/7Mui39lS/IbFZW1TPQOc=
|
||||||
|
|
|
||||||
|
|
@ -1,57 +1,251 @@
|
||||||
// app/page.tsx
|
// app/page.tsx
|
||||||
|
// This page doubles as:
|
||||||
|
// 1. A landing page
|
||||||
|
// 2. A product spec
|
||||||
|
// 3. A reminder to future-me what the hell I was building
|
||||||
|
//
|
||||||
|
// If you’re reading this months later: hi 👋
|
||||||
|
// The product is the automation, not the UI.
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<main className="max-w-3xl mx-auto p-8 space-y-12">
|
<main className="max-w-3xl mx-auto p-8 space-y-16">
|
||||||
|
|
||||||
{/* What this is */}
|
{/* --------------------------------------------------
|
||||||
|
Intro
|
||||||
|
-------------------------------------------------- */}
|
||||||
<section>
|
<section>
|
||||||
<h1 className="text-2xl font-semibold">
|
<h1 className="text-2xl font-semibold">
|
||||||
Stripe → Xero automation
|
Stripe → Xero automation
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="mt-2 text-gray-600">
|
<p className="mt-2 text-gray-600">
|
||||||
Automatically create and mark Xero invoices as paid when a Stripe payment succeeds.
|
Automatically create and mark Xero invoices as paid when a Stripe payment succeeds.
|
||||||
|
<br />
|
||||||
Built for people who value time more than pressing buttons.
|
Built for people who value time more than pressing buttons.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Steps */}
|
{/* --------------------------------------------------
|
||||||
|
High-level flow (human readable)
|
||||||
|
-------------------------------------------------- */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-xl font-medium">How it works</h2>
|
<h2 className="text-xl font-medium">How it works (high level)</h2>
|
||||||
|
|
||||||
<ol className="mt-4 space-y-3 list-decimal list-inside text-gray-700">
|
<ol className="mt-4 space-y-3 list-decimal list-inside text-gray-700">
|
||||||
<li>Log in ( Set up magic link, db has been set up)</li>
|
<li>Log in via magic link (passwordless)</li>
|
||||||
<li>Connect Stripe</li>
|
<li>Connect your Stripe account</li>
|
||||||
<li>Connect Xero</li>
|
<li>Connect your Xero organisation</li>
|
||||||
<li>Make a payment</li>
|
<li>A Stripe payment succeeds</li>
|
||||||
<li>Invoice appears in Xero as paid</li>
|
<li>An invoice appears in Xero as paid</li>
|
||||||
</ol>
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Proof */}
|
{/* --------------------------------------------------
|
||||||
|
Magic link auth – detailed flow
|
||||||
|
-------------------------------------------------- */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-xl font-medium">Proof, not promises</h2>
|
<h2 className="text-xl font-medium">Login flow (magic link)</h2>
|
||||||
|
|
||||||
<p className="mt-2 text-gray-600">
|
<p className="mt-2 text-gray-600">
|
||||||
Your next Stripe payment will automatically reconcile in Xero.
|
Authentication is passwordless. We only store intent and proof of login.
|
||||||
No manual matching. No “awaiting payment”.
|
</p>
|
||||||
|
|
||||||
|
{/* Text-based flow diagram (easy to read + copy) */}
|
||||||
|
<pre className="mt-4 p-4 bg-gray-50 border rounded text-sm overflow-x-auto">
|
||||||
|
{`Browser
|
||||||
|
|
|
||||||
|
| POST /auth/login (email)
|
||||||
|
v
|
||||||
|
Backend
|
||||||
|
- find or create user
|
||||||
|
- generate token
|
||||||
|
- hash token
|
||||||
|
- store login_tokens row
|
||||||
|
- send email (SES)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Email (magic link)
|
||||||
|
|
|
||||||
|
| GET /auth/callback?token=XYZ
|
||||||
|
v
|
||||||
|
Backend
|
||||||
|
- hash token
|
||||||
|
- validate token (unused + not expired)
|
||||||
|
- mark token as used
|
||||||
|
- create session
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Set session cookie
|
||||||
|
`}
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
{/* Step-by-step breakdown */}
|
||||||
|
<ol className="mt-6 space-y-4 list-decimal list-inside text-gray-700">
|
||||||
|
<li>
|
||||||
|
User enters their email address.
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
Backend creates (or finds) a user record and stores a one-time login token
|
||||||
|
in <code className="px-1 bg-gray-100 rounded">login_tokens</code>.
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
An email is sent containing a short-lived magic link.
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
When the link is clicked, the token is validated, marked as used,
|
||||||
|
and a session is created.
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
A secure session cookie is set. No passwords. No OAuth popups.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* --------------------------------------------------
|
||||||
|
Stripe → Xero automation flow
|
||||||
|
-------------------------------------------------- */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-medium">Stripe → Xero automation flow</h2>
|
||||||
|
|
||||||
|
<pre className="mt-4 p-4 bg-gray-50 border rounded text-sm overflow-x-auto">
|
||||||
|
{`Stripe payment succeeds
|
||||||
|
|
|
||||||
|
| Webhook
|
||||||
|
v
|
||||||
|
Backend
|
||||||
|
- verify Stripe event
|
||||||
|
- map payment to customer
|
||||||
|
- create Xero invoice
|
||||||
|
- mark invoice as paid
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Xero (reconciled automatically)
|
||||||
|
`}
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<p className="mt-4 text-gray-600">
|
||||||
|
Once connected, everything runs automatically.
|
||||||
|
No manual reconciliation. No “awaiting payment” state.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Pricing */}
|
{/* --------------------------------------------------
|
||||||
|
Proof
|
||||||
|
-------------------------------------------------- */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-medium">Proof, not promises</h2>
|
||||||
|
|
||||||
|
<p className="mt-2 text-gray-600">
|
||||||
|
Your next Stripe payment will automatically reconcile in Xero.
|
||||||
|
<br />
|
||||||
|
No manual matching. No bookkeeping busywork.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* --------------------------------------------------
|
||||||
|
Pricing
|
||||||
|
-------------------------------------------------- */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-xl font-medium">Pricing</h2>
|
<h2 className="text-xl font-medium">Pricing</h2>
|
||||||
|
|
||||||
<p className="mt-2 text-gray-700">
|
<p className="mt-2 text-gray-700">
|
||||||
£200 / month — unlimited invoices.
|
£200 / month — unlimited invoices.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* CTA */}
|
{/* --------------------------------------------------
|
||||||
|
Footer / reminder
|
||||||
|
-------------------------------------------------- */}
|
||||||
<section className="pt-8 border-t">
|
<section className="pt-8 border-t">
|
||||||
<p className="text-gray-500 text-sm">
|
<p className="text-gray-500 text-sm">
|
||||||
This page is a placeholder. The product is the automation.
|
This page is intentionally simple.
|
||||||
|
<br />
|
||||||
|
The product is the automation, not the UI.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-medium">Implementation notes (for future me)</h2>
|
||||||
|
|
||||||
|
<p className="mt-2 text-gray-600">
|
||||||
|
These are the only docs needed to implement magic-link auth with Next.js + AWS SES.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul className="mt-4 space-y-2 list-disc list-inside text-gray-700">
|
||||||
|
<li>
|
||||||
|
Next.js Route Handlers (auth endpoints):{" "}
|
||||||
|
<a
|
||||||
|
href="https://nextjs.org/docs/app/building-your-application/routing/route-handlers"
|
||||||
|
className="text-blue-600 underline"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
nextjs.org/docs/app/.../route-handlers
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
Next.js Server Actions (optional):{" "}
|
||||||
|
<a
|
||||||
|
href="https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions"
|
||||||
|
className="text-blue-600 underline"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
nextjs.org/docs/app/.../server-actions
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
AWS SES – sending email (Node.js):{" "}
|
||||||
|
<a
|
||||||
|
href="https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/sesv2/"
|
||||||
|
className="text-blue-600 underline"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
AWS SDK SESv2
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
AWS SES sandbox → production access:{" "}
|
||||||
|
<a
|
||||||
|
href="https://docs.aws.amazon.com/ses/latest/dg/request-production-access.html"
|
||||||
|
className="text-blue-600 underline"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Request production access
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
Node.js crypto (token generation + hashing):{" "}
|
||||||
|
<a
|
||||||
|
href="https://nodejs.org/api/crypto.html"
|
||||||
|
className="text-blue-600 underline"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
nodejs.org/api/crypto
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
Cookies & sessions:{" "}
|
||||||
|
<a
|
||||||
|
href="https://nextjs.org/docs/app/api-reference/functions/cookies"
|
||||||
|
className="text-blue-600 underline"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Next.js cookies API
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue