Skip to content

Commit 96c26a7

Browse files
authored
Magic link (stack-auth#13)
* added magic link email, updated email template * added magic link ui and db schema * restructured sign in sign up page * updated example custom button * added joy tabs * fixed bugs, added magic link errors, abstracted token creation * added magic link callback * fixed token bugs * added more auth information to user object * added changeset
1 parent a5f9587 commit 96c26a7

File tree

52 files changed

+1100
-567
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

52 files changed

+1100
-567
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@stackframe/stack-server": minor
3+
"@stackframe/stack-shared": minor
4+
"@stackframe/stack": minor
5+
"@stackframe/stack-sc": minor
6+
---
7+
8+
added magic link

apps/dev/src/components/custom-button.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import React from "react";
44
import { Button as DefaultButton, useDesign } from "@stackframe/stack";
55

66
export const Button = React.forwardRef<
7-
HTMLButtonElement,
7+
React.ElementRef<typeof DefaultButton>,
88
React.ComponentProps<typeof DefaultButton>
99
>(({
1010
variant = "primary",

docs/docs/02-customization/03-custom-components.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import React from "react";
2121
import { Button as DefaultButton, useDesign } from "@stackframe/stack";
2222

2323
export const Button = React.forwardRef<
24-
HTMLButtonElement,
24+
React.ElementRef<typeof DefaultButton>,
2525
React.ComponentProps<typeof DefaultButton>
2626
>(({
2727
variant = "primary",

package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,5 @@
4848
"overrides": {}
4949
},
5050
"dependencies": {
51-
"@radix-ui/react-separator": "^1.0.3",
52-
"@stackframe/stack": "workspace:^"
5351
}
5452
}

packages/stack-server/backend-design-doc.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ Terminology: "Invalid" means it was found but not valid, "Not found" means it wa
5757
- `EmailVerificationCodeNotFound`: The e-mail verification code does not exist for this project. (404)
5858
- `EmailVerificationCodeExpired`: The e-mail verification code has expired. (400)
5959
- `EmailVerificationCodeAlreadyUsed`: The e-mail verification code has already been used. (400)
60+
- `MagicLinkError`:
61+
- `MagicLinkCodeError`:
62+
- `MagicLinkCodeNotFound`: The magic link code does not exist for this project. (404)
63+
- `MagicLinkCodeExpired`: The magic link code has expired. (400)
64+
- `MagicLinkCodeAlreadyUsed`: The magic link code has already been used. (400)
6065
- `PasswordResetError`:
6166
- `PasswordResetCodeError`:
6267
- `PasswordResetCodeNotFound`: The password reset code does not exist for this project. (404)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
-- AlterTable
2+
ALTER TABLE "ProjectConfig" ADD COLUMN "magicLinkEnabled" BOOLEAN NOT NULL DEFAULT false;
3+
4+
-- AlterTable, authWithEmail default to true if password hash is set previously, otherwise false
5+
ALTER TABLE "ProjectUser" ADD COLUMN "authWithEmail" BOOLEAN NOT NULL DEFAULT false;
6+
UPDATE "ProjectUser" SET "authWithEmail" = true WHERE "passwordHash" IS NOT NULL;
7+
8+
-- CreateTable
9+
CREATE TABLE "ProjectUserMagicLinkCode" (
10+
"projectId" TEXT NOT NULL,
11+
"projectUserId" UUID NOT NULL,
12+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
13+
"updatedAt" TIMESTAMP(3) NOT NULL,
14+
"code" TEXT NOT NULL,
15+
"expiresAt" TIMESTAMP(3) NOT NULL,
16+
"usedAt" TIMESTAMP(3),
17+
"redirectUrl" TEXT NOT NULL,
18+
"newUser" BOOLEAN NOT NULL,
19+
20+
CONSTRAINT "ProjectUserMagicLinkCode_pkey" PRIMARY KEY ("projectId","code")
21+
);
22+
23+
-- CreateIndex
24+
CREATE UNIQUE INDEX "ProjectUserMagicLinkCode_code_key" ON "ProjectUserMagicLinkCode"("code");
25+
26+
-- AddForeignKey
27+
ALTER TABLE "ProjectUserMagicLinkCode" ADD CONSTRAINT "ProjectUserMagicLinkCode_projectId_projectUserId_fkey" FOREIGN KEY ("projectId", "projectUserId") REFERENCES "ProjectUser"("projectId", "projectUserId") ON DELETE CASCADE ON UPDATE CASCADE;

packages/stack-server/prisma/schema.prisma

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ model ProjectConfig {
3939
4040
allowLocalhost Boolean
4141
credentialEnabled Boolean
42+
magicLinkEnabled Boolean
4243
4344
projects Project[]
4445
oauthProviderConfigs OAuthProviderConfig[]
@@ -87,17 +88,20 @@ model ProjectUser {
8788
projectUserOAuthAccounts ProjectUserOAuthAccount[]
8889
projectUserEmailVerificationCode ProjectUserEmailVerificationCode[]
8990
projectUserPasswordResetCode ProjectUserPasswordResetCode[]
91+
projectUserMagicLinkCode ProjectUserMagicLinkCode[]
9092
9193
primaryEmail String?
9294
primaryEmailVerified Boolean
9395
profileImageUrl String?
9496
displayName String?
9597
passwordHash String?
98+
authWithEmail Boolean
9699
97100
serverMetadata Json?
98101
clientMetadata Json?
99102
100103
@@id([projectId, projectUserId])
104+
@@unique([projectId, primaryEmail, authWithEmail])
101105
}
102106

103107
model ProjectUserOAuthAccount {
@@ -188,6 +192,24 @@ model ProjectUserPasswordResetCode {
188192
@@id([projectId, code])
189193
}
190194

195+
model ProjectUserMagicLinkCode {
196+
projectId String
197+
projectUserId String @db.Uuid
198+
199+
createdAt DateTime @default(now())
200+
updatedAt DateTime @updatedAt
201+
202+
code String @unique
203+
expiresAt DateTime
204+
usedAt DateTime?
205+
redirectUrl String
206+
newUser Boolean
207+
208+
projectUser ProjectUser @relation(fields: [projectId, projectUserId], references: [projectId, projectUserId], onDelete: Cascade)
209+
210+
@@id([projectId, code])
211+
}
212+
191213
//#region API keys
192214

193215
model ApiKeySet {

packages/stack-server/prisma/seed.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ async function seed() {
5858
}
5959
},
6060
credentialEnabled: true,
61+
magicLinkEnabled: true,
6162
},
6263
},
6364
},

packages/stack-server/src/app/api/v1/auth/callback/[provider]/route.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ export const GET = deprecatedSmartRouteHandler(async (req: NextRequest, options:
148148
profileImageUrl: userInfo.profileImageUrl,
149149
primaryEmail: userInfo.email,
150150
primaryEmailVerified: true,
151+
authWithEmail: false,
151152
},
152153
},
153154
},
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import * as yup from "yup";
3+
import { prismaClient } from "@/prisma-client";
4+
import { deprecatedParseRequest, deprecatedSmartRouteHandler } from "@/lib/route-handlers";
5+
import { KnownErrors } from "@stackframe/stack-shared";
6+
import { createAuthTokens } from "@/lib/tokens";
7+
8+
const postSchema = yup.object({
9+
body: yup.object({
10+
code: yup.string().required(),
11+
}),
12+
});
13+
14+
export const POST = deprecatedSmartRouteHandler(async (req: NextRequest) => {
15+
const { body: { code } } = await deprecatedParseRequest(req, postSchema);
16+
17+
const codeRecord = await prismaClient.projectUserMagicLinkCode.findUnique({
18+
where: {
19+
code
20+
},
21+
include: {
22+
projectUser: true,
23+
},
24+
});
25+
26+
if (!codeRecord) {
27+
throw new KnownErrors.MagicLinkCodeNotFound();
28+
}
29+
30+
if (codeRecord.expiresAt < new Date()) {
31+
throw new KnownErrors.MagicLinkCodeExpired();
32+
}
33+
34+
if (codeRecord.usedAt) {
35+
throw new KnownErrors.MagicLinkCodeAlreadyUsed();
36+
}
37+
38+
await prismaClient.projectUser.update({
39+
where: {
40+
projectId_projectUserId: {
41+
projectId: codeRecord.projectId,
42+
projectUserId: codeRecord.projectUserId,
43+
},
44+
},
45+
data: {
46+
primaryEmailVerified: true,
47+
},
48+
});
49+
50+
await prismaClient.projectUserMagicLinkCode.update({
51+
where: {
52+
code,
53+
},
54+
data: {
55+
usedAt: new Date(),
56+
},
57+
});
58+
59+
const { refreshToken, accessToken } = await createAuthTokens({
60+
projectId: codeRecord.projectId,
61+
projectUserId: codeRecord.projectUserId,
62+
});
63+
64+
return NextResponse.json({
65+
refreshToken,
66+
accessToken,
67+
newUser: codeRecord.newUser,
68+
});
69+
});

0 commit comments

Comments
 (0)