diff --git a/apps/partners/next.config.mjs b/apps/partners/next.config.mjs index f46c0848..09958f20 100644 --- a/apps/partners/next.config.mjs +++ b/apps/partners/next.config.mjs @@ -1,6 +1,9 @@ /** @type {import('next').NextConfig} */ const nextConfig = { transpilePackages: ["@umamin/ui", "@umamin/db", "@umamin/gql"], + experimental: { + serverComponentsExternalPackages: ["@node-rs/argon2"], + }, compiler: { removeConsole: process.env.NODE_ENV === "production", }, diff --git a/apps/partners/package.json b/apps/partners/package.json index 44a5d8fe..a118ac7d 100644 --- a/apps/partners/package.json +++ b/apps/partners/package.json @@ -14,45 +14,46 @@ "@graphql-yoga/plugin-persisted-operations": "^3.6.2", "@graphql-yoga/plugin-response-cache": "^3.8.2", "@lucia-auth/adapter-drizzle": "^1.0.7", + "@node-rs/argon2": "^1.8.3", + "@umamin/db": "workspace:*", + "@umamin/gql": "workspace:*", + "@umamin/ui": "workspace:*", "@urql/core": "^5.0.5", "@urql/exchange-graphcache": "^7.1.1", "@urql/exchange-persisted": "^4.3.0", "@urql/next": "^1.1.1", + "@whatwg-node/server": "^0.9.46", + "arctic": "^1.9.2", "date-fns": "^3.6.0", "geist": "^1.3.1", - "arctic": "^1.9.2", "gql.tada": "^1.8.5", "graphql": "^16.9.0", "graphql-yoga": "^5.6.2", "lucia": "^3.2.0", "lucide-react": "^0.407.0", "modern-screenshot": "^4.4.39", - "react-intersection-observer": "^9.10.2", - "urql": "^4.1.0", - "zod": "^3.22.4", - "sonner": "^1.5.0", "nanoid": "^5.0.7", + "next": "14.2.5", "nextjs-toploader": "^1.6.12", - "@whatwg-node/server": "^0.9.46", - "@umamin/db": "workspace:*", - "@umamin/gql": "workspace:*", - "@umamin/ui": "workspace:*", "react": "^18", "react-dom": "^18", - "next": "14.2.5" + "react-intersection-observer": "^9.10.2", + "sonner": "^1.5.0", + "urql": "^4.1.0", + "zod": "^3.22.4" }, "devDependencies": { "@0no-co/graphqlsp": "^1.12.12", - "@umamin/eslint-config": "workspace:*", - "@umamin/tsconfig": "workspace:*", - "typescript": "^5", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "@umamin/eslint-config": "workspace:*", + "@umamin/tsconfig": "workspace:*", "autoprefixer": "^10.0.1", + "eslint": "^8", + "eslint-config-next": "14.2.5", "postcss": "^8", "tailwindcss": "^3.4.1", - "eslint": "^8", - "eslint-config-next": "14.2.5" + "typescript": "^5" } } diff --git a/apps/partners/src/app/dashboard/components/navbar.tsx b/apps/partners/src/app/dashboard/components/navbar.tsx new file mode 100644 index 00000000..3dccf48e --- /dev/null +++ b/apps/partners/src/app/dashboard/components/navbar.tsx @@ -0,0 +1,20 @@ +import Link from "next/link"; +import { Badge } from "@umamin/ui/components/badge"; +import { SignOutButton } from "./sign-out-btn"; + +export async function Navbar() { + return ( + + ); +} diff --git a/apps/partners/src/app/dashboard/components/sign-out-btn.tsx b/apps/partners/src/app/dashboard/components/sign-out-btn.tsx new file mode 100644 index 00000000..d34486a3 --- /dev/null +++ b/apps/partners/src/app/dashboard/components/sign-out-btn.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { Loader2 } from "lucide-react"; +import { useFormStatus } from "react-dom"; +import { Button } from "@umamin/ui/components/button"; + +export function SignOutButton() { + const { pending } = useFormStatus(); + + return ( + + ); +} diff --git a/apps/partners/src/app/dashboard/layout.tsx b/apps/partners/src/app/dashboard/layout.tsx new file mode 100644 index 00000000..ee6b22b9 --- /dev/null +++ b/apps/partners/src/app/dashboard/layout.tsx @@ -0,0 +1,14 @@ +import { Navbar } from "./components/navbar"; + +export default function Layout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( +
+ + {children} +
+ ); +} diff --git a/apps/partners/src/app/dashboard/page.tsx b/apps/partners/src/app/dashboard/page.tsx new file mode 100644 index 00000000..c8c14490 --- /dev/null +++ b/apps/partners/src/app/dashboard/page.tsx @@ -0,0 +1,10 @@ +import { getSession } from "@/lib/auth"; + +export default async function Dashboard() { + const { user } = await getSession(); + return ( +
+

Hello, {user?.displayName || user?.username}

+
+ ); +} diff --git a/apps/partners/src/app/login/components/form.tsx b/apps/partners/src/app/login/components/form.tsx new file mode 100644 index 00000000..d89b3be3 --- /dev/null +++ b/apps/partners/src/app/login/components/form.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { login } from "@/lib/actions"; +import { useFormState } from "react-dom"; + +import { LoginButton } from "./login-button"; +import { Input } from "@umamin/ui/components/input"; +import { Label } from "@umamin/ui/components/label"; + +export function LoginForm() { + const [state, formAction] = useFormState(login, { error: "" }); + + return ( +
+
+ + +
+ +
+ + + {!!state?.error && ( +

{state.error}

+ )} +
+ + + + ); +} diff --git a/apps/partners/src/app/login/components/login-button.tsx b/apps/partners/src/app/login/components/login-button.tsx new file mode 100644 index 00000000..1ca484aa --- /dev/null +++ b/apps/partners/src/app/login/components/login-button.tsx @@ -0,0 +1,25 @@ +"use client"; + +import Link from "next/link"; +import { Loader2 } from "lucide-react"; +import { useFormStatus } from "react-dom"; +import { Button } from "@umamin/ui/components/button"; + +export function LoginButton() { + const { pending } = useFormStatus(); + + return ( +
+ + + +
+ ); +} diff --git a/apps/partners/src/app/login/google/callback/route.ts b/apps/partners/src/app/login/google/callback/route.ts new file mode 100644 index 00000000..261b8560 --- /dev/null +++ b/apps/partners/src/app/login/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/apps/partners/src/app/login/google/route.ts b/apps/partners/src/app/login/google/route.ts new file mode 100644 index 00000000..88f65bc7 --- /dev/null +++ b/apps/partners/src/app/login/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/apps/partners/src/app/login/loading.tsx b/apps/partners/src/app/login/loading.tsx new file mode 100644 index 00000000..5ba9d85f --- /dev/null +++ b/apps/partners/src/app/login/loading.tsx @@ -0,0 +1,28 @@ +import { Skeleton } from "@umamin/ui/components/skeleton"; + +export default function Loading() { + return ( +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + +
+
+ ); +} diff --git a/apps/partners/src/app/login/page.tsx b/apps/partners/src/app/login/page.tsx new file mode 100644 index 00000000..b5759e10 --- /dev/null +++ b/apps/partners/src/app/login/page.tsx @@ -0,0 +1,70 @@ +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 Partners — Login", + description: + "Log in to Umamin Partners to manage anonymous feedback, surveys, and communications securely. Enhance your workflow with powerful tools and maintain privacy at every step.", + keywords: [ + "Umamin login", + "anonymous messaging login", + "encrypted messages login", + ], + robots: { + index: false, + follow: false, + }, + openGraph: { + type: "website", + title: "Umamin Partners — Login", + description: + "Log in to Umamin Partners to manage anonymous feedback, surveys, and communications securely. Enhance your workflow with powerful tools and maintain privacy at every step.", + url: "https://partners.umamin.link/login", + }, + twitter: { + card: "summary_large_image", + title: "Umamin Partners — Login", + description: + "Log in to Umamin Partners to manage anonymous feedback, surveys, and communications securely. Enhance your workflow with powerful tools and maintain privacy at every step.", + }, +}; + +export default async function Login() { + const { user } = await getSession(); + + if (user) { + redirect("/dashboard"); + } + + return ( +
+ + +
+

