Skip to content

Commit 27b6391

Browse files
committed
Login with email
1 parent bebb22e commit 27b6391

File tree

8 files changed

+379
-67
lines changed

8 files changed

+379
-67
lines changed
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"use server";
2+
3+
import crypto from "crypto";
4+
import { eq } from "drizzle-orm";
5+
6+
import { verificationTokens } from "@echo-webkom/db/schemas";
7+
import { db } from "@echo-webkom/db/serverless";
8+
import { MagicLinkEmail } from "@echo-webkom/email";
9+
import { emailClient } from "@echo-webkom/email/client";
10+
11+
import { BASE_URL } from "@/config";
12+
import { isValidEmail } from "@/utils/string";
13+
14+
type MagicLinkResult = { success: true; message: string } | { success: false; error: string };
15+
16+
export async function sendMagicLink(email: string): Promise<MagicLinkResult> {
17+
try {
18+
if (!email || !isValidEmail(email)) {
19+
return {
20+
success: false,
21+
error: "Ugyldig e-postadresse",
22+
};
23+
}
24+
25+
// Check if user exists with this email or alternative email
26+
const existingUser = await db.query.users.findFirst({
27+
where: (user, { eq, or }) => or(eq(user.email, email), eq(user.alternativeEmail, email)),
28+
});
29+
30+
if (!existingUser) {
31+
return {
32+
success: false,
33+
error:
34+
"Ingen bruker funnet med denne e-postadressen. Du må være medlem av echo for å logge inn.",
35+
};
36+
}
37+
38+
// Generate secure token
39+
const token = crypto.randomBytes(32).toString("hex");
40+
const expires = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes from now
41+
42+
// Delete any existing tokens for this email
43+
await db.delete(verificationTokens).where(eq(verificationTokens.identifier, email));
44+
45+
// Store the new token
46+
await db.insert(verificationTokens).values({
47+
identifier: email,
48+
token,
49+
expires,
50+
});
51+
52+
// Generate magic link URL
53+
const magicLinkUrl = `${BASE_URL}/api/auth/magic-link/verify?token=${token}&email=${encodeURIComponent(email)}`;
54+
55+
// Send email
56+
await emailClient.sendEmail(
57+
[email],
58+
"Logg inn på echo",
59+
MagicLinkEmail({
60+
magicLinkUrl,
61+
firstName: existingUser.name?.split(" ")[0] ?? "der",
62+
}),
63+
);
64+
65+
return {
66+
success: true,
67+
message: "Magic link sendt! Sjekk din e-post for å logge inn.",
68+
};
69+
} catch {
70+
return {
71+
success: false,
72+
error: "En feil oppstod. Prøv igjen senere.",
73+
};
74+
}
75+
}

apps/web/src/app/(default)/auth/logg-inn/_components/sign-in-buttons.tsx

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"use client";
22

3+
import { useState } from "react";
34
import Image from "next/image";
45
import Link from "next/link";
56

@@ -8,8 +9,37 @@ import { Feide } from "@/components/icons/feide";
89
import { Heading } from "@/components/typography/heading";
910
import { Text } from "@/components/typography/text";
1011
import { Button } from "@/components/ui/button";
12+
import { Input } from "@/components/ui/input";
13+
import { sendMagicLink } from "../_actions/magic-link";
1114

