From 8396a96f1f4cbc42cd47d73e62281f21a7f5c5eb Mon Sep 17 00:00:00 2001 From: Dale B Date: Fri, 23 Aug 2024 15:38:48 +0800 Subject: [PATCH] refactor: move settings to ui package --- apps/social/src/app/components/navbar.tsx | 13 +- apps/social/src/app/settings/page.tsx | 15 + apps/social/src/app/settings/queries.ts | 39 +++ packages/ui/components.json | 2 +- packages/ui/package.json | 27 +- packages/ui/src/app/actions.ts | 259 ++++++++++++++++++ .../settings/components/account-form.tsx | 176 ++++++++++++ .../settings/components/account.tsx | 93 +++++++ .../components/settings/components/danger.tsx | 70 +++++ .../settings/components/delete-button.tsx | 21 ++ .../settings/components/general.tsx | 215 +++++++++++++++ .../settings/components/privacy.tsx | 128 +++++++++ .../settings/components/sign-out-button.tsx | 21 ++ .../ui/src/components/settings/loading.tsx | 44 +++ packages/ui/src/components/settings/page.tsx | 103 +++++++ .../ui/src/components/settings/queries.ts | 39 +++ packages/ui/src/graphql-env.d.ts | 51 ++++ packages/ui/src/lib/auth.ts | 84 ++++++ packages/ui/src/lib/firebase.ts | 22 ++ packages/ui/src/lib/gql/client.ts | 17 ++ packages/ui/src/lib/gql/rsc.ts | 32 +++ packages/ui/src/lib/utils.ts | 47 +++- packages/ui/src/schema.graphql | 130 +++++++++ packages/ui/tsconfig.json | 16 +- pnpm-lock.yaml | 55 +++- 25 files changed, 1703 insertions(+), 16 deletions(-) create mode 100644 apps/social/src/app/settings/page.tsx create mode 100644 apps/social/src/app/settings/queries.ts create mode 100644 packages/ui/src/app/actions.ts create mode 100644 packages/ui/src/components/settings/components/account-form.tsx create mode 100644 packages/ui/src/components/settings/components/account.tsx create mode 100644 packages/ui/src/components/settings/components/danger.tsx create mode 100644 packages/ui/src/components/settings/components/delete-button.tsx create mode 100644 packages/ui/src/components/settings/components/general.tsx create mode 100644 packages/ui/src/components/settings/components/privacy.tsx create mode 100644 packages/ui/src/components/settings/components/sign-out-button.tsx create mode 100644 packages/ui/src/components/settings/loading.tsx create mode 100644 packages/ui/src/components/settings/page.tsx create mode 100644 packages/ui/src/components/settings/queries.ts create mode 100644 packages/ui/src/graphql-env.d.ts create mode 100644 packages/ui/src/lib/auth.ts create mode 100644 packages/ui/src/lib/firebase.ts create mode 100644 packages/ui/src/lib/gql/client.ts create mode 100644 packages/ui/src/lib/gql/rsc.ts create mode 100644 packages/ui/src/schema.graphql diff --git a/apps/social/src/app/components/navbar.tsx b/apps/social/src/app/components/navbar.tsx index 0cc8b52f..50e53944 100644 --- a/apps/social/src/app/components/navbar.tsx +++ b/apps/social/src/app/components/navbar.tsx @@ -4,20 +4,21 @@ import { logout } from "@/actions"; import logo from "/public/logo.svg"; import { getSession } from "@/lib/auth"; import { SignOutButton } from "./sign-out-btn"; + import { ArrowLeft, + ScanFace, LinkIcon, LogIn, - MessagesSquare, - ScanFace, - ScrollText, UserCog, + ScrollText, + MessagesSquare, } from "lucide-react"; import { Avatar, - AvatarFallback, AvatarImage, + AvatarFallback, } from "@umamin/ui/components/avatar"; import { @@ -66,6 +67,10 @@ export async function Navbar() { Profile + + Settings + + {session ? (
diff --git a/apps/social/src/app/settings/page.tsx b/apps/social/src/app/settings/page.tsx new file mode 100644 index 00000000..5b0beb93 --- /dev/null +++ b/apps/social/src/app/settings/page.tsx @@ -0,0 +1,15 @@ +import { getSession } from "@/lib/auth"; +import Settings from "@umamin/ui/app/settings/page"; +import { CurrentUserResult, getCurrentUser } from "./queries"; +import { redirect } from "next/navigation"; + +export default async function Page() { + const { user, session } = await getSession(); + const userData = await getCurrentUser(session?.id); + + if (!user) { + redirect("/login"); + } + + return ; +} diff --git a/apps/social/src/app/settings/queries.ts b/apps/social/src/app/settings/queries.ts new file mode 100644 index 00000000..3e8e9543 --- /dev/null +++ b/apps/social/src/app/settings/queries.ts @@ -0,0 +1,39 @@ +import { cache } from "react"; +import getClient from "@/lib/gql/rsc"; +import { graphql, ResultOf } from "gql.tada"; + +export const CURRENT_USER_QUERY = graphql(` + query CurrentUser { + user { + __typename + id + bio + username + displayName + question + quietMode + imageUrl + createdAt + accounts { + __typename + id + email + picture + createdAt + } + } + } +`); + +const currentUserPersisted = graphql.persisted( + "3f2320bbe96bd7895f618b6cdedfdee5d2f40e3e0c1d75095ea0844a0ff107b4", + CURRENT_USER_QUERY +); + +export const getCurrentUser = cache(async (sessionId?: string) => { + const result = await getClient(sessionId).query(currentUserPersisted, {}); + + return result?.data?.user; +}); + +export type CurrentUserResult = ResultOf["user"]; diff --git a/packages/ui/components.json b/packages/ui/components.json index 4c168719..b249d208 100644 --- a/packages/ui/components.json +++ b/packages/ui/components.json @@ -11,7 +11,7 @@ "prefix": "" }, "aliases": { - "components": "@/components", + "components": "../../../components", "utils": "@/lib/utils" } } diff --git a/packages/ui/package.json b/packages/ui/package.json index 5ffacf59..cea6dfa1 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -8,28 +8,47 @@ "./tailwind.config": "./tailwind.config.ts", "./lib/*": "./src/lib/*.ts", "./components/*": "./src/components/ui/*.tsx", - "./ad": "./src/components/ad-container.tsx" + "./ad": "./src/components/ad-container.tsx", + "./app/*": "./src/components/*.tsx" }, "scripts": { "ui:add": "pnpm dlx shadcn-ui@latest add", "clean": "rm -rf ./node_modules .turbo", "lint": "eslint . --max-warnings 0", - "check-types": "tsc --noEmit" + "check-types": "tsc --noEmit", + "gql:generate-persisted": "gql.tada generate-persisted", + "gql:generate-schema": "gql.tada generate-schema http://localhost:3000/api/graphql" }, "devDependencies": { + "@0no-co/graphqlsp": "^1.12.12", + "@lucia-auth/adapter-drizzle": "^1.0.7", + "@node-rs/argon2": "^1.8.3", "@tailwindcss/typography": "^0.5.13", "@types/eslint": "^8.56.5", "@types/node": "^20.11.24", "@types/react": "^18.3.2", "@types/react-dom": "^18.3.0", + "@umamin/aes": "workspace:*", + "@umamin/db": "workspace:*", "@umamin/eslint-config": "workspace:*", + "@umamin/gql": "workspace:*", "@umamin/tsconfig": "workspace:*", + "@urql/core": "^5.0.5", + "@urql/exchange-persisted": "^4.3.0", + "@urql/next": "^1.1.1", + "arctic": "^1.9.2", "autoprefixer": "^10.4.19", + "date-fns": "^3.6.0", "eslint": "^8.57.0", + "firebase": "^10.12.4", + "gql.tada": "^1.8.5", + "lucia": "^3.2.0", + "nanoid": "^5.0.7", + "next": "14.2.5", "postcss": "^8.4.40", "react": "^18.3.1", - "typescript": "^5.5.4", - "tailwindcss": "^3.4.7" + "tailwindcss": "^3.4.7", + "typescript": "^5.5.4" }, "dependencies": { "@hookform/resolvers": "^3.9.0", diff --git a/packages/ui/src/app/actions.ts b/packages/ui/src/app/actions.ts new file mode 100644 index 00000000..6dcaf759 --- /dev/null +++ b/packages/ui/src/app/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/packages/ui/src/components/settings/components/account-form.tsx b/packages/ui/src/components/settings/components/account-form.tsx new file mode 100644 index 00000000..bc445c61 --- /dev/null +++ b/packages/ui/src/components/settings/components/account-form.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { z } from "zod"; +import { toast } from "sonner"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { analytics } from "../../../lib/firebase"; +import { logEvent } from "firebase/analytics"; +import { Loader2, CircleX } from "lucide-react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useRouter, useSearchParams } from "next/navigation"; + +import { Button } from "../../../components/ui/button"; +import { Input } from "../../../components/ui/input"; + +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "../../../components/ui/form"; +import { updatePassword } from "../../../app/actions"; + +const FormSchema = z + .object({ + currentPassword: z.string().max(255, { message: "Invalid password" }), + 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 function AccountForm({ pwdHash }: { pwdHash?: string | null }) { + const router = useRouter(); + const searchParams = useSearchParams(); + const [saving, setSaving] = useState(false); + + const form = useForm>({ + resolver: zodResolver(FormSchema), + defaultValues: { + currentPassword: "", + password: "", + confirmPassword: "", + }, + }); + + async function onSubmit(data: z.infer) { + setSaving(true); + + const res = await updatePassword({ + currentPassword: data.currentPassword, + password: data.password, + }); + + if (res?.error) { + toast.error(res.error); + setSaving(false); + return; + } + + setSaving(false); + + toast.success("Password updated successfully"); + form.reset(); + router.refresh(); + logEvent(analytics, "update_password"); + } + + return ( + <> + {searchParams.get("error") === "already_linked" && ( +
+ + Account already linked to a different profile. +
+ )} + + + + {pwdHash && ( + ( + + Current Password + + + + + + )} + /> + )} + + ( + + New Password + + + + + + )} + /> + + ( + + Confirm New Password + + + + + + )} + /> + +
+ +
+ + + + ); +} diff --git a/packages/ui/src/components/settings/components/account.tsx b/packages/ui/src/components/settings/components/account.tsx new file mode 100644 index 00000000..a901db21 --- /dev/null +++ b/packages/ui/src/components/settings/components/account.tsx @@ -0,0 +1,93 @@ +import Link from "next/link"; +import { ScanFace, ShieldAlert } from "lucide-react"; +import { formatDistanceToNow, fromUnixTime } from "date-fns"; + +import { + Alert, + AlertDescription, + AlertTitle, +} from "../../../components/ui/alert"; + +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "../../../components/ui/avatar"; +import { DangerSettings } from "./danger"; +import { AccountForm } from "./account-form"; +import { CurrentUserResult } from "../queries"; +import { Label } from "../../../components/ui/label"; +import { Button } from "../../../components/ui/button"; +import { Card, CardHeader } from "../../../components/ui/card"; + +export function AccountSettings({ + user, + pwdHash, +}: { + user: CurrentUserResult; + pwdHash?: string | null; +}) { + const account = user?.accounts?.length ? user.accounts[0] : null; + + return ( +
+ {!!account && ( + <> + + + + + + + + + + +
+

{account.email}

+ +

+ Linked{" "} + {formatDistanceToNow(fromUnixTime(account.createdAt), { + addSuffix: true, + })} +

+
+
+
+ + )} + + {!pwdHash && ( + + + Password (optional) + + You can have another way of logging in by adding a password. + + + )} + + {!account && ( + + + Link Account + + To prevent account loss, you may connect your account with Google. + You can still login with your credentials. + + + + )} + + + +
+ ); +} diff --git a/packages/ui/src/components/settings/components/danger.tsx b/packages/ui/src/components/settings/components/danger.tsx new file mode 100644 index 00000000..25e8cfef --- /dev/null +++ b/packages/ui/src/components/settings/components/danger.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useState } from "react"; +import { UserRoundX } from "lucide-react"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "../../../components/ui/dialog"; +import { Input } from "../../../components/ui/input"; + +import { + Alert, + AlertDescription, + AlertTitle, +} from "../../../components/ui/alert"; +import { deleteAccount } from "../../../app/actions"; +import { DeleteButton } from "./delete-button"; +import { Button } from "../../../components/ui/button"; + +export function DangerSettings() { + const [confirmText, setConfirmText] = useState(""); + return ( +
+ + + Danger Zone + + This action will permanently delete your profile and messages. All of + your data will be removed from our servers forever. + + + + + + + + + Are you absolutely sure? + + This will permanently delete your account and remove your data + from our servers. Type{" "} + delete my account to + confirm. + + + +
+ setConfirmText(e.target.value)} + placeholder="Enter confirmation text" + /> + + +
+
+
+
+ ); +} diff --git a/packages/ui/src/components/settings/components/delete-button.tsx b/packages/ui/src/components/settings/components/delete-button.tsx new file mode 100644 index 00000000..3f55742b --- /dev/null +++ b/packages/ui/src/components/settings/components/delete-button.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { Button } from "../../../components/ui/button"; +import { Loader2 } from "lucide-react"; +import { useFormStatus } from "react-dom"; + +export function DeleteButton({ confirmText }: { confirmText: string }) { + const { pending } = useFormStatus(); + + return ( + + ); +} diff --git a/packages/ui/src/components/settings/components/general.tsx b/packages/ui/src/components/settings/components/general.tsx new file mode 100644 index 00000000..bf9ce958 --- /dev/null +++ b/packages/ui/src/components/settings/components/general.tsx @@ -0,0 +1,215 @@ +"use client"; + +import { z } from "zod"; +import { toast } from "sonner"; +import { useState } from "react"; +import { graphql } from "gql.tada"; +import { useForm } from "react-hook-form"; +import { analytics } from "../../../lib/firebase"; +import { useRouter } from "next/navigation"; +import { logEvent } from "firebase/analytics"; +import { Info, Loader2 } from "lucide-react"; +import { zodResolver } from "@hookform/resolvers/zod"; + +import client from "@/lib/gql/client"; +import { formatError } from "../../../lib/utils"; +import { Input } from "../../../components/ui/input"; +import { Button } from "../../../components/ui/button"; +import { Textarea } from "../../../components/ui/textarea"; + +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "../../../components/ui/form"; +import { CurrentUserResult } from "../queries"; + +const UPDATE_USER_MUTATION = graphql(` + mutation UpdateUser($input: UpdateUserInput!) { + updateUser(input: $input) + } +`); + +const updateUserPersisted = graphql.persisted( + "0eb5223468cd7923b5d9a12fc2104425d047f9d34a871160b2e8d37bfc9224fc", + UPDATE_USER_MUTATION +); + +const FormSchema = z.object({ + question: z + .string() + .min(1, { + message: "Custom message must be at least 1 character.", + }) + .max(150, { + message: "Custom message must not be longer than 150 characters.", + }), + bio: z.string().max(150, { + message: "Bio must not be longer than 150 characters.", + }), + displayName: z.string().max(20, { + message: "Display name must not exceed 20 characters.", + }), + 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.", + }), +}); + +export function GeneralSettings({ user }: { user: CurrentUserResult }) { + const router = useRouter(); + const [saving, setSaving] = useState(false); + const account = user?.accounts?.length ? user.accounts[0] : null; + + const form = useForm>({ + resolver: zodResolver(FormSchema), + defaultValues: { + bio: user?.bio ?? "", + question: user?.question, + username: user?.username, + displayName: user?.displayName ?? "", + }, + }); + + async function onSubmit(data: z.infer) { + if ( + user?.username === data.username && + user?.bio === data.bio && + user?.question === data.question && + user?.displayName === data.displayName + ) { + toast.info("No changes detected"); + return; + } + + setSaving(true); + + const res = await client.mutation(updateUserPersisted, { + input: { + ...data, + username: data.username.toLowerCase(), + }, + }); + + if (res.error) { + setSaving(false); + toast.error(formatError(res.error.message)); + return; + } + + setSaving(false); + router.refresh(); + toast.success("Details updated"); + logEvent(analytics, "update_details"); + } + + return ( +
+ + ( + + Display Name + + + + + + )} + /> + + ( + + Username + + + + {account ? ( + + Your previous username will be available to other users. + + ) : ( + + + Google account required + + )} + + + )} + /> + + ( + + Custom Message + +