+ Umamin Account +

+

+ Proceed with your Umamin v2.0 profile +

+
+ + + +
+ Don't have an account?{" "} + + Sign up + +
+
+ ); +} diff --git a/apps/partners/src/lib/actions.ts b/apps/partners/src/lib/actions.ts new file mode 100644 index 00000000..e4c580ec --- /dev/null +++ b/apps/partners/src/lib/actions.ts @@ -0,0 +1,134 @@ +"use server"; + +import { db, eq } from "@umamin/db"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { hash, verify } from "@node-rs/argon2"; +import { getSession, lucia } from "@/lib/auth"; +import { user as userSchema } from "@umamin/db/schema/user"; + +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"); +} + +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"); +} + +interface ActionResult { + error: string | null; +} diff --git a/apps/partners/src/lib/auth.ts b/apps/partners/src/lib/auth.ts new file mode 100644 index 00000000..05f347e7 --- /dev/null +++ b/apps/partners/src/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/pnpm-lock.yaml b/pnpm-lock.yaml index f7ad650c..8a04796e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@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:../../packages/db @@ -4451,7 +4454,6 @@ packages: libsql@0.3.19: resolution: {integrity: sha512-Aj5cQ5uk/6fHdmeW0TiXK42FqUlwx7ytmMLPSaUQPin5HKKKuUPD62MAbN4OEweGBBI7q1BekoEN4gPUEL6MZA==} - cpu: [x64, arm64, wasm32] os: [darwin, linux, win32] lilconfig@2.1.0: @@ -9153,7 +9155,7 @@ snapshots: 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)(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) @@ -9207,7 +9209,7 @@ snapshots: enhanced-resolve: 5.17.1 eslint: 8.57.0 eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.4))(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@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)(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) fast-glob: 3.3.2 get-tsconfig: 4.7.6 is-core-module: 2.15.0 @@ -9282,7 +9284,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - 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)(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): dependencies: array-includes: 3.1.8 array.prototype.findlastindex: 1.2.5