diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 9a5731b2..65c2d8e7 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -14,6 +14,10 @@ const config = { /** We already do linting and typechecking as separate tasks in CI */ eslint: { ignoreDuringBuilds: true }, typescript: { ignoreBuildErrors: true }, + + images: { + domains: [process.env.NEXT_PUBLIC_SUPABASE_STORAGE_URL ?? ""], + }, }; export default config; diff --git a/apps/web/src/components/Dashboard.tsx b/apps/web/src/components/Dashboard.tsx index 87d180e3..8533c1f2 100644 --- a/apps/web/src/components/Dashboard.tsx +++ b/apps/web/src/components/Dashboard.tsx @@ -5,7 +5,7 @@ import FeedbackButton from "./FeedbackButton"; import SideNavigation from "./SideNavigation"; export default function Dashboard(props: { children: React.ReactNode }) { - const { data, isLoading } = api.auth.getUser.useQuery(); + const { data, isLoading } = api.user.getUser.useQuery(); return ( <> @@ -28,7 +28,10 @@ export default function Dashboard(props: { children: React.ReactNode }) {
- +
{props.children}
diff --git a/apps/web/src/components/SideNavigation.tsx b/apps/web/src/components/SideNavigation.tsx index 284f78a4..410f3270 100644 --- a/apps/web/src/components/SideNavigation.tsx +++ b/apps/web/src/components/SideNavigation.tsx @@ -58,7 +58,7 @@ export default function SideNavigation({
  • @@ -67,8 +67,8 @@ export default function SideNavigation({ diff --git a/apps/web/src/components/UserMenu.tsx b/apps/web/src/components/UserMenu.tsx index 27e53de0..abdf48e2 100644 --- a/apps/web/src/components/UserMenu.tsx +++ b/apps/web/src/components/UserMenu.tsx @@ -21,17 +21,20 @@ export default function UserMenu({ email, isLoading, }: UserMenuProps) { + const db = createClient(); const router = useRouter(); const { themePreference, switchTheme } = useTheme(); const handleLogout = async () => { - const db = createClient(); - await db.auth.signOut(); router.push("/login"); }; + const avatarUrl = imageUrl + ? db.storage.from("avatars").getPublicUrl(imageUrl).data.publicUrl + : null; + return (
    @@ -42,9 +45,9 @@ export default function UserMenu({
    ) : ( - {imageUrl ? ( + {avatarUrl ? ( cookie.includes("auth-token")); - // const { data } = api.auth.getUser.useQuery(undefined, { + // const { data } = api.user.getUser.useQuery(undefined, { // enabled: authCookieExists ? true : false, // }); diff --git a/apps/web/src/views/auth/signup/index.tsx b/apps/web/src/views/auth/signup/index.tsx index 18dfe3c3..b5777aa4 100644 --- a/apps/web/src/views/auth/signup/index.tsx +++ b/apps/web/src/views/auth/signup/index.tsx @@ -1,7 +1,9 @@ import { useState } from "react"; + // import { useRouter } from "next/navigation"; import { Auth } from "~/components/AuthForm"; import { PageHead } from "~/components/PageHead"; + // import { api } from "~/utils/api"; export default function SignupPage() { @@ -18,7 +20,7 @@ export default function SignupPage() { // .split("; ") // .some((cookie) => cookie.includes("auth-token")); - // const { data } = api.auth.getUser.useQuery(undefined, { + // const { data } = api.user.getUser.useQuery(undefined, { // enabled: authCookieExists ? true : false, // }); diff --git a/apps/web/src/views/board/index.tsx b/apps/web/src/views/board/index.tsx index 91ee75da..39c858ad 100644 --- a/apps/web/src/views/board/index.tsx +++ b/apps/web/src/views/board/index.tsx @@ -157,7 +157,7 @@ export default function BoardPage() { type="text" {...register("name")} onBlur={handleSubmit(onSubmit)} - className="block border-0 bg-transparent p-0 py-0 font-medium leading-[2.3rem] tracking-tight text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000 sm:text-[1.2rem]" + className="block border-0 bg-transparent p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000 sm:text-[1.2rem]" /> )} diff --git a/apps/web/src/views/home/index.tsx b/apps/web/src/views/home/index.tsx index b8fe44a9..52782e6e 100644 --- a/apps/web/src/views/home/index.tsx +++ b/apps/web/src/views/home/index.tsx @@ -21,7 +21,7 @@ export default function HomeView() { ? Cookies.get(env.NEXT_PUBLIC_SUPABASE_AUTH_COOKIE_NAME) : null; - const { data } = api.auth.getUser.useQuery(undefined, { + const { data } = api.user.getUser.useQuery(undefined, { enabled: !!token, }); diff --git a/apps/web/src/views/settings/components/Avatar.tsx b/apps/web/src/views/settings/components/Avatar.tsx new file mode 100644 index 00000000..ad67fe5f --- /dev/null +++ b/apps/web/src/views/settings/components/Avatar.tsx @@ -0,0 +1,113 @@ +import Image from "next/image"; +import { useState } from "react"; + +import { usePopup } from "~/providers/popup"; +import { api } from "~/utils/api"; +import createClient from "~/utils/supabase/client"; + +export default function Avatar({ + userId, + userImage, +}: { + userId: string | undefined; + userImage: string | null | undefined; +}) { + const supabase = createClient(); + const utils = api.useUtils(); + const { showPopup } = usePopup(); + const [uploading, setUploading] = useState(false); + + const updateUser = api.user.update.useMutation({ + onSuccess: async () => { + try { + await utils.user.getUser.refetch(); + } catch (e) { + console.error(e); + throw e; + } + }, + onError: () => { + showPopup({ + header: "Error updating profile image", + message: "Please try again later, or contact customer support.", + icon: "error", + }); + }, + }); + + const avatarUrl = userImage + ? supabase.storage.from("avatars").getPublicUrl(userImage).data.publicUrl + : null; + + const uploadAvatar = async (event: React.ChangeEvent) => { + try { + setUploading(true); + + if (!event.target.files || event.target.files.length === 0) { + throw new Error("You must select an image to upload."); + } + + if (!userId) { + throw new Error("User ID is required."); + } + + const file = event.target.files[0]; + + if (!file) { + throw new Error("No file selected."); + } + + const fileExt = file.name.split(".").pop(); + const fileName = `${userId}/avatar.${fileExt}`; + const filePath = `${fileName}`; + + const { error: uploadError } = await supabase.storage + .from("avatars") + .upload(filePath, file, { upsert: true }); + + if (uploadError) { + throw uploadError; + } + + updateUser.mutate({ image: filePath }); + } catch (error) { + console.error(error); + } finally { + setUploading(false); + } + }; + + return ( +
    +
    + + {avatarUrl ? ( + Avatar + ) : ( + + + + + + )} +
    +
    + ); +} diff --git a/apps/web/src/views/settings/index.tsx b/apps/web/src/views/settings/index.tsx index d26b2134..80152228 100644 --- a/apps/web/src/views/settings/index.tsx +++ b/apps/web/src/views/settings/index.tsx @@ -6,6 +6,8 @@ import { NewWorkspaceForm } from "~/components/NewWorkspaceForm"; import { PageHead } from "~/components/PageHead"; import { useModal } from "~/providers/modal"; import { useWorkspace } from "~/providers/workspace"; +import { api } from "~/utils/api"; +import Avatar from "./components/Avatar"; import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation"; import { PremiumUsernameConfirmation } from "./components/PremiumUsernameConfirmation"; import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescriptionForm"; @@ -16,6 +18,8 @@ export default function SettingsPage() { const { modalContentType, openModal } = useModal(); const { workspace } = useWorkspace(); + const { data } = api.user.getUser.useQuery(); + const handleOpenBillingPortal = async () => { try { const response = await fetch("/api/stripe/create_billing_session", { @@ -47,6 +51,13 @@ export default function SettingsPage() { +
    +

    + Profile picture +

    + +
    +

    Workspace name diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts index ef6e9cc2..84eddd25 100644 --- a/packages/api/src/root.ts +++ b/packages/api/src/root.ts @@ -5,6 +5,7 @@ import { importRouter } from "./routers/import"; import { labelRouter } from "./routers/label"; import { listRouter } from "./routers/list"; import { memberRouter } from "./routers/member"; +import { userRouter } from "./routers/user"; import { workspaceRouter } from "./routers/workspace"; import { createTRPCRouter } from "./trpc"; @@ -16,6 +17,7 @@ export const appRouter = createTRPCRouter({ list: listRouter, member: memberRouter, import: importRouter, + user: userRouter, workspace: workspaceRouter, }); diff --git a/packages/api/src/routers/auth.ts b/packages/api/src/routers/auth.ts index 846ed9c9..73be3c6c 100644 --- a/packages/api/src/routers/auth.ts +++ b/packages/api/src/routers/auth.ts @@ -1,52 +1,9 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import * as userRepo from "@kan/db/repository/user.repo"; - -import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; +import { createTRPCRouter, publicProcedure } from "../trpc"; export const authRouter = createTRPCRouter({ - getUser: protectedProcedure - .meta({ - openapi: { - method: "GET", - path: "/users/me", - summary: "Get user", - description: - "Retrieves the currently authenticated user's profile information", - tags: ["Users"], - protect: true, - }, - }) - .input(z.void()) - .output( - z.object({ - id: z.string(), - email: z.string(), - name: z.string().nullable(), - stripeCustomerId: z.string().nullable(), - }), - ) - .query(async ({ ctx }) => { - const userId = ctx.user?.id; - - if (!userId) - throw new TRPCError({ - message: `User not authenticated`, - code: "UNAUTHORIZED", - }); - - const result = await userRepo.getById(ctx.db, userId); - - if (!result?.name) { - throw new TRPCError({ - message: `User not found`, - code: "NOT_FOUND", - }); - } - - return result; - }), loginWithEmail: publicProcedure .meta({ openapi: { diff --git a/packages/api/src/routers/user.ts b/packages/api/src/routers/user.ts new file mode 100644 index 00000000..5abb8b69 --- /dev/null +++ b/packages/api/src/routers/user.ts @@ -0,0 +1,93 @@ +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; + +import * as userRepo from "@kan/db/repository/user.repo"; + +import { createTRPCRouter, protectedProcedure } from "../trpc"; + +export const userRouter = createTRPCRouter({ + getUser: protectedProcedure + .meta({ + openapi: { + method: "GET", + path: "/users/me", + summary: "Get user", + description: + "Retrieves the currently authenticated user's profile information", + tags: ["Users"], + protect: true, + }, + }) + .input(z.void()) + .output( + z.object({ + id: z.string(), + email: z.string(), + name: z.string().nullable(), + image: z.string().nullable(), + stripeCustomerId: z.string().nullable(), + }), + ) + .query(async ({ ctx }) => { + const userId = ctx.user?.id; + + if (!userId) + throw new TRPCError({ + message: `User not authenticated`, + code: "UNAUTHORIZED", + }); + + const result = await userRepo.getById(ctx.db, userId); + + if (!result?.name) { + throw new TRPCError({ + message: `User not found`, + code: "NOT_FOUND", + }); + } + + return result; + }), + update: protectedProcedure + .meta({ + openapi: { + method: "PUT", + path: "/users", + summary: "Update user", + description: + "Updates the currently authenticated user's profile information", + tags: ["Users"], + protect: true, + }, + }) + .input( + z.object({ + image: z.string(), + }), + ) + .output( + z.object({ + image: z.string().nullable(), + }), + ) + .mutation(async ({ ctx, input }) => { + const userId = ctx.user?.id; + + if (!userId) + throw new TRPCError({ + message: `User not authenticated`, + code: "UNAUTHORIZED", + }); + + const result = await userRepo.update(ctx.adminDb, userId, input); + + if (!result) { + throw new TRPCError({ + message: `User not found`, + code: "NOT_FOUND", + }); + } + + return result; + }), +}); diff --git a/packages/db/seed.sql b/packages/db/seed.sql index 8b20ce30..4c2e4ecc 100644 --- a/packages/db/seed.sql +++ b/packages/db/seed.sql @@ -712,4 +712,41 @@ FOR ALL TO authenticated USING ( "createdBy" = auth.uid() -); \ No newline at end of file +); + +/* BUCKETS */ +insert into storage.buckets + (id, name, public) +values + ('avatars', 'avatars', true); + +alter table storage.objects enable row level security; + +CREATE POLICY "Users can upload their own avatar" +ON storage.objects FOR INSERT +TO authenticated +WITH CHECK ( + bucket_id = 'avatars' AND + (storage.foldername(name))[1] = auth.uid()::text +); + +CREATE POLICY "Users can update their own avatar" +ON storage.objects FOR UPDATE +TO authenticated +USING ( + bucket_id = 'avatars' AND + (storage.foldername(name))[1] = auth.uid()::text +); + +CREATE POLICY "Users can delete their own avatar" +ON storage.objects FOR DELETE +TO authenticated +USING ( + bucket_id = 'avatars' AND + (storage.foldername(name))[1] = auth.uid()::text +); + +CREATE POLICY "Avatar images are publicly accessible" +ON storage.objects FOR SELECT +TO anon, authenticated +USING (bucket_id = 'avatars'); \ No newline at end of file diff --git a/packages/db/src/repository/user.repo.ts b/packages/db/src/repository/user.repo.ts index a5368f6b..6fecdf9b 100644 --- a/packages/db/src/repository/user.repo.ts +++ b/packages/db/src/repository/user.repo.ts @@ -5,7 +5,7 @@ import type { Database } from "@kan/db/types/database.types"; export const getById = async (db: SupabaseClient, userId: string) => { const { data } = await db .from("user") - .select(`id, name, email, stripeCustomerId`) + .select(`id, name, email, image, stripeCustomerId`) .eq("id", userId) .limit(1) .single(); @@ -44,3 +44,18 @@ export const create = async ( return data; }; + +export const update = async ( + db: SupabaseClient, + userId: string, + updates: { image: string | null }, +) => { + const { data } = await db + .from("user") + .update({ image: updates.image }) + .eq("id", userId) + .select(`image`) + .single(); + + return data; +};