1215
export const SignInButtons = () => {
16+
const [email, setEmail] = useState("");
17+
const [isLoading, setIsLoading] = useState(false);
18+
const [message, setMessage] = useState<{ text: string; isError: boolean } | null>(null);
19+
20+
const handleMagicLinkSubmit = async (e: React.FormEvent) => {
21+
e.preventDefault();
22+
if (!email) return;
23+
24+
setIsLoading(true);
25+
setMessage(null);
26+
27+
try {
28+
const result = await sendMagicLink(email);
29+
30+
if (result.success) {
31+
setMessage({ text: result.message, isError: false });
32+
setEmail("");
33+
} else {
34+
setMessage({ text: result.error, isError: true });
35+
}
36+
} catch {
37+
setMessage({ text: "En feil oppstod. Prøv igjen senere.", isError: true });
38+
} finally {
39+
setIsLoading(false);
40+
}
41+
};
42+
1343
return (
1444
<div className="border-muted-dark bg-muted mx-auto flex w-full max-w-[380px] flex-col rounded-xl border-2 p-8">
1545
<Image src={EchoLogo} alt="echo logo" width={100} height={100} className="mx-auto" />
@@ -32,6 +62,29 @@ export const SignInButtons = () => {
3262
</li>
3363
</ul>
3464

65+
<div className="mb-4">
66+
<Text size="sm" className="mb-2 font-semibold">
67+
Eller logg inn med e-post
68+
</Text>
69+
<form onSubmit={handleMagicLinkSubmit} className="flex flex-col gap-3">
70+
<Input
71+
type="email"
72+
placeholder="din-epost@example.com"
73+
value={email}
74+
onChange={(e) => setEmail(e.target.value)}
75+
required
76+
/>
77+
<Button type="submit" disabled={!email || isLoading} className="w-full">
78+
{isLoading ? "Sender..." : "Send magic link"}
79+
</Button>
80+
{message && (
81+
<Text size="sm" className={message.isError ? "text-red-600" : "text-green-600"}>
82+
{message.text}
83+
</Text>
84+
)}
85+
</form>
86+
</div>
87+
3588
<Text size="sm" className="text-muted-foreground">
3689
For å kunne logge inn må du være medlem av echo.{" "}
3790
<Link className="underline" href="/om/vedtekter#§-2-medlemmer">
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { cookies } from "next/headers";
2+
import { NextResponse, type NextRequest } from "next/server";
3+
import { addDays } from "date-fns";
4+
import { and, eq } from "drizzle-orm";
5+
import { nanoid } from "nanoid";
6+
7+
import { sessions, users, verificationTokens } from "@echo-webkom/db/schemas";
8+
import { db } from "@echo-webkom/db/serverless";
9+
10+
import { createSessionCookie, SESSION_COOKIE_NAME } from "@/auth/session";
11+
12+
export async function GET(request: NextRequest) {
13+
try {
14+
const searchParams = request.nextUrl.searchParams;
15+
const token = searchParams.get("token");
16+
const email = searchParams.get("email");
17+
18+
if (!token || !email) {
19+
return NextResponse.redirect(new URL("/auth/logg-inn?error=invalid-token", request.url));
20+
}
21+
22+
// Find and verify the token
23+
const verificationToken = await db.query.verificationTokens.findFirst({
24+
where: and(eq(verificationTokens.identifier, email), eq(verificationTokens.token, token)),
25+
});
26+
27+
if (!verificationToken) {
28+
return NextResponse.redirect(new URL("/auth/logg-inn?error=invalid-token", request.url));
29+
}
30+
31+
// Check if token has expired
32+
if (verificationToken.expires < new Date()) {
33+
// Clean up expired token
34+
await db
35+
.delete(verificationTokens)
36+
.where(and(eq(verificationTokens.identifier, email), eq(verificationTokens.token, token)));
37+
38+
return NextResponse.redirect(new URL("/auth/logg-inn?error=expired-token", request.url));
39+
}
40+
41+
// Find the user
42+
const user = await db.query.users.findFirst({
43+
where: (user, { eq, or }) => or(eq(user.email, email), eq(user.alternativeEmail, email)),
44+
});
45+
46+
if (!user) {
47+
return NextResponse.redirect(new URL("/auth/logg-inn?error=user-not-found", request.url));
48+
}
49+
50+
// Clean up the used token
51+
await db
52+
.delete(verificationTokens)
53+
.where(and(eq(verificationTokens.identifier, email), eq(verificationTokens.token, token)));
54+
55+
// Update user's last sign in time
56+
await db.update(users).set({ lastSignInAt: new Date() }).where(eq(users.id, user.id));
57+
58+
// Create or find existing session
59+
let existingSession = await db.query.sessions.findFirst({
60+
where: (row, { eq, and, gt }) => and(eq(row.userId, user.id), gt(row.expires, new Date())),
61+
});
62+
63+
if (!existingSession) {
64+
const sessionId = nanoid(40);
65+
const expiresAt = addDays(new Date(), 30);
66+
await db.insert(sessions).values({
67+
sessionToken: sessionId,
68+
userId: user.id,
69+
expires: expiresAt,
70+
});
71+
existingSession = {
72+
sessionToken: sessionId,
73+
expires: expiresAt,
74+
userId: user.id,
75+
};
76+
}
77+
78+
// Set session cookie
79+
const cookieStore = await cookies();
80+
const sessionCookie = await createSessionCookie(existingSession.sessionToken);
81+
82+
cookieStore.set(SESSION_COOKIE_NAME, sessionCookie, {
83+
path: "/",
84+
expires: existingSession.expires,
85+
sameSite: "lax",
86+
secure: process.env.NODE_ENV === "production",
87+
});
88+
89+
return NextResponse.redirect(new URL("/", request.url));
90+
} catch (error) {
91+
console.error("Error verifying magic link:", error);
92+
return NextResponse.redirect(new URL("/auth/logg-inn?error=verification-failed", request.url));
93+
}
94+
}

apps/web/src/utils/string.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import z from "zod";
2+
13
/**
24
* Capitalizes the first letter of a string.
35
*
@@ -92,10 +94,28 @@ export const initials = (name: string): string => {
9294
return `${first![0]}${second![0]}`.toUpperCase();
9395
};
9496

97+
/**
98+
* Truncates a string to a maximum length and adds an ellipsis if it exceeds that length.
99+
* If the string is shorter than or equal to the maximum length, it is returned unchanged.
100+
*
101+
* @param str the string to truncate
102+
* @param maxLength the maximum length of the string
103+
* @returns the truncated string with an ellipsis if it exceeds the maximum length
104+
*/
95105
export const ellipsis = (str: string, maxLength: number) => {
96106
if (str.length <= maxLength) {
97107
return str;
98108
}
99109

100110
return `${str.slice(0, maxLength)}...`;
101111
};
112+
113+
/**
114+
* Checks if a string is a valid email address.
115+
*
116+
* @param email the string to check
117+
* @returns true if the string is a valid email address, false otherwise
118+
*/
119+
export const isValidEmail = (email: string): boolean => {
120+
return z.string().email().safeParse(email).success;
121+
};

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,4 @@
4040
"turbo": "2.5.8",
4141
"typescript": "5.6.3"
4242
}
43-
}
43+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import * as React from "react";
2+
import {
3+
Body,
4+
Button,
5+
Container,
6+
Head,
7+
Heading,
8+
Html,
9+
Img,
10+
Preview,
11+
Section,
12+
Tailwind,
13+
Text,
14+
} from "@react-email/components";
15+
16+
type MagicLinkProps = {
17+
magicLinkUrl: string;
18+
firstName?: string;
19+
};
20+
21+
export default function MagicLinkEmail({ magicLinkUrl, firstName = "der" }: MagicLinkProps) {
22+
return (
23+
<Html>
24+
<Head />
25+
<Preview>Din magic link til å logge inn på echo</Preview>
26+
<Tailwind>
27+
<Body className="bg-white font-sans">
28+
<Container className="mx-auto my-8 w-full max-w-screen-sm border border-solid border-gray-200">
29+
<Section className="px-8 py-12 text-center">
30+
<Img
31+
src="https://cdn.sanity.io/images/pgq2pd26/production/b3eacd94f92e9041f7ece0346f27db0c9e520f60-512x512.png"
32+
width="75"
33+
height="75"
34+
alt="echo"
35+
style={{ margin: "auto" }}
36+
/>
37+
<Heading className="text-3xl font-bold">Logg inn på echo</Heading>
38+
39+
<Text className="text-gray-600">Hei {firstName}!</Text>
40+
41+
<Text className="text-gray-600">
42+
Klikk på knappen under for å logge inn på echo. Denne lenken er gyldig i 10
43+
minutter.
44+
</Text>
45+
46+
<Section className="my-8">
47+
<Button
48+
className="rounded bg-blue-600 px-6 py-3 text-center text-white no-underline"
49+
href={magicLinkUrl}
50+
>
51+
Logg inn
52+
</Button>
53+
</Section>
54+
55+
<Text className="text-sm text-gray-500">
56+
Hvis du ikke kan klikke på knappen, kan du kopiere og lime inn denne lenken i
57+
nettleseren din:
58+
</Text>
59+
60+
<Text className="break-all text-sm text-gray-400">{magicLinkUrl}</Text>
61+
62+
<Text className="text-sm text-gray-500">
63+
Hvis du ikke har bedt om en innloggingslenke, kan du trygt ignorere denne e-posten.
64+
</Text>
65+
66+
<Text className="text-xs text-gray-400">
67+
Denne lenken utløper automatisk etter 10 minutter av sikkerhetshensyn.
68+
</Text>
69+
</Section>
70+
</Container>
71+
</Body>
72+
</Tailwind>
73+
</Html>
74+
);
75+
}

packages/email/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ export { default as AccessGrantedEmail } from "./emails/access-granted";
88
export { default as AccessDeniedEmail } from "./emails/access-denied";
99
export { default as AccessRequestNotificationEmail } from "./emails/access-request-notification";
1010
export { default as EmailVerificationEmail } from "./emails/email-verification";
11+
export { default as MagicLinkEmail } from "./emails/magic-link";

0 commit comments

Comments
 (0)