diff --git a/apps/social/next.config.mjs b/apps/social/next.config.mjs index 32f7991c..27ba365c 100644 --- a/apps/social/next.config.mjs +++ b/apps/social/next.config.mjs @@ -8,7 +8,7 @@ const nextConfig = { removeConsole: process.env.NODE_ENV === "production", }, - transpilePackages: ["@umamin/ui", "@umamin/db", "@umamin/gql"], + transpilePackages: ["@umamin/ui", "@umamin/db", "@umamin/gql", "@umamin/shared"], images: { remotePatterns: [ { diff --git a/apps/social/package.json b/apps/social/package.json index 95f58f2d..439c8b96 100644 --- a/apps/social/package.json +++ b/apps/social/package.json @@ -24,6 +24,7 @@ "@umamin/db": "workspace:*", "@umamin/gql": "workspace:*", "@umamin/ui": "workspace:*", + "@umamin/shared": "workspace:*", "@urql/core": "^5.0.5", "@urql/exchange-graphcache": "^7.1.1", "@urql/exchange-persisted": "^4.3.0", diff --git a/apps/social/src/app/(authentication)/login/google/callback/route.ts b/apps/social/src/app/(authentication)/login/google/callback/route.ts index 261b8560..00f5c9ad 100644 --- a/apps/social/src/app/(authentication)/login/google/callback/route.ts +++ b/apps/social/src/app/(authentication)/login/google/callback/route.ts @@ -1,158 +1 @@ -import { nanoid } from "nanoid"; -import { generateId } from "lucia"; -import { cookies } from "next/headers"; -import { db, and, eq } from "@umamin/db"; -import { OAuth2RequestError } from "arctic"; -import { - user as userSchema, - account as accountSchema, -} from "@umamin/db/schema/user"; - -import { getSession, google, lucia } from "@/lib/auth"; - -export async function GET(request: Request): Promise { - const url = new URL(request.url); - const code = url.searchParams.get("code"); - const state = url.searchParams.get("state"); - - const storedState = cookies().get("google_oauth_state")?.value ?? null; - const storedCodeVerifier = cookies().get("code_verifier")?.value ?? null; - - if ( - !code || - !state || - !storedState || - !storedCodeVerifier || - state !== storedState - ) { - return new Response(null, { - status: 400, - }); - } - - try { - const tokens = await google.validateAuthorizationCode( - code, - storedCodeVerifier, - ); - - const googleUserResponse = await fetch( - "https://openidconnect.googleapis.com/v1/userinfo", - { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }, - ); - - const googleUser: GoogleUser = await googleUserResponse.json(); - - const { user } = await getSession(); - - const existingUser = await db.query.account.findFirst({ - where: and( - eq(accountSchema.providerId, "google"), - eq(accountSchema.providerUserId, googleUser.sub), - ), - }); - - if (user && existingUser) { - return new Response(null, { - status: 302, - headers: { - Location: "/settings?error=already_linked", - }, - }); - } else if (user) { - await db - .update(userSchema) - .set({ - imageUrl: googleUser.picture, - }) - .where(eq(userSchema.id, user.id)); - - await db.insert(accountSchema).values({ - providerId: "google", - providerUserId: googleUser.sub, - userId: user.id, - picture: googleUser.picture, - email: googleUser.email, - }); - - return new Response(null, { - status: 302, - headers: { - Location: "/settings", - }, - }); - } - - if (existingUser) { - const session = await lucia.createSession(existingUser.userId, {}); - const sessionCookie = lucia.createSessionCookie(session.id); - - cookies().set( - sessionCookie.name, - sessionCookie.value, - sessionCookie.attributes, - ); - - return new Response(null, { - status: 302, - headers: { - Location: "/login", - }, - }); - } - - const usernameId = generateId(5); - const userId = nanoid(); - - await db.insert(userSchema).values({ - id: userId, - imageUrl: googleUser.picture, - username: `umamin_${usernameId}`, - }); - - await db.insert(accountSchema).values({ - providerId: "google", - providerUserId: googleUser.sub, - userId, - picture: googleUser.picture, - email: googleUser.email, - }); - - const session = await lucia.createSession(userId, {}); - const sessionCookie = lucia.createSessionCookie(session.id); - - cookies().set( - sessionCookie.name, - sessionCookie.value, - sessionCookie.attributes, - ); - - return new Response(null, { - status: 302, - headers: { - Location: "/login", - }, - }); - } catch (err: any) { - console.log(err); - if (err instanceof OAuth2RequestError) { - return new Response(null, { - status: 400, - }); - } - - return new Response(null, { - status: 500, - }); - } -} - -interface GoogleUser { - sub: string; - picture: string; - email: string; -} +export { GET } from "@umamin/shared/routes/google/callback/route"; diff --git a/apps/social/src/app/(authentication)/login/google/route.ts b/apps/social/src/app/(authentication)/login/google/route.ts index 88f65bc7..7c78acb6 100644 --- a/apps/social/src/app/(authentication)/login/google/route.ts +++ b/apps/social/src/app/(authentication)/login/google/route.ts @@ -1,31 +1 @@ -import { generateState, generateCodeVerifier } from "arctic"; -import { cookies } from "next/headers"; -import { google } from "@/lib/auth"; - -export async function GET(): Promise { - const state = generateState(); - const codeVerifier = generateCodeVerifier(); - const url = await google.createAuthorizationURL(state, codeVerifier, { - scopes: ["profile", "email"], - }); - - url.searchParams.set("access_type", "offline"); - - cookies().set("google_oauth_state", state, { - path: "/", - secure: process.env.NODE_ENV === "production", - httpOnly: true, - maxAge: 60 * 10, - sameSite: "lax", - }); - - cookies().set("code_verifier", codeVerifier, { - path: "/", - secure: process.env.NODE_ENV === "production", - httpOnly: true, - maxAge: 60 * 10, - sameSite: "lax", - }); - - return Response.redirect(url); -} +export { GET } from "@umamin/shared/routes/google/route"; diff --git a/apps/social/src/app/(authentication)/login/loading.tsx b/apps/social/src/app/(authentication)/login/loading.tsx index 5ba9d85f..2012bff6 100644 --- a/apps/social/src/app/(authentication)/login/loading.tsx +++ b/apps/social/src/app/(authentication)/login/loading.tsx @@ -1,28 +1 @@ -import { Skeleton } from "@umamin/ui/components/skeleton"; - -export default function Loading() { - return ( -
-
- - -
- -
- - -
- -
- - -
- -
- - - -
-
- ); -} +export { default } from "@umamin/shared/app/login/loading"; diff --git a/apps/social/src/app/(authentication)/login/page.tsx b/apps/social/src/app/(authentication)/login/page.tsx index a2e86b09..4842a9f4 100644 --- a/apps/social/src/app/(authentication)/login/page.tsx +++ b/apps/social/src/app/(authentication)/login/page.tsx @@ -1,14 +1,3 @@ -import Link from "next/link"; -import dynamic from "next/dynamic"; -import { getSession } from "@/lib/auth"; -import { redirect } from "next/navigation"; -import { LoginForm } from "./components/form"; - -const BrowserWarning = dynamic( - () => import("@umamin/ui/components/browser-warning"), - { ssr: false } -); - export const metadata = { title: "Umamin Social — Login", description: @@ -37,41 +26,4 @@ export const metadata = { }, }; -export default async function Login() { - const { user } = await getSession(); - - if (user) { - redirect("/"); - } - - return ( -
- -
-

- Umamin Social -

-

- The Umamin v2.0{" "} - Next generation open-source social platform -

-
- -
-
-

Account

-

- Proceed with your Umamin v2.0 profile -

-
- -
- Don't have an account?{" "} - - Sign up - -
-
-
- ); -} +export { default } from "@umamin/shared/app/login/page"; diff --git a/apps/social/src/app/(authentication)/register/loading.tsx b/apps/social/src/app/(authentication)/register/loading.tsx index d623ffdd..ff4b79de 100644 --- a/apps/social/src/app/(authentication)/register/loading.tsx +++ b/apps/social/src/app/(authentication)/register/loading.tsx @@ -1,44 +1 @@ -import { Skeleton } from "@umamin/ui/components/skeleton"; - -export default function Loading() { - return ( -
-
-
- - -
- - -
-
- -
-
- - -
- -
- - -
-
-
- -
-
-
- - -
- - -
- - - -
-
- ); -} +export { default } from "@umamin/shared/app/register/loading"; diff --git a/apps/social/src/app/(authentication)/register/page.tsx b/apps/social/src/app/(authentication)/register/page.tsx index beda3f71..bf7f0719 100644 --- a/apps/social/src/app/(authentication)/register/page.tsx +++ b/apps/social/src/app/(authentication)/register/page.tsx @@ -1,14 +1,3 @@ -import Link from "next/link"; -import dynamic from "next/dynamic"; -import { getSession } from "@/lib/auth"; -import { redirect } from "next/navigation"; -import { RegisterForm } from "./components/form"; - -const BrowserWarning = dynamic( - () => import("@umamin/ui/components/browser-warning"), - { ssr: false } -); - export const metadata = { title: "Umamin Social — Register", description: @@ -37,41 +26,4 @@ export const metadata = { }, }; -export default async function Register() { - const { user } = await getSession(); - - if (user) { - redirect("/"); - } - - return ( -
- - -
-

- Umamin Account -

-

- By creating an account, you agree to our{" "} - - Privacy Policy - {" "} - and{" "} - - Terms of Service - -

-
- - - -
- Already have an account?{" "} - - Login - -
-
- ); -} +export { default } from "@umamin/shared/app/register/page"; diff --git a/packages/shared/.eslintrc.json b/packages/shared/.eslintrc.json new file mode 100644 index 00000000..bffb357a --- /dev/null +++ b/packages/shared/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/packages/shared/.gitignore b/packages/shared/.gitignore new file mode 100644 index 00000000..fd3dbb57 --- /dev/null +++ b/packages/shared/.gitignore @@ -0,0 +1,36 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/packages/shared/README.md b/packages/shared/README.md new file mode 100644 index 00000000..c4033664 --- /dev/null +++ b/packages/shared/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/packages/shared/actions.ts b/packages/shared/actions.ts new file mode 100644 index 00000000..ee86c9bd --- /dev/null +++ b/packages/shared/actions.ts @@ -0,0 +1,259 @@ +"use server"; + +import { nanoid } from "nanoid"; +import { db, eq } from "@umamin/db"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { hash, verify } from "@node-rs/argon2"; +import { + user as userSchema, + account as accountSchema, +} from "@umamin/db/schema/user"; +import { note as noteSchema } from "@umamin/db/schema/note"; +import { message as messageSchema } from "@umamin/db/schema/message"; + +import { getSession, lucia } from "./lib/auth"; +import { z } from "zod"; + +export async function logout(): Promise { + const { session } = await getSession(); + + if (!session) { + throw new Error("Unauthorized"); + } + + await lucia.invalidateSession(session.id); + + const sessionCookie = lucia.createBlankSessionCookie(); + cookies().set( + sessionCookie.name, + sessionCookie.value, + sessionCookie.attributes, + ); + + return redirect("/login"); +} + +const signupSchema = z + .object({ + username: z + .string() + .min(5, { + message: "Username must be at least 5 characters", + }) + .max(20, { + message: "Username must not exceed 20 characters", + }) + .refine((url) => /^[a-zA-Z0-9_-]+$/.test(url), { + message: "Username must be alphanumeric with no spaces", + }), + password: z + .string() + .min(5, { + message: "Password must be at least 5 characters", + }) + .max(255, { + message: "Password must not exceed 255 characters", + }), + confirmPassword: z.string(), + }) + .refine( + (values) => { + return values.password === values.confirmPassword; + }, + { + message: "Password does not match", + path: ["confirmPassword"], + }, + ); + +export async function signup(_: any, formData: FormData) { + const validatedFields = signupSchema.safeParse({ + username: formData.get("username"), + password: formData.get("password"), + confirmPassword: formData.get("confirmPassword"), + }); + + if (!validatedFields.success) { + return { + errors: validatedFields.error.flatten().fieldErrors, + }; + } + + const passwordHash = await hash(validatedFields.data.password, { + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1, + }); + + const userId = nanoid(); + + try { + await db.insert(userSchema).values({ + id: userId, + username: validatedFields.data.username.toLowerCase(), + passwordHash, + }); + } catch (err: any) { + if (err.code === "SQLITE_CONSTRAINT") { + if (err.message.includes("user.username")) { + return { + errors: { + username: ["Username already taken"], + }, + }; + } + } + + throw new Error("Something went wrong"); + } + + const session = await lucia.createSession(userId, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + + cookies().set( + sessionCookie.name, + sessionCookie.value, + sessionCookie.attributes, + ); + + return redirect("/inbox"); +} + +export async function login(_: any, formData: FormData): Promise { + const username = formData.get("username"); + + if ( + typeof username !== "string" || + username.length < 5 || + username.length > 20 || + !/^[a-zA-Z0-9_-]+$/.test(username) + ) { + return { + error: "Incorrect username or password", + }; + } + + const password = formData.get("password"); + + if ( + typeof password !== "string" || + password.length < 5 || + password.length > 255 + ) { + return { + error: "Incorrect username or password", + }; + } + + const existingUser = await db.query.user.findFirst({ + where: eq(userSchema.username, username.toLowerCase()), + }); + + if (!existingUser || !existingUser.passwordHash) { + return { + error: "Incorrect username or password", + }; + } + + const validPassword = await verify(existingUser.passwordHash, password, { + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1, + }); + + if (!validPassword) { + return { + error: "Incorrect username or password", + }; + } + + const session = await lucia.createSession(existingUser.id, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + cookies().set( + sessionCookie.name, + sessionCookie.value, + sessionCookie.attributes, + ); + + return redirect("/inbox"); +} + +export async function updatePassword({ + currentPassword, + password, +}: { + currentPassword?: string; + password: string; +}): Promise { + const { user } = await getSession(); + + if (!user) { + throw new Error("Unauthorized"); + } + + if (currentPassword && user.passwordHash) { + const validPassword = await verify(user.passwordHash, currentPassword, { + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1, + }); + + if (!validPassword) { + return { + error: "Incorrect password", + }; + } + } + + const passwordHash = await hash(password, { + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1, + }); + + await db + .update(userSchema) + .set({ passwordHash }) + .where(eq(userSchema.id, user.id)); + + return redirect("/settings"); +} + +export async function deleteAccount() { + const { user } = await getSession(); + + if (!user) { + throw new Error("Unauthorized"); + } + + try { + await db.batch([ + db.delete(messageSchema).where(eq(messageSchema.receiverId, user.id)), + db.delete(accountSchema).where(eq(accountSchema.userId, user.id)), + db.delete(noteSchema).where(eq(noteSchema.userId, user.id)), + db.delete(userSchema).where(eq(userSchema.id, user.id)), + ]); + + await lucia.invalidateSession(user.id); + + const sessionCookie = lucia.createBlankSessionCookie(); + cookies().set( + sessionCookie.name, + sessionCookie.value, + sessionCookie.attributes, + ); + } catch (err) { + throw new Error("Failed to delete account"); + } + + return redirect("/login"); +} + +interface ActionResult { + error: string | null; +} diff --git a/apps/social/src/app/(authentication)/login/components/form.tsx b/packages/shared/app/login/components/form.tsx similarity index 100% rename from apps/social/src/app/(authentication)/login/components/form.tsx rename to packages/shared/app/login/components/form.tsx diff --git a/apps/social/src/app/(authentication)/login/components/login-button.tsx b/packages/shared/app/login/components/login-button.tsx similarity index 100% rename from apps/social/src/app/(authentication)/login/components/login-button.tsx rename to packages/shared/app/login/components/login-button.tsx diff --git a/packages/shared/app/login/loading.tsx b/packages/shared/app/login/loading.tsx new file mode 100644 index 00000000..5ba9d85f --- /dev/null +++ b/packages/shared/app/login/loading.tsx @@ -0,0 +1,28 @@ +import { Skeleton } from "@umamin/ui/components/skeleton"; + +export default function Loading() { + return ( +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + +
+
+ ); +} diff --git a/packages/shared/app/login/page.tsx b/packages/shared/app/login/page.tsx new file mode 100644 index 00000000..537cd831 --- /dev/null +++ b/packages/shared/app/login/page.tsx @@ -0,0 +1,42 @@ +import Link from "next/link"; +import dynamic from "next/dynamic"; +import { getSession } from "@/lib/auth"; +import { redirect } from "next/navigation"; +import { LoginForm } from "./components/form"; + +const BrowserWarning = dynamic( + () => import("@umamin/ui/components/browser-warning"), + { ssr: false } +); + +export default async function Login() { + const { user } = await getSession(); + + if (user) { + redirect("/inbox"); + } + + return ( +
+ + +
+

+ Umamin Account +

+

+ Proceed with your Umamin v2.0 profile +

+
+ + + +
+ Don't have an account?{" "} + + Sign up + +
+
+ ); +} diff --git a/apps/social/src/app/(authentication)/register/components/button.tsx b/packages/shared/app/register/components/button.tsx similarity index 100% rename from apps/social/src/app/(authentication)/register/components/button.tsx rename to packages/shared/app/register/components/button.tsx diff --git a/apps/social/src/app/(authentication)/register/components/form.tsx b/packages/shared/app/register/components/form.tsx similarity index 100% rename from apps/social/src/app/(authentication)/register/components/form.tsx rename to packages/shared/app/register/components/form.tsx diff --git a/packages/shared/app/register/loading.tsx b/packages/shared/app/register/loading.tsx new file mode 100644 index 00000000..d623ffdd --- /dev/null +++ b/packages/shared/app/register/loading.tsx @@ -0,0 +1,44 @@ +import { Skeleton } from "@umamin/ui/components/skeleton"; + +export default function Loading() { + return ( +
+
+
+ + +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+
+ +
+
+
+ + +
+ + +
+ + + +
+
+ ); +} diff --git a/packages/shared/app/register/page.tsx b/packages/shared/app/register/page.tsx new file mode 100644 index 00000000..1313bb40 --- /dev/null +++ b/packages/shared/app/register/page.tsx @@ -0,0 +1,49 @@ +import Link from "next/link"; +import dynamic from "next/dynamic"; +import { getSession } from "@/lib/auth"; +import { redirect } from "next/navigation"; +import { RegisterForm } from "./components/form"; + +const BrowserWarning = dynamic( + () => import("@umamin/ui/components/browser-warning"), + { ssr: false } +); + +export default async function Register() { + const { user } = await getSession(); + + if (user) { + redirect("/inbox"); + } + + return ( +
+ + +
+

+ Umamin Account +

+

+ By creating an account, you agree to our{" "} + + Privacy Policy + {" "} + and{" "} + + Terms of Service + +

+
+ + + +
+ Already have an account?{" "} + + Login + +
+
+ ); +} diff --git a/packages/shared/lib/auth.ts b/packages/shared/lib/auth.ts new file mode 100644 index 00000000..05f347e7 --- /dev/null +++ b/packages/shared/lib/auth.ts @@ -0,0 +1,84 @@ +import { cache } from "react"; +import { Google } from "arctic"; +import { cookies } from "next/headers"; +import { Lucia, Session, User } from "lucia"; +import { DrizzleSQLiteAdapter } from "@lucia-auth/adapter-drizzle"; + +import { db } from "@umamin/db"; +import { + SelectUser, + user as userSchema, + session as sessionSchema, +} from "@umamin/db/schema/user"; + +export const google = new Google( + process.env.GOOGLE_CLIENT_ID!, + process.env.GOOGLE_CLIENT_SECRET!, + process.env.GOOGLE_REDIRECT_URI!, +); + +const adapter = new DrizzleSQLiteAdapter(db, sessionSchema, userSchema); + +export const lucia = new Lucia(adapter, { + sessionCookie: { + expires: false, + attributes: { + secure: process.env.NODE_ENV === "production", + }, + }, + getUserAttributes: (attributes) => { + return { + ...attributes, + }; + }, +}); + +export const getSession = cache( + async (): Promise< + { user: User; session: Session } | { user: null; session: null } + > => { + const sessionId = cookies().get(lucia.sessionCookieName)?.value ?? null; + + if (!sessionId) { + return { + user: null, + session: null, + }; + } + + const result = await lucia.validateSession(sessionId); + + try { + if (result.session && result.session.fresh) { + const sessionCookie = lucia.createSessionCookie(result.session.id); + cookies().set( + sessionCookie.name, + sessionCookie.value, + sessionCookie.attributes, + ); + } + + if (!result.session) { + const sessionCookie = lucia.createBlankSessionCookie(); + cookies().set( + sessionCookie.name, + sessionCookie.value, + sessionCookie.attributes, + ); + } + } catch (err) { + console.log(err); + } + return result; + }, +); + +declare module "lucia" { + // eslint-disable-next-line no-unused-vars + interface Register { + Lucia: typeof lucia; + DatabaseUserAttributes: DatabaseUserAttributes; + } +} + +interface DatabaseUserAttributes extends SelectUser {} diff --git a/packages/shared/next.config.mjs b/packages/shared/next.config.mjs new file mode 100644 index 00000000..4678774e --- /dev/null +++ b/packages/shared/next.config.mjs @@ -0,0 +1,4 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = {}; + +export default nextConfig; diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 00000000..90d6a4be --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,47 @@ +{ + "name": "@umamin/shared", + "version": "0.1.0", + "private": true, + "scripts": { + "lint": "next lint" + }, + "exports": { + "./app/*": "./app/*.tsx", + "./routes/*": "./routes/*.ts", + "./components": "./components/index.ts", + "./lib": "./utils/lib/*.ts", + "./actions": "./actions.ts" + }, + "peerDependencies": { + "react": "^18", + "react-dom": "^18", + "next": "14.2.5", + "gql.tada": "^1.8.5", + "graphql": "^16.9.0", + "@urql/core": "^5.0.5", + "@urql/next": "^1.1.1", + "urql": "^4.1.0" + }, + "dependencies": { + "@umamin/db": "workspace:*", + "@umamin/gql": "workspace:*", + "@umamin/ui": "workspace:*", + "@lucia-auth/adapter-drizzle": "^1.0.7", + "nanoid": "^5.0.7", + "zod": "^3.22.4", + "lucia": "^3.2.0", + "arctic": "^1.9.2", + "lucide-react": "^0.407.0", + "@node-rs/argon2": "^1.8.3" + }, + "devDependencies": { + "typescript": "^5", + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "postcss": "^8", + "tailwindcss": "^3.4.1", + "eslint": "^8", + "eslint-config-next": "14.2.6" + } +} diff --git a/packages/shared/postcss.config.cjs b/packages/shared/postcss.config.cjs new file mode 100644 index 00000000..a4917b21 --- /dev/null +++ b/packages/shared/postcss.config.cjs @@ -0,0 +1 @@ +module.exports = require("@umamin/ui/postcss.config"); diff --git a/packages/shared/routes/google/callback/route.ts b/packages/shared/routes/google/callback/route.ts new file mode 100644 index 00000000..5b346169 --- /dev/null +++ b/packages/shared/routes/google/callback/route.ts @@ -0,0 +1,158 @@ +import { nanoid } from "nanoid"; +import { generateId } from "lucia"; +import { cookies } from "next/headers"; +import { db, and, eq } from "@umamin/db"; +import { OAuth2RequestError } from "arctic"; +import { + user as userSchema, + account as accountSchema, +} from "@umamin/db/schema/user"; + +import { getSession, google, lucia } from "@/lib/auth"; + +export async function GET(request: Request): Promise { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + + const storedState = cookies().get("google_oauth_state")?.value ?? null; + const storedCodeVerifier = cookies().get("code_verifier")?.value ?? null; + + if ( + !code || + !state || + !storedState || + !storedCodeVerifier || + state !== storedState + ) { + return new Response(null, { + status: 400, + }); + } + + try { + const tokens = await google.validateAuthorizationCode( + code, + storedCodeVerifier + ); + + const googleUserResponse = await fetch( + "https://openidconnect.googleapis.com/v1/userinfo", + { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + } + ); + + const googleUser: GoogleUser = await googleUserResponse.json(); + + const { user } = await getSession(); + + const existingUser = await db.query.account.findFirst({ + where: and( + eq(accountSchema.providerId, "google"), + eq(accountSchema.providerUserId, googleUser.sub) + ), + }); + + if (user && existingUser) { + return new Response(null, { + status: 302, + headers: { + Location: "/settings?error=already_linked", + }, + }); + } else if (user) { + await db + .update(userSchema) + .set({ + imageUrl: googleUser.picture, + }) + .where(eq(userSchema.id, user.id)); + + await db.insert(accountSchema).values({ + providerId: "google", + providerUserId: googleUser.sub, + userId: user.id, + picture: googleUser.picture, + email: googleUser.email, + }); + + return new Response(null, { + status: 302, + headers: { + Location: "/settings", + }, + }); + } + + if (existingUser) { + const session = await lucia.createSession(existingUser.userId, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + + cookies().set( + sessionCookie.name, + sessionCookie.value, + sessionCookie.attributes + ); + + return new Response(null, { + status: 302, + headers: { + Location: "/login", + }, + }); + } + + const usernameId = generateId(5); + const userId = nanoid(); + + await db.insert(userSchema).values({ + id: userId, + imageUrl: googleUser.picture, + username: `umamin_${usernameId}`, + }); + + await db.insert(accountSchema).values({ + providerId: "google", + providerUserId: googleUser.sub, + userId, + picture: googleUser.picture, + email: googleUser.email, + }); + + const session = await lucia.createSession(userId, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + + cookies().set( + sessionCookie.name, + sessionCookie.value, + sessionCookie.attributes + ); + + return new Response(null, { + status: 302, + headers: { + Location: "/login", + }, + }); + } catch (err: any) { + console.log(err); + if (err instanceof OAuth2RequestError) { + return new Response(null, { + status: 400, + }); + } + + return new Response(null, { + status: 500, + }); + } +} + +interface GoogleUser { + sub: string; + picture: string; + email: string; +} diff --git a/packages/shared/routes/google/route.ts b/packages/shared/routes/google/route.ts new file mode 100644 index 00000000..88f65bc7 --- /dev/null +++ b/packages/shared/routes/google/route.ts @@ -0,0 +1,31 @@ +import { generateState, generateCodeVerifier } from "arctic"; +import { cookies } from "next/headers"; +import { google } from "@/lib/auth"; + +export async function GET(): Promise { + const state = generateState(); + const codeVerifier = generateCodeVerifier(); + const url = await google.createAuthorizationURL(state, codeVerifier, { + scopes: ["profile", "email"], + }); + + url.searchParams.set("access_type", "offline"); + + cookies().set("google_oauth_state", state, { + path: "/", + secure: process.env.NODE_ENV === "production", + httpOnly: true, + maxAge: 60 * 10, + sameSite: "lax", + }); + + cookies().set("code_verifier", codeVerifier, { + path: "/", + secure: process.env.NODE_ENV === "production", + httpOnly: true, + maxAge: 60 * 10, + sameSite: "lax", + }); + + return Response.redirect(url); +} diff --git a/packages/shared/tailwind.config.ts b/packages/shared/tailwind.config.ts new file mode 100644 index 00000000..544d0407 --- /dev/null +++ b/packages/shared/tailwind.config.ts @@ -0,0 +1 @@ +export * from "@umamin/ui/tailwind.config"; diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 00000000..e7ff90fd --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/ui/postcss.config.js b/packages/ui/postcss.config.js index c21c0763..12a703d9 100644 --- a/packages/ui/postcss.config.js +++ b/packages/ui/postcss.config.js @@ -4,4 +4,3 @@ module.exports = { autoprefixer: {}, }, }; - diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a04796e..4e84e4e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -189,6 +189,9 @@ importers: '@umamin/gql': specifier: workspace:* version: link:../../packages/gql + '@umamin/shared': + specifier: workspace:* + version: link:../../packages/shared '@umamin/ui': specifier: workspace:* version: link:../../packages/ui @@ -551,7 +554,7 @@ importers: version: 7.18.0(eslint@8.57.0)(typescript@5.5.4) '@vercel/style-guide': specifier: ^5.2.0 - version: 5.2.0(@next/eslint-plugin-next@14.2.5)(eslint@8.57.0)(prettier@3.3.3)(typescript@5.5.4) + version: 5.2.0(@next/eslint-plugin-next@14.2.6)(eslint@8.57.0)(prettier@3.3.3)(typescript@5.5.4) eslint-config-prettier: specifier: ^9.1.0 version: 9.1.0(eslint@8.57.0) @@ -617,6 +620,88 @@ importers: specifier: ^5.5.4 version: 5.5.4 + packages/shared: + dependencies: + '@lucia-auth/adapter-drizzle': + specifier: ^1.0.7 + version: 1.0.7(lucia@3.2.0) + '@node-rs/argon2': + specifier: ^1.8.3 + version: 1.8.3 + '@umamin/db': + specifier: workspace:* + version: link:../db + '@umamin/gql': + specifier: workspace:* + version: link:../gql + '@umamin/ui': + specifier: workspace:* + version: link:../ui + '@urql/core': + specifier: ^5.0.5 + version: 5.0.5(graphql@16.9.0) + '@urql/next': + specifier: ^1.1.1 + version: 1.1.1(next@14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(urql@4.1.0(@urql/core@5.0.5(graphql@16.9.0))(react@18.3.1)) + arctic: + specifier: ^1.9.2 + version: 1.9.2 + gql.tada: + specifier: ^1.8.5 + version: 1.8.5(graphql@16.9.0)(typescript@5.5.4) + graphql: + specifier: ^16.9.0 + version: 16.9.0 + lucia: + specifier: ^3.2.0 + version: 3.2.0 + lucide-react: + specifier: ^0.407.0 + version: 0.407.0(react@18.3.1) + nanoid: + specifier: ^5.0.7 + version: 5.0.7 + next: + specifier: 14.2.5 + version: 14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: + specifier: ^18 + version: 18.3.1 + react-dom: + specifier: ^18 + version: 18.3.1(react@18.3.1) + urql: + specifier: ^4.1.0 + version: 4.1.0(@urql/core@5.0.5(graphql@16.9.0))(react@18.3.1) + zod: + specifier: ^3.22.4 + version: 3.23.8 + devDependencies: + '@types/node': + specifier: ^20 + version: 20.14.13 + '@types/react': + specifier: ^18 + version: 18.3.3 + '@types/react-dom': + specifier: ^18 + version: 18.3.0 + eslint: + specifier: ^8 + version: 8.57.0 + eslint-config-next: + specifier: 14.2.6 + version: 14.2.6(eslint@8.57.0)(typescript@5.5.4) + postcss: + specifier: ^8 + version: 8.4.40 + tailwindcss: + specifier: ^3.4.1 + version: 3.4.7 + typescript: + specifier: ^5 + version: 5.5.4 + packages/tsconfig: {} packages/ui: @@ -1599,6 +1684,9 @@ packages: '@next/eslint-plugin-next@14.2.5': resolution: {integrity: sha512-LY3btOpPh+OTIpviNojDpUdIbHW9j0JBYBjsIp8IxtDFfYFyORvw3yNq6N231FVqQA7n7lwaf7xHbVJlA1ED7g==} + '@next/eslint-plugin-next@14.2.6': + resolution: {integrity: sha512-d3+p4AjIYmhqzYHhhmkRYYN6ZU35TwZAKX08xKRfnHkz72KhWL2kxMFsDptpZs5e8bBGdepn7vn1+9DaF8iX+A==} + '@next/mdx@14.2.5': resolution: {integrity: sha512-AROhSdXQg0/jt55iqxVSJqp9oaCyXwRe44/I17c77gDshZ6ex7VKBZDH0GljaxZ0Y4mScYUbFJJEh42Xw4X4Dg==} peerDependencies: @@ -3705,6 +3793,15 @@ packages: typescript: optional: true + eslint-config-next@14.2.6: + resolution: {integrity: sha512-z0URA5LO6y8lS/YLN0EDW/C4LEkDODjJzA37dvLVdzCPzuewjzTe1os5g3XclZAZrQ8X8hPaSMQ2JuVWwMmrTA==} + peerDependencies: + eslint: ^7.23.0 || ^8.0.0 + typescript: '>=3.3.1' + peerDependenciesMeta: + typescript: + optional: true + eslint-config-prettier@9.1.0: resolution: {integrity: sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==} hasBin: true @@ -6863,6 +6960,10 @@ snapshots: dependencies: glob: 10.3.10 + '@next/eslint-plugin-next@14.2.6': + dependencies: + glob: 10.3.10 + '@next/mdx@14.2.5(@mdx-js/loader@3.0.1(webpack@5.93.0))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))': dependencies: source-map: 0.7.4 @@ -8389,7 +8490,7 @@ snapshots: react: 18.3.1 urql: 4.1.0(@urql/core@5.0.5(graphql@16.9.0))(react@18.3.1) - '@vercel/style-guide@5.2.0(@next/eslint-plugin-next@14.2.5)(eslint@8.57.0)(prettier@3.3.3)(typescript@5.5.4)': + '@vercel/style-guide@5.2.0(@next/eslint-plugin-next@14.2.6)(eslint@8.57.0)(prettier@3.3.3)(typescript@5.5.4)': dependencies: '@babel/core': 7.25.2 '@babel/eslint-parser': 7.25.1(@babel/core@7.25.2)(eslint@8.57.0) @@ -8411,7 +8512,7 @@ snapshots: eslint-plugin-unicorn: 48.0.1(eslint@8.57.0) prettier-plugin-packagejson: 2.5.1(prettier@3.3.3) optionalDependencies: - '@next/eslint-plugin-next': 14.2.5 + '@next/eslint-plugin-next': 14.2.6 eslint: 8.57.0 prettier: 3.3.3 typescript: 5.5.4 @@ -9165,6 +9266,24 @@ snapshots: - eslint-import-resolver-webpack - supports-color + eslint-config-next@14.2.6(eslint@8.57.0)(typescript@5.5.4): + dependencies: + '@next/eslint-plugin-next': 14.2.6 + '@rushstack/eslint-patch': 1.10.4 + '@typescript-eslint/parser': 7.2.0(eslint@8.57.0)(typescript@5.5.4) + eslint: 8.57.0 + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) + eslint-plugin-jsx-a11y: 6.9.0(eslint@8.57.0) + eslint-plugin-react: 7.35.0(eslint@8.57.0) + eslint-plugin-react-hooks: 4.6.2(eslint@8.57.0) + optionalDependencies: + typescript: 5.5.4 + transitivePeerDependencies: + - eslint-import-resolver-webpack + - supports-color + eslint-config-prettier@9.1.0(eslint@8.57.0): dependencies: eslint: 8.57.0