Skip to content

Commit 86a80a4

Browse files
Credentials provider (#192)
* email password functionality * feedback
1 parent 354b004 commit 86a80a4

File tree

10 files changed

+512
-113
lines changed

10 files changed

+512
-113
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "User" ADD COLUMN "hashedPassword" TEXT;

packages/db/prisma/schema.prisma

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -105,20 +105,20 @@ model Invite {
105105
}
106106

107107
model Org {
108-
id Int @id @default(autoincrement())
109-
name String
110-
domain String @unique
111-
createdAt DateTime @default(now())
112-
updatedAt DateTime @updatedAt
113-
members UserToOrg[]
114-
connections Connection[]
115-
repos Repo[]
116-
secrets Secret[]
117-
118-
stripeCustomerId String?
108+
id Int @id @default(autoincrement())
109+
name String
110+
domain String @unique
111+
createdAt DateTime @default(now())
112+
updatedAt DateTime @updatedAt
113+
members UserToOrg[]
114+
connections Connection[]
115+
repos Repo[]
116+
secrets Secret[]
117+
118+
stripeCustomerId String?
119119
120120
/// List of pending invites to this organization
121-
invites Invite[]
121+
invites Invite[]
122122
}
123123

124124
enum OrgRole {
@@ -157,13 +157,14 @@ model Secret {
157157

158158
// @see : https://authjs.dev/concepts/database-models#user
159159
model User {
160-
id String @id @default(cuid())
161-
name String?
162-
email String? @unique
163-
emailVerified DateTime?
164-
image String?
165-
accounts Account[]
166-
orgs UserToOrg[]
160+
id String @id @default(cuid())
161+
name String?
162+
email String? @unique
163+
hashedPassword String?
164+
emailVerified DateTime?
165+
image String?
166+
accounts Account[]
167+
orgs UserToOrg[]
167168
168169
/// List of pending invites that the user has created
169170
invites Invite[]

packages/web/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
"@viz-js/lang-dot": "^1.0.4",
7676
"@xiechao/codemirror-lang-handlebars": "^1.0.4",
7777
"ajv": "^8.17.1",
78+
"bcrypt": "^5.1.1",
7879
"class-variance-authority": "^0.7.0",
7980
"client-only": "^0.0.1",
8081
"clsx": "^2.1.1",
@@ -124,6 +125,7 @@
124125
"zod": "^3.23.8"
125126
},
126127
"devDependencies": {
128+
"@types/bcrypt": "^5.0.2",
127129
"@types/node": "^20",
128130
"@types/psl": "^1.1.3",
129131
"@types/react": "^18",
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { ErrorCode } from "@/lib/errorCodes";
2+
import { verifyCredentialsRequestSchema } from "@/lib/schemas";
3+
import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
4+
import { prisma } from "@/prisma";
5+
import { User as NextAuthUser } from "next-auth";
6+
import bcrypt from 'bcrypt';
7+
8+
export const runtime = 'nodejs';
9+
10+
export async function POST(request: Request) {
11+
const body = await request.json();
12+
const parsed = await verifyCredentialsRequestSchema.safeParseAsync(body);
13+
14+
if (!parsed.success) {
15+
return serviceErrorResponse(
16+
schemaValidationError(parsed.error)
17+
)
18+
}
19+
20+
const { email, password } = parsed.data;
21+
const user = await getOrCreateUser(email, password);
22+
23+
if (!user) {
24+
return serviceErrorResponse(
25+
{
26+
statusCode: 401,
27+
errorCode: ErrorCode.INVALID_CREDENTIALS,
28+
message: 'Invalid email or password',
29+
}
30+
)
31+
}
32+
33+
return Response.json(user);
34+
}
35+
36+
async function getOrCreateUser(email: string, password: string): Promise<NextAuthUser | null> {
37+
const user = await prisma.user.findUnique({
38+
where: { email }
39+
});
40+
41+
// The user doesn't exist, so create a new one.
42+
if (!user) {
43+
const hashedPassword = bcrypt.hashSync(password, 10);
44+
const newUser = await prisma.user.create({
45+
data: {
46+
email,
47+
hashedPassword,
48+
}
49+
});
50+
51+
return {
52+
id: newUser.id,
53+
email: newUser.email,
54+
}
55+
56+
// Otherwise, the user exists, so verify the password.
57+
} else {
58+
if (!user.hashedPassword) {
59+
return null;
60+
}
61+
62+
if (!bcrypt.compareSync(password, user.hashedPassword)) {
63+
return null;
64+
}
65+
66+
return {
67+
id: user.id,
68+
email: user.email,
69+
name: user.name ?? undefined,
70+
image: user.image ?? undefined,
71+
};
72+
}
73+
74+
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
'use client';
2+
3+
import { Button } from "@/components/ui/button";
4+
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
5+
import { Input } from "@/components/ui/input";
6+
import { useForm } from "react-hook-form";
7+
import { zodResolver } from "@hookform/resolvers/zod";
8+
import { z } from "zod";
9+
import logoDark from "@/public/sb_logo_dark_large.png";
10+
import logoLight from "@/public/sb_logo_light_large.png";
11+
import githubLogo from "@/public/github.svg";
12+
import googleLogo from "@/public/google.svg";
13+
import Image from "next/image";
14+
import { signIn } from "next-auth/react";
15+
import { useCallback, useMemo } from "react";
16+
import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
17+
import { verifyCredentialsRequestSchema } from "@/lib/schemas";
18+
19+
export const LoginForm = () => {
20+
const callbackUrl = useNonEmptyQueryParam("callbackUrl");
21+
const error = useNonEmptyQueryParam("error");
22+
23+
const form = useForm<z.infer<typeof verifyCredentialsRequestSchema>>({
24+
resolver: zodResolver(verifyCredentialsRequestSchema),
25+
defaultValues: {
26+
email: "",
27+
password: "",
28+
},
29+
});
30+
31+
const onSignInWithEmailPassword = (values: z.infer<typeof verifyCredentialsRequestSchema>) => {
32+
signIn("credentials", {
33+
email: values.email,
34+
password: values.password,
35+
redirectTo: callbackUrl ?? "/"
36+
});
37+
}
38+
39+
const onSignInWithOauth = useCallback((provider: string) => {
40+
signIn(provider, { redirectTo: callbackUrl ?? "/" });
41+
}, [callbackUrl]);
42+
43+
const errorMessage = useMemo(() => {
44+
if (!error) {
45+
return "";
46+
}
47+
switch (error) {
48+
case "CredentialsSignin":
49+
return "Invalid email or password. Please try again.";
50+
case "OAuthAccountNotLinked":
51+
return "This email is already associated with a different sign-in method.";
52+
default:
53+
return "An error occurred during authentication. Please try again.";
54+
}
55+
}, [error]);
56+
57+
return (
58+
<div className="flex flex-col items-center border p-16 rounded-lg gap-6 w-[500px]">
59+
{error && (
60+
<div className="text-sm text-destructive text-center text-wrap border p-2 rounded-md border-destructive">
61+
{errorMessage}
62+
</div>
63+
)}
64+
<div>
65+
<Image
66+
src={logoDark}
67+
className="h-16 w-auto hidden dark:block"
68+
alt={"Sourcebot logo"}
69+
priority={true}
70+
/>
71+
<Image
72+
src={logoLight}
73+
className="h-16 w-auto block dark:hidden"
74+
alt={"Sourcebot logo"}
75+
priority={true}
76+
/>
77+
</div>
78+
<ProviderButton
79+
name="GitHub"
80+
logo={githubLogo}
81+
onClick={() => {
82+
onSignInWithOauth("github")
83+
}}
84+
/>
85+
<ProviderButton
86+
name="Google"
87+
logo={googleLogo}
88+
onClick={() => {
89+
onSignInWithOauth("google")
90+
}}
91+
/>
92+
<div className="flex items-center w-full gap-4">
93+
<div className="h-[1px] flex-1 bg-border" />
94+
<span className="text-muted-foreground text-sm">or</span>
95+
<div className="h-[1px] flex-1 bg-border" />
96+
</div>
97+
<div className="flex flex-col w-60">
98+
<Form {...form}>
99+
<form onSubmit={form.handleSubmit(onSignInWithEmailPassword)}>
100+
<FormField
101+
control={form.control}
102+
name="email"
103+
render={({ field }) => (
104+
<FormItem className="mb-4">
105+
<FormLabel>Email</FormLabel>
106+
<FormControl>
107+
<Input placeholder="email@example.com" {...field} />
108+
</FormControl>
109+
<FormMessage />
110+
</FormItem>
111+
)}
112+
/>
113+
<FormField
114+
control={form.control}
115+
name="password"
116+
render={({ field }) => (
117+
<FormItem className="mb-8">
118+
<FormLabel>Password</FormLabel>
119+
<FormControl>
120+
<Input type="password" {...field} />
121+
</FormControl>
122+
<FormMessage />
123+
</FormItem>
124+
)}
125+
/>
126+
<Button type="submit" className="w-full">
127+
Sign in
128+
</Button>
129+
</form>
130+
</Form>
131+
</div>
132+
</div >
133+
)
134+
}
135+
136+
const ProviderButton = ({
137+
name,
138+
logo,
139+
onClick,
140+
}: {
141+
name: string;
142+
logo: string;
143+
onClick: () => void;
144+
}) => {
145+
return (
146+
<Button onClick={onClick}>
147+
{logo && <Image src={logo} alt={name} className="w-5 h-5 invert dark:invert-0 mr-2" />}
148+
Sign in with {name}
149+
</Button>
150+
)
151+
}

0 commit comments

Comments
 (0)