) => {
+ 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 ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ );
+}
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;
+};