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 (
+
+ );
+}
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