diff --git a/@api/routes/organization/change-logo.ts b/@api/routes/organization/change-logo.ts new file mode 100644 index 0000000..38f1325 --- /dev/null +++ b/@api/routes/organization/change-logo.ts @@ -0,0 +1,54 @@ +import { Organizations } from '@api/database/schema' +import { authProcedure, organizationMemberMiddleware } from '@api/trpc' +import { TRPCError } from '@trpc/server' +import { eq } from 'drizzle-orm' +import { Buffer } from 'node:buffer' +import { z } from 'zod' + +export const organizationChangeLogoRoute = authProcedure + .input( + z.object({ + organization: z.object({ + id: z.string().uuid(), + avatar: z.object({ + name: z + .string() + .max(1234) + .transform((name) => name.replace(/[^a-zA-Z0-9.-_]/gi, '-')), + base64: z + .string() + .max(1024 * 1024) // 1 MB + .transform((base64) => { + return Buffer.from(base64, 'base64') + }), + }), + }), + }), + ) + .use(organizationMemberMiddleware) + .mutation(async ({ ctx, input }) => { + const organization = await ctx.db.query.Organizations.findFirst({ + where(t, { eq }) { + return eq(t.id, input.organization.id) + }, + }) + + if (!organization) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Organization not found', + }) + } + + const objectName = `organization/${input.organization.id}/logo/${input.organization.avatar.name}` + const deleteOldLogo = ctx.env.PUBLIC_BUCKET.delete(organization.logoUrl) + const uploadNewLogo = ctx.env.PUBLIC_BUCKET.put(objectName, input.organization.avatar.base64) + const updateLogoUrl = ctx.db + .update(Organizations) + .set({ + logoUrl: objectName, + }) + .where(eq(Organizations.id, input.organization.id)) + + await Promise.all([deleteOldLogo, uploadNewLogo, updateLogoUrl]) + }) diff --git a/@api/routes/organization/index.ts b/@api/routes/organization/index.ts index b83cb24..0e33368 100644 --- a/@api/routes/organization/index.ts +++ b/@api/routes/organization/index.ts @@ -1,10 +1,14 @@ import { router } from '@api/trpc' +import { organizationChangeLogoRoute } from './change-logo' import { organizationCreateRoute } from './create' import { organizationDetailRoute } from './detail' import { organizationListRoute } from './list' +import { organizationUpdateRoute } from './update' export const organizationRouter = router({ list: organizationListRoute, detail: organizationDetailRoute, create: organizationCreateRoute, + update: organizationUpdateRoute, + changeLogo: organizationChangeLogoRoute, }) diff --git a/@api/routes/organization/update.ts b/@api/routes/organization/update.ts new file mode 100644 index 0000000..664fa36 --- /dev/null +++ b/@api/routes/organization/update.ts @@ -0,0 +1,23 @@ +import { Organizations } from '@api/database/schema' +import { authProcedure, organizationMemberMiddleware } from '@api/trpc' +import { eq } from 'drizzle-orm' +import { z } from 'zod' + +export const organizationUpdateRoute = authProcedure + .input( + z.object({ + organization: z.object({ + id: z.string().uuid(), + name: z.string(), + }), + }), + ) + .use(organizationMemberMiddleware) + .mutation(async ({ ctx, input }) => { + await ctx.db + .update(Organizations) + .set({ + name: input.organization.name, + }) + .where(eq(Organizations.id, input.organization.id)) + }) diff --git a/@api/trpc.ts b/@api/trpc.ts index 9685ee3..73617da 100644 --- a/@api/trpc.ts +++ b/@api/trpc.ts @@ -1,6 +1,7 @@ -import { TRPCError, initTRPC } from '@trpc/server' +import { TRPCError, experimental_standaloneMiddleware, initTRPC } from '@trpc/server' import SuperJSON from 'superjson' import type { Context } from './context' +import type { Db } from './lib/db' const t = initTRPC.context().create({ transformer: SuperJSON, @@ -73,3 +74,21 @@ const authMiddleware = middleware(async ({ ctx, next }) => { }) export const authProcedure = procedure.use(authMiddleware) + +export const organizationMemberMiddleware = experimental_standaloneMiddleware<{ + ctx: { auth: { session: { userId: string } }; db: Db } // defaults to 'object' if not defined + input: { organizationId: string } | { organization: { id: string } } // defaults to 'unknown' if not defined +}>().create(async ({ ctx, next, input }) => { + const organizationId = 'organizationId' in input ? input.organizationId : input.organization.id + + const organizationMember = await ctx.db.query.OrganizationMembers.findFirst({ + where(t, { and, eq }) { + return and(eq(t.organizationId, organizationId), eq(t.userId, ctx.auth.session.userId)) + }, + }) + + if (!organizationMember) + throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this organization.' }) + + return next() +}) diff --git a/@ui/styles/globals.css b/@ui/styles/globals.css index ea91940..103d68b 100644 --- a/@ui/styles/globals.css +++ b/@ui/styles/globals.css @@ -80,4 +80,8 @@ table-layout: fixed; width: 100%; } + + img { + @apply object-center object-cover; + } } diff --git a/@web/app/(auth)/(settings)/_components/nav.tsx b/@web/app/(auth)/(settings)/_components/nav.tsx new file mode 100644 index 0000000..37730f2 --- /dev/null +++ b/@web/app/(auth)/(settings)/_components/nav.tsx @@ -0,0 +1,76 @@ +'use client' + +import { api } from '@web/lib/api' +import { isActivePathname } from '@web/lib/utils' +import Link from 'next/link' +import { usePathname, useSearchParams } from 'next/navigation' +import { Skeleton } from '@ui/ui/skeleton' +import { ViewportBlock } from '@ui/ui/viewport-block' + +const staticLinks = [ + { + name: 'Profile', + href: '/profile', + }, + { + name: 'Notifications', + href: '/notifications', + }, +] + +export function Nav() { + const pathname = usePathname() + const searchParams = useSearchParams() + const query = api.organization.list.useInfiniteQuery( + { + limit: 6, + }, + { + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ) + + const organizationLinks = + query.data?.pages.flatMap((page) => + page.items.map((organization) => ({ + name: organization.name, + id: organization.id, + href: `organization?id=${organization.id}`, + })), + ) ?? [] + + return ( + + ) +} diff --git a/@web/app/(auth)/profile/layout.tsx b/@web/app/(auth)/(settings)/layout.tsx similarity index 100% rename from @web/app/(auth)/profile/layout.tsx rename to @web/app/(auth)/(settings)/layout.tsx diff --git a/@web/app/(auth)/(settings)/organization/_components/organization-infos-form.tsx b/@web/app/(auth)/(settings)/organization/_components/organization-infos-form.tsx new file mode 100644 index 0000000..ff55f08 --- /dev/null +++ b/@web/app/(auth)/(settings)/organization/_components/organization-infos-form.tsx @@ -0,0 +1,138 @@ +'use client' + +import { api } from '@web/lib/api' +import { constructPublicResourceUrl } from '@web/lib/utils' +import imageCompression from 'browser-image-compression' +import { Base64 } from 'js-base64' +import { useSearchParams } from 'next/navigation' +import { useId, useRef } from 'react' +import { match } from 'ts-pattern' +import { z } from 'zod' +import { Button } from '@ui/ui/button' +import { GeneralError } from '@ui/ui/general-error' +import { GeneralSkeleton } from '@ui/ui/general-skeleton' +import { Input } from '@ui/ui/input' +import { Label } from '@ui/ui/label' +import { MutationStatusIcon } from '@ui/ui/mutation-status-icon' + +export function OrganizationInfosForm() { + const searchParams = useSearchParams() + const organizationId = z.string().uuid().parse(searchParams.get('id')) + + const nameId = useId() + const query = api.organization.detail.useQuery({ + organizationId, + }) + const mutation = api.organization.update.useMutation() + + const action = (form: FormData) => { + const name = form.get('name') as string + + mutation.mutate({ + organization: { + id: organizationId, + name, + }, + }) + } + + return ( +
+
+
+

Organization Information

+

Edit information about your organization.

+
+ +
+ {match(query) + .with({ status: 'loading' }, () => ) + .with({ status: 'error' }, () => ) + .with({ status: 'success' }, (query) => ( +
+
+
+ {query.data.organization.name} +
+ +

JPG, GIF or PNG. 1MB max.

+
+
+ +
+ +
+ +
+
+
+ +
+ +
+
+ )) + .exhaustive()} +
+
+
+ ) +} + +export function LogoChangeButton() { + const searchParams = useSearchParams() + const organizationId = z.string().uuid().parse(searchParams.get('id')) + const inputRef = useRef(null) + const mutation = api.organization.changeLogo.useMutation() + + const onChange = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0] + if (!file) return + + const compressedImage = await imageCompression(file, { + maxSizeMB: 1, + maxWidthOrHeight: 200, + useWebWorker: true, + }) + + const avatarBase64 = Base64.fromUint8Array(new Uint8Array(await compressedImage.arrayBuffer())) + mutation.mutate({ + organization: { + id: organizationId, + avatar: { + name: compressedImage.name, + base64: avatarBase64, + }, + }, + }) + } + + return ( + <> + + + + ) +} diff --git a/@web/app/(auth)/(settings)/organization/page.tsx b/@web/app/(auth)/(settings)/organization/page.tsx new file mode 100644 index 0000000..b9dce88 --- /dev/null +++ b/@web/app/(auth)/(settings)/organization/page.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from 'next' +import { OrganizationInfosForm } from './_components/organization-infos-form' + +export const metadata: Metadata = { + title: 'Organization', +} + +export default function OrganizationPage() { + return ( +
+
+ +
+
+ ) +} diff --git a/@web/app/(auth)/profile/_components/oauth-connections.tsx b/@web/app/(auth)/(settings)/profile/_components/oauth-connections.tsx similarity index 100% rename from @web/app/(auth)/profile/_components/oauth-connections.tsx rename to @web/app/(auth)/(settings)/profile/_components/oauth-connections.tsx diff --git a/@web/app/(auth)/profile/_components/personal-infos-form.tsx b/@web/app/(auth)/(settings)/profile/_components/personal-infos-form.tsx similarity index 92% rename from @web/app/(auth)/profile/_components/personal-infos-form.tsx rename to @web/app/(auth)/(settings)/profile/_components/personal-infos-form.tsx index 438e0e8..13b6b87 100644 --- a/@web/app/(auth)/profile/_components/personal-infos-form.tsx +++ b/@web/app/(auth)/(settings)/profile/_components/personal-infos-form.tsx @@ -57,7 +57,12 @@ export function PersonalInfosForm() {
- +
@@ -66,6 +71,7 @@ export function PersonalInfosForm() {
-
    - {links.map((link) => ( -
  • - - {link.name} - -
  • - ))} -
- - ) -} diff --git a/@web/components/organization-create-sheet.tsx b/@web/components/organization-create-sheet.tsx index 74f2f1b..cb6d461 100644 --- a/@web/components/organization-create-sheet.tsx +++ b/@web/components/organization-create-sheet.tsx @@ -48,7 +48,14 @@ export function OrganizationCreateSheet({ children, onSuccess, ...props }: Props - +
diff --git a/@web/components/profile-dropdown-menu.tsx b/@web/components/profile-dropdown-menu.tsx index 2a906c1..14f383d 100644 --- a/@web/components/profile-dropdown-menu.tsx +++ b/@web/components/profile-dropdown-menu.tsx @@ -44,7 +44,7 @@ export function ProfileDropdownMenu({ children, open = false, onOpenChange, ...p {children} - + @@ -61,7 +61,7 @@ export function ProfileDropdownMenu({ children, open = false, onOpenChange, ...p ) } -function WorkspaceList({ onOpenChange }: { onOpenChange: (v: boolean) => void }) { +function OrganizationList({ onOpenChange }: { onOpenChange: (v: boolean) => void }) { const sessionInfosQuery = api.auth.infos.useQuery() const listQuery = api.organization.list.useInfiniteQuery( { @@ -81,7 +81,7 @@ function WorkspaceList({ onOpenChange }: { onOpenChange: (v: boolean) => void }) >
{match(listQuery) - .with({ status: 'loading' }, () => ) + .with({ status: 'loading' }, () => ) .with({ status: 'error' }, () => '') .with({ status: 'success' }, (query) => { return query.data.pages.map((page, i) => { @@ -89,7 +89,7 @@ function WorkspaceList({ onOpenChange }: { onOpenChange: (v: boolean) => void })
{page.items.map((item) => { return ( - void }) {!query.isFetching && query.hasNextPage && ( query.fetchNextPage()} /> )} - {query.hasNextPage && } + {query.hasNextPage && }
) }) @@ -118,7 +118,7 @@ function WorkspaceList({ onOpenChange }: { onOpenChange: (v: boolean) => void }) ) } -function WorkspaceListItemSkeleton() { +function OrganizationListItemSkeleton() { return (
@@ -130,7 +130,7 @@ function WorkspaceListItemSkeleton() { ) } -function WorkspaceListItem(props: { +function OrganizationListItem(props: { organization: { id: string name: string @@ -189,15 +189,13 @@ function WorkspaceListItem(props: {
- {/* TODO: implement */} - Email - Message - - More... + + Settings + diff --git a/@web/lib/utils.ts b/@web/lib/utils.ts index 8768d93..c01cc85 100644 --- a/@web/lib/utils.ts +++ b/@web/lib/utils.ts @@ -1,6 +1,7 @@ import { env } from '@web/env' -export function isActivePathname(pathname: string, currentPathname: string) { +export function isActivePathname(url: string, currentPathname: string) { + const { pathname } = new URL(url, 'https://dinsterizer.com') return `${currentPathname}/`.startsWith(`${pathname}/`) }