diff options
Diffstat (limited to 'apps')
| -rw-r--r-- | apps/web/components/signin/CredentialsForm.tsx | 19 | ||||
| -rw-r--r-- | apps/web/server/auth.ts | 88 |
2 files changed, 97 insertions, 10 deletions
diff --git a/apps/web/components/signin/CredentialsForm.tsx b/apps/web/components/signin/CredentialsForm.tsx index 07e08fae..65fec6a8 100644 --- a/apps/web/components/signin/CredentialsForm.tsx +++ b/apps/web/components/signin/CredentialsForm.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { ActionButton } from "@/components/ui/action-button"; import { Form, @@ -28,9 +28,18 @@ const signInSchema = z.object({ password: z.string(), }); +const SIGNIN_FAILED = "Incorrect username or password"; +const OAUTH_FAILED = "OAuth login failed: "; + function SignIn() { - const [signinError, setSigninError] = useState(false); + const [signinError, setSigninError] = useState(""); const router = useRouter(); + const searchParams = useSearchParams(); + const oAuthError = searchParams.get("error"); + if (oAuthError && !signinError) { + setSigninError(`${OAUTH_FAILED} ${oAuthError}`); + } + const form = useForm<z.infer<typeof signInSchema>>({ resolver: zodResolver(signInSchema), }); @@ -45,7 +54,7 @@ function SignIn() { password: value.password, }); if (!resp || !resp?.ok) { - setSigninError(true); + setSigninError(SIGNIN_FAILED); return; } router.replace("/"); @@ -53,9 +62,7 @@ function SignIn() { > <div className="flex w-full flex-col space-y-2"> {signinError && ( - <p className="w-full text-center text-destructive"> - Incorrect username or password - </p> + <p className="w-full text-center text-destructive">{signinError}</p> )} <FormField control={form.control} diff --git a/apps/web/server/auth.ts b/apps/web/server/auth.ts index 2ab44d5a..483d1522 100644 --- a/apps/web/server/auth.ts +++ b/apps/web/server/auth.ts @@ -1,5 +1,6 @@ import type { Adapter } from "next-auth/adapters"; import { DrizzleAdapter } from "@auth/drizzle-adapter"; +import { and, count, eq } from "drizzle-orm"; import NextAuth, { DefaultSession, getServerSession, @@ -15,13 +16,16 @@ import { users, verificationTokens, } from "@hoarder/db/schema"; +import serverConfig from "@hoarder/shared/config"; import { validatePassword } from "@hoarder/trpc/auth"; +type UserRole = "admin" | "user"; + declare module "next-auth/jwt" { export interface JWT { user: { id: string; - role: "admin" | "user"; + role: UserRole; } & DefaultSession["user"]; } } @@ -33,15 +37,38 @@ declare module "next-auth" { export interface Session { user: { id: string; - role: "admin" | "user"; + role: UserRole; } & DefaultSession["user"]; } export interface DefaultUser { - role: "admin" | "user" | null; + role: UserRole | null; } } +/** + * Returns true if the user table is empty, which indicates that this user is going to be + * the first one. This can be racy if multiple users are created at the same time, but + * that should be fine. + */ +async function isFirstUser(): Promise<boolean> { + const [{ count: userCount }] = await db + .select({ count: count() }) + .from(users); + return userCount == 0; +} + +/** + * Returns true if the user is an admin + */ +async function isAdmin(email: string): Promise<boolean> { + const res = await db.query.users.findFirst({ + columns: { role: true }, + where: eq(users.email, email), + }); + return res?.role == "admin"; +} + const providers: Provider[] = [ CredentialsProvider({ // The name to display on the sign in form (e.g. "Sign in with...") @@ -67,6 +94,35 @@ const providers: Provider[] = [ }), ]; +const oauth = serverConfig.auth.oauth; +if (oauth.wellKnownUrl) { + providers.push({ + id: "custom", + name: oauth.name, + type: "oauth", + wellKnown: oauth.wellKnownUrl, + authorization: { params: { scope: oauth.scope } }, + clientId: oauth.clientId, + clientSecret: oauth.clientSecret, + allowDangerousEmailAccountLinking: oauth.allowDangerousEmailAccountLinking, + idToken: true, + checks: ["pkce", "state"], + async profile(profile: Record<string, string>) { + const [admin, firstUser] = await Promise.all([ + isAdmin(profile.email), + isFirstUser(), + ]); + return { + id: profile.sub, + name: profile.name, + email: profile.email, + image: profile.picture, + role: admin || firstUser ? "admin" : "user", + }; + }, + }); +} + export const authOptions: NextAuthOptions = { // https://github.com/nextauthjs/next-auth/issues/9493 adapter: DrizzleAdapter(db, { @@ -79,7 +135,31 @@ export const authOptions: NextAuthOptions = { session: { strategy: "jwt", }, + pages: { + signIn: "/signin", + signOut: "/signin", + error: "/signin", + newUser: "/signin", + }, callbacks: { + async signIn({ credentials, profile }) { + if (credentials) { + return true; + } + if (!profile?.email || !profile?.name) { + throw new Error("No profile"); + } + const [{ count: userCount }] = await db + .select({ count: count() }) + .from(users) + .where(and(eq(users.email, profile.email))); + + // If it's a new user and signups are disabled, fail the sign in + if (userCount === 0 && serverConfig.auth.disableSignups) { + throw new Error("Signups are disabled in server config"); + } + return true; + }, async jwt({ token, user }) { if (user) { token.user = { @@ -87,7 +167,7 @@ export const authOptions: NextAuthOptions = { name: user.name, email: user.email, image: user.image, - role: user.role || "user", + role: user.role ?? "user", }; } return token; |
