Skip to content

Commit

Permalink
Add user auth via email/password
Browse files Browse the repository at this point in the history
  • Loading branch information
jsbroks committed Oct 12, 2024
1 parent bc335b3 commit f17458c
Show file tree
Hide file tree
Showing 19 changed files with 8,378 additions and 41 deletions.
19 changes: 19 additions & 0 deletions apps/webservice/src/app/(auth)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { IconPlane } from "@tabler/icons-react";

import { Button } from "@ctrlplane/ui/button";

export default function AuthPage({ children }: { children: React.ReactNode }) {
return (
<div className="h-full">
<div className="flex items-center gap-2 p-4">
<IconPlane className="h-10 w-10" />
<div className="flex-grow" />
<Button variant="ghost" className="text-muted-foreground">
Contact
</Button>
<Button variant="outline">Sign up</Button>
</div>
{children}
</div>
);
}
4 changes: 4 additions & 0 deletions apps/webservice/src/app/(auth)/login/LoginCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { IconBrandGoogle, IconLock } from "@tabler/icons-react";
import { signIn } from "next-auth/react";

import { Button } from "@ctrlplane/ui/button";
import { Separator } from "@ctrlplane/ui/separator";

export const LoginCard: React.FC<{
isGoogleEnabled: boolean;
Expand All @@ -15,6 +16,9 @@ export const LoginCard: React.FC<{
Log in to Ctrlplane
</h1>
<div className="space-y-6">
<>
<Separator />
</>
<div className="space-y-2">
{/* <Button
onClick={() => signIn("github")}
Expand Down
9 changes: 3 additions & 6 deletions apps/webservice/src/app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,16 @@ import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { IconPlane } from "@tabler/icons-react";

import { auth } from "@ctrlplane/auth";
import { auth, isGoogleAuthEnabled, isOIDCAuthEnabled } from "@ctrlplane/auth";
import { Button } from "@ctrlplane/ui/button";

import { env } from "~/env";
import { LoginCard } from "./LoginCard";

export const metadata: Metadata = { title: "Ctrlplane Login" };

export default async function LoginPage() {
const session = await auth();
if (session != null) redirect("/");
const isOidcEnabled = env.AUTH_OIDC_CLIENT_ID != null;
const isGoogleEnabled = env.AUTH_GOOGLE_CLIENT_ID != null;
return (
<div className="h-full">
<div className="flex items-center gap-2 p-4">
Expand All @@ -26,8 +23,8 @@ export default async function LoginPage() {
<Button variant="outline">Sign up</Button>
</div>
<LoginCard
isGoogleEnabled={isGoogleEnabled}
isOidcEnabled={isOidcEnabled}
isGoogleEnabled={isGoogleAuthEnabled}
isOidcEnabled={isOIDCAuthEnabled}
/>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { redirect } from "next/navigation";
import { IconPlane } from "@tabler/icons-react";

import { auth } from "@ctrlplane/auth";
import { auth, isGoogleAuthEnabled, isOIDCAuthEnabled } from "@ctrlplane/auth";
import { Button } from "@ctrlplane/ui/button";

import { env } from "~/env";
Expand All @@ -10,8 +10,7 @@ import { LoginCard } from "../../LoginCard";
export default async function LoginInvitePage() {
const session = await auth();
if (session != null) redirect("/");
const isOidcEnabled = env.AUTH_OIDC_CLIENT_ID != null;
const isGoogleEnabled = env.AUTH_GOOGLE_CLIENT_ID != null;

return (
<div className="h-full">
<div className="flex items-center gap-2 p-4">
Expand All @@ -23,8 +22,8 @@ export default async function LoginInvitePage() {
<Button variant="outline">Sign up</Button>
</div>
<LoginCard
isGoogleEnabled={isGoogleEnabled}
isOidcEnabled={isOidcEnabled}
isGoogleEnabled={isGoogleAuthEnabled}
isOidcEnabled={isOIDCAuthEnabled}
/>
</div>
);
Expand Down
101 changes: 101 additions & 0 deletions apps/webservice/src/app/(auth)/sign-up/SignUpCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"use client";

import { useRouter } from "next/navigation";
import { z } from "zod";

import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
useForm,
} from "@ctrlplane/ui/form";
import { Input } from "@ctrlplane/ui/input";

import { api } from "~/trpc/react";

const schema = z.object({
name: z.string().min(1),
email: z.string().email(),
password: z.string().min(8),
});

export const SignUpCard: React.FC<{
isGoogleEnabled: boolean;
isOidcEnabled: boolean;
}> = () => {
const router = useRouter();
const signUp = api.user.auth.signUp.useMutation();
const form = useForm({
schema,
defaultValues: {
name: "",
email: "",
password: "",
},
});

const onSubmit = form.handleSubmit((data) => {
signUp.mutate(data);
router.replace("/login");
});

return (
<div className="container mx-auto mt-[150px] max-w-[375px]">
<h1 className="mb-10 text-center text-3xl font-bold">
Sign up for Ctrlplane
</h1>
<div className="space-y-6">
<div className="space-y-2">
<Form {...form}>
<form onSubmit={onSubmit}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input {...field} type="password" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</div>
</div>
</div>
);
};
34 changes: 32 additions & 2 deletions packages/api/src/router/user.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { TRPCError } from "@trpc/server";
import { hashSync } from "bcryptjs";
import { omit } from "lodash";
import { z } from "zod";

import { can, generateApiKey, hash } from "@ctrlplane/auth/utils";
import { and, eq, takeFirst } from "@ctrlplane/db";
import { and, eq, takeFirst, takeFirstOrNull } from "@ctrlplane/db";
import { db } from "@ctrlplane/db/client";
import { scopeType, updateUser, user, userApiKey } from "@ctrlplane/db/schema";
import * as schema from "@ctrlplane/db/schema";
import { signInSchema } from "@ctrlplane/validators/auth";

import { createTRPCRouter, protectedProcedure } from "../trpc";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";

export const profileRouter = createTRPCRouter({
update: protectedProcedure
Expand All @@ -20,7 +25,32 @@ export const profileRouter = createTRPCRouter({
),
});

const authRouter = createTRPCRouter({
signUp: publicProcedure
.input(signInSchema.extend({ name: z.string() }))
.mutation(async ({ ctx, input }) => {
const { email, password, name } = input;

const user = await ctx.db
.select()
.from(schema.user)
.where(eq(schema.user.email, email))
.then(takeFirstOrNull);

if (user != null)
throw new TRPCError({ code: "NOT_FOUND", message: "User not found." });

const passwordHash = hashSync(password, 10);
return db
.insert(schema.user)
.values({ name, email, passwordHash })
.returning()
.then(takeFirst);
}),
});

export const userRouter = createTRPCRouter({
auth: authRouter,
can: protectedProcedure
.input(
z.object({
Expand Down
1 change: 1 addition & 0 deletions packages/auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"dependencies": {
"@auth/drizzle-adapter": "1.4.1",
"@ctrlplane/db": "workspace:*",
"@ctrlplane/validators": "workspace:*",
"@t3-oss/env-nextjs": "catalog:",
"bcryptjs": "^2.4.3",
"next": "catalog:",
Expand Down
75 changes: 51 additions & 24 deletions packages/auth/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import type { DefaultSession, NextAuthConfig, Profile } from "next-auth";
import type { DefaultSession, NextAuthConfig } from "next-auth";
import type { JWT } from "next-auth/jwt";
import type { OIDCConfig } from "next-auth/providers";
import type { Provider } from "next-auth/providers";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import Credentials from "next-auth/providers/credentials";
import Google from "next-auth/providers/google";
import { ZodError } from "zod";

import { and, eq, isNull } from "@ctrlplane/db";
import { db } from "@ctrlplane/db/client";
import * as schema from "@ctrlplane/db/schema";
import { signInSchema } from "@ctrlplane/validators/auth";

import { env } from "../env";
import { getUserByCredentials } from "./utils/credentials";

declare module "next-auth" {
interface Session {
Expand All @@ -19,34 +23,57 @@ declare module "next-auth" {
}
}

export const isGoogleAuthEnabled = env.AUTH_GOOGLE_CLIENT_ID != null;
export const isOIDCAuthEnabled = env.AUTH_OIDC_CLIENT_ID != null;
export const isCredentialsAuthEnabled =
!isGoogleAuthEnabled && !isOIDCAuthEnabled;

const providers = (): Provider[] => {
const p: Provider[] = [];
if (isGoogleAuthEnabled)
p.push(
Google({
clientId: env.AUTH_GOOGLE_CLIENT_ID,
}),
);

if (isOIDCAuthEnabled)
p.push({
id: "oidc",
type: "oidc",
name: "Single Sign-On",
issuer: env.AUTH_OIDC_ISSUER,
clientId: env.AUTH_OIDC_CLIENT_ID,
clientSecret: env.AUTH_OIDC_CLIENT_SECRET,
});

if (isCredentialsAuthEnabled)
p.push(
Credentials({
credentials: { email: {}, password: {} },
authorize: async (credentials) => {
try {
const { email, password } = signInSchema.parse(credentials);
return getUserByCredentials(email, password);
} catch (error) {
// Return `null` to indicate that the credentials are invalid
if (error instanceof ZodError) return null;
throw error;
}
},
}),
);

return p;
};

export const authConfig: NextAuthConfig = {
adapter: DrizzleAdapter(db, {
usersTable: schema.user,
accountsTable: schema.account,
sessionsTable: schema.session,
}),
providers: [
...(env.AUTH_GOOGLE_CLIENT_ID == null
? []
: [
Google({
clientId: env.AUTH_GOOGLE_CLIENT_ID,
clientSecret: env.AUTH_GOOGLE_CLIENT_SECRET,
}),
]),
...(env.AUTH_OIDC_ISSUER == null
? []
: [
{
id: "oidc",
type: "oidc",
name: "Single Sign-On",
issuer: env.AUTH_OIDC_ISSUER,
clientId: env.AUTH_OIDC_CLIENT_ID!,
clientSecret: env.AUTH_OIDC_CLIENT_SECRET!,
} satisfies OIDCConfig<Profile>,
]),
],
providers: providers(),
callbacks: {
session: (opts) => {
if (!("user" in opts))
Expand Down
1 change: 1 addition & 0 deletions packages/auth/src/index.rsc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import NextAuth from "next-auth";

import { authConfig } from "./config";

export * from "./config";
export type { Session } from "next-auth";

const {
Expand Down
1 change: 1 addition & 0 deletions packages/auth/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import NextAuth from "next-auth";

import { authConfig } from "./config";

export * from "./config";
export type { Session } from "next-auth";

const {
Expand Down
Loading

0 comments on commit f17458c

Please sign in to comment.