Skip to content

Commit

Permalink
Adds basic magic link demo (#683)
Browse files Browse the repository at this point in the history
  • Loading branch information
zomars committed Jun 14, 2024
1 parent 2e7a553 commit 5406d64
Show file tree
Hide file tree
Showing 6 changed files with 255 additions and 1 deletion.
78 changes: 78 additions & 0 deletions examples/next/src/app/app-router-magic-links/form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"use client";

import * as css from "@/app/css";

import { useEffect, useState } from "react";
import { SessionData, defaultSession } from "./lib";

export function Form() {
const [session, setSession] = useState<SessionData>(defaultSession);
const [isLoading, setIsLoading] = useState(true);

useEffect(() => {
fetch("/app-router-magic-links/session")
.then((res) => res.json())
.then((session) => {
setSession(session);
setIsLoading(false);
});
}, []);

if (isLoading) {
return <p className="text-lg">Loading...</p>;
}

if (session.isLoggedIn) {
return (
<>
<p className="text-lg">
Logged in user: <strong>{session.username}</strong>
</p>
<LogoutButton />
</>
);
}

return <LoginForm />;
}

function LoginForm() {
return (
<form
action="/app-router-magic-links/session"
method="POST"
className={css.form}
>
<label className="block text-lg">
<span className={css.label}>Username</span>
<input
type="text"
name="username"
className={css.input}
placeholder=""
defaultValue="Alison"
required
// for demo purposes, disabling autocomplete 1password here
autoComplete="off"
data-1p-ignore
/>
</label>
<div>
<input type="submit" value="Login" className={css.button} />
</div>
</form>
);
}

function LogoutButton() {
return (
<p>
<a
href="/app-router-magic-links/session?action=logout"
className={css.button}
>
Logout
</a>
</p>
);
}
23 changes: 23 additions & 0 deletions examples/next/src/app/app-router-magic-links/lib.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { SessionOptions } from "iron-session";

export interface SessionData {
username: string;
isLoggedIn: boolean;
}

export const defaultSession: SessionData = {
username: "",
isLoggedIn: false,
};

export const sessionOptions: SessionOptions = {
password: "complex_password_at_least_32_characters_long",
cookieName: "iron-examples-app-router-magic-links",
cookieOptions: {
secure: process.env.NODE_ENV === "production",
},
};

export function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
22 changes: 22 additions & 0 deletions examples/next/src/app/app-router-magic-links/magic-login/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { getIronSession, unsealData } from "iron-session";
import { cookies } from "next/headers";
import { NextRequest } from "next/server";
import { SessionData, sessionOptions } from "../lib";

export async function GET(request: NextRequest) {
const seal = new URL(request.url).searchParams.get("seal") as string;
const { username } = await unsealData<{ username: string }>(seal, {
password: sessionOptions.password,
});
const session = await getIronSession<SessionData>(cookies(), sessionOptions);
session.isLoggedIn = true;
session.username = username ?? "No username";
await session.save();

// https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/303
// not using redirect() yet: https://github.com/vercel/next.js/issues/51592#issuecomment-1810212676
return Response.redirect(
`${request.nextUrl.origin}/app-router-magic-links`,
303,
);
}
75 changes: 75 additions & 0 deletions examples/next/src/app/app-router-magic-links/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import * as css from "@/app/css";
import Link from "next/link";

import { Metadata } from "next";
import { GetTheCode } from "../../get-the-code";
import { Title } from "../title";
import { Form } from "./form";

export const metadata: Metadata = {
title: "🛠 iron-session examples: Magic links",
};

export default function AppRouterRedirect() {
return (
<main className="p-10 space-y-5">
<Title subtitle="+ client components, route handlers, redirects, and fetch" />

<p className="italic max-w-xl">
<u>How to test</u>: Login and refresh the page to see iron-session in
action.
</p>

<div className="grid grid-cols-1 gap-4 p-10 border border-slate-500 rounded-md max-w-xl">
<Form />
</div>

<GetTheCode path="app/app-router-magic-links" />
<HowItWorks />

<p>
<Link href="/" className={css.link}>
← All examples
</Link>
</p>
</main>
);
}

function HowItWorks() {
return (
<details className="max-w-2xl space-y-4">
<summary className="cursor-pointer">How it works</summary>

<ol className="list-decimal list-inside">
<li>
The form is submitted to /app-router-magic-links/session (API route)
via a POST call (non-fetch). The API route generates a sealed token
and returns the magic link to client so it can be either sent or used
right away. When the magic link is visited it sets the session data
and redirects back to /app-router-magic-links (this page)
</li>
<li>
The page gets the session data via a fetch call to
/app-router-magic-links/session (API route). The API route either
return the session data (logged in) or a default session (not logged
in).
</li>
<li>
The logout is a regular link navigating to
/app-router-magic-links/logout which destroy the session and redirects
back to /app-router-magic-links (this page).
</li>
</ol>

<p>
<strong>Pros</strong>: Simple.
</p>
<p>
<strong>Cons</strong>: Dangerous if not used properly. Without any
invalidations or blacklists, the magic link can be used multiple times
if compromised.
</p>
</details>
);
}
49 changes: 49 additions & 0 deletions examples/next/src/app/app-router-magic-links/session/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { getIronSession, sealData } from "iron-session";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { NextRequest } from "next/server";
import { SessionData, defaultSession, sessionOptions, sleep } from "../lib";

// /app-router-magic-links/session
export async function POST(request: NextRequest) {
const formData = await request.formData();
const username = formData.get("username") as string;
const fifteenMinutesInSeconds = 15 * 60;

const seal = await sealData(
{ username },
{
password: "complex_password_at_least_32_characters_long",
ttl: fifteenMinutesInSeconds,
},
);

return Response.json({
ok: true,
// Ideally this would be an email or text message with a link to the magic link route
magic_link: `${process.env.PUBLIC_URL}/app-router-magic-links/magic-login?seal=${seal}`,
});
}

// /app-router-magic-links/session
// /app-router-magic-links/session?action=logout
export async function GET(request: NextRequest) {
const session = await getIronSession<SessionData>(cookies(), sessionOptions);

console.log(new URL(request.url).searchParams);
const action = new URL(request.url).searchParams.get("action");
// /app-router-magic-links/session?action=logout
if (action === "logout") {
session.destroy();
return redirect("/app-router-magic-links");
}

// simulate looking up the user in db
await sleep(250);

if (session.isLoggedIn !== true) {
return Response.json(defaultSession);
}

return Response.json(session);
}
9 changes: 8 additions & 1 deletion examples/next/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,14 @@ export default function Home() {
Pages Router + API routes, redirects, and fetch
</Link>
</li>
<li className="text-slate-500">Magic links (Help needed)</li>
<li>
<Link
href="/app-router-magic-links"
className={`${css.link} text-lg`}
>
Magic links
</Link>
</li>
<li className="text-slate-500">
OAuth login example (SWR) (Help needed)
</li>
Expand Down

0 comments on commit 5406d64

Please sign in to comment.