diff --git a/backend/src/modules/general/index.ts b/backend/src/modules/general/index.ts index 76bfd46a4..f0ea04cb6 100644 --- a/backend/src/modules/general/index.ts +++ b/backend/src/modules/general/index.ts @@ -19,9 +19,10 @@ import { organizationsTable } from '#/db/schema/organizations'; import { type TokenModel, tokensTable } from '#/db/schema/tokens'; import { usersTable } from '#/db/schema/users'; import { getUserBy } from '#/db/util'; -import { entityIdFields, entityTables } from '#/entity-config'; +import { entityIdFields, entityTables, menuSections } from '#/entity-config'; import { errorResponse } from '#/lib/errors'; import { i18n } from '#/lib/i18n'; +import { sendSSEToUsers } from '#/lib/sse'; import { isAuthenticated } from '#/middlewares/guard'; import { logEvent } from '#/middlewares/logger/log-event'; import { verifyUnsubscribeToken } from '#/modules/users/helpers/unsubscribe-token'; @@ -178,47 +179,49 @@ const generalRoutes = app return errorResponse(ctx, 400, 'invalid_token_or_expired', 'warn'); } const user = await getUserBy('email', token.email); - if (!user) { - return errorResponse(ctx, 404, 'not_found', 'warn', 'user', { email: token.email }); - } + if (!user) return errorResponse(ctx, 404, 'not_found', 'warn', 'user', { email: token.email }); // If it is a system invitation, update user role if (token.type === 'system_invitation') return ctx.json({ success: true }, 200); - if (token.type === 'membership_invitation') { - if (!token.organizationId) return errorResponse(ctx, 400, 'invalid_token', 'warn'); - - const [organization] = await db - .select() - .from(organizationsTable) - .where(and(eq(organizationsTable.id, token.organizationId))); + if (!token.organizationId) return errorResponse(ctx, 400, 'invalid_token', 'warn'); - if (!organization) { - return errorResponse(ctx, 404, 'not_found', 'warn', 'organization', { organization: token.organizationId }); - } + const [organization] = await db + .select() + .from(organizationsTable) + .where(and(eq(organizationsTable.id, token.organizationId))); - const [existingMembership] = await db - .select() - .from(membershipsTable) - .where(and(eq(membershipsTable.organizationId, organization.id), eq(membershipsTable.userId, user.id))); - - if (existingMembership) { - if (existingMembership.role !== token.role) { - await db - .update(membershipsTable) - .set({ role: token.role as MembershipModel['role'] }) - .where(and(eq(membershipsTable.organizationId, organization.id), eq(membershipsTable.userId, user.id))); - } + if (!organization) return errorResponse(ctx, 404, 'not_found', 'warn', 'organization', { organization: token.organizationId }); - return ctx.json({ success: true }, 200); + const [existingMembership] = await db + .select() + .from(membershipsTable) + .where(and(eq(membershipsTable.organizationId, organization.id), eq(membershipsTable.userId, user.id))); + + if (existingMembership) { + if (existingMembership.role !== token.role) { + await db + .update(membershipsTable) + .set({ role: token.role as MembershipModel['role'] }) + .where(and(eq(membershipsTable.organizationId, organization.id), eq(membershipsTable.userId, user.id))); } - // Insert membership - const role = token.role as MembershipModel['role']; - await insertMembership({ user, role, entity: organization }); + return ctx.json({ success: true }, 200); } - return ctx.json({ success: true }, 200); + // Insert membership + const role = token.role as MembershipModel['role']; + const membership = await insertMembership({ user, role, entity: organization }); + + const newMenuItem = { + newItem: { ...organization, membership }, + sectionName: menuSections.find((el) => el.entityType === membership.type)?.name, + }; + + // SSE with entity data, to update user's menu + sendSSEToUsers([user.id], 'add_entity', newMenuItem); + + return ctx.json({ success: true, data: newMenuItem }, 200); }) /* * Paddle webhook diff --git a/backend/src/modules/general/routes.ts b/backend/src/modules/general/routes.ts index 38ee27d90..9c23d1449 100644 --- a/backend/src/modules/general/routes.ts +++ b/backend/src/modules/general/routes.ts @@ -5,7 +5,7 @@ import { authRateLimiter, rateLimiter } from '#/middlewares/rate-limiter'; import { errorResponses, successWithDataSchema, successWithoutDataSchema } from '#/utils/schema/common-responses'; import { pageEntityTypeSchema, slugSchema, tokenSchema } from '#/utils/schema/common-schemas'; import { userUnsubscribeQuerySchema } from '../users/schema'; -import { acceptInviteBodySchema, checkTokenSchema, inviteBodySchema, suggestionsSchema } from './schema'; +import { acceptInviteBodySchema, acceptInviteResponseSchema, checkTokenSchema, inviteBodySchema, suggestionsSchema } from './schema'; class GeneralRoutesConfig { public unsubscribeUser = createRouteConfig({ @@ -173,7 +173,7 @@ class GeneralRoutesConfig { description: 'Invitation was accepted', content: { 'application/json': { - schema: successWithoutDataSchema, + schema: successWithDataSchema(acceptInviteResponseSchema), }, }, }, diff --git a/backend/src/modules/general/schema.ts b/backend/src/modules/general/schema.ts index 8ffbd0d84..8277542e1 100644 --- a/backend/src/modules/general/schema.ts +++ b/backend/src/modules/general/schema.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { config } from 'config'; import { createSelectSchema } from 'drizzle-zod'; import { tokensTable } from '#/db/schema/tokens'; +import { type MenuSectionName, menuSections } from '#/entity-config'; import { contextEntityTypeSchema, idOrSlugSchema, @@ -14,6 +15,7 @@ import { passwordSchema, slugSchema, } from '#/utils/schema/common-schemas'; +import { menuItemSchema } from '../me/schema'; import { membershipInfoSchema } from '../memberships/schema'; import { userSchema } from '../users/schema'; @@ -37,6 +39,15 @@ export const acceptInviteBodySchema = z.object({ oauth: z.enum(config.enabledOauthProviders).optional(), }); +const sectionNames = menuSections.map((section) => section.name) as [MenuSectionName]; + +export const acceptInviteResponseSchema = z + .object({ + newItem: menuItemSchema, + sectionName: z.enum(sectionNames), + }) + .optional(); + export const entitySuggestionSchema = z.object({ slug: slugSchema, id: idSchema, diff --git a/backend/src/modules/me/index.ts b/backend/src/modules/me/index.ts index 308a2e9f3..db2208d90 100644 --- a/backend/src/modules/me/index.ts +++ b/backend/src/modules/me/index.ts @@ -17,6 +17,8 @@ import { oauthAccountsTable } from '#/db/schema/oauth-accounts'; import { passkeysTable } from '#/db/schema/passkeys'; import { type MenuSection, entityIdFields, entityTables, menuSections } from '#/entity-config'; import { getContextUser, getMemberships } from '#/lib/context'; +import { resolveEntity } from '#/lib/entity'; +import { sendSSEToUsers } from '#/lib/sse'; import { getPreparedSessions } from './helpers/get-sessions'; import type { MenuItem, UserMenu } from './schema'; @@ -242,7 +244,30 @@ const meRoutes = app return ctx.json({ success: true }, 200); }) + /* + * Delete current user (self) entity membership + */ + .openapi(meRoutesConfig.leaveEntity, async (ctx) => { + const user = getContextUser(); + if (!user) return errorResponse(ctx, 404, 'not_found', 'warn', 'user', { user: 'self' }); + + const { entityType, idOrSlug } = ctx.req.valid('query'); + + const entity = await resolveEntity(entityType, idOrSlug); + if (!entity) return errorResponse(ctx, 404, 'not_found', 'warn', entityType); + const entityIdField = entityIdFields[entityType]; + + // Delete the memberships + await db + .delete(membershipsTable) + .where(and(eq(membershipsTable.userId, user.id), eq(membershipsTable.type, entityType), eq(membershipsTable[entityIdField], entity.id))); + + sendSSEToUsers([user.id], 'remove_entity', { id: entity.id, entity: entity.entity }); + logEvent('User leave entity', { user: user.id }); + + return ctx.json({ success: true }, 200); + }) /* * Delete passkey of self */ diff --git a/backend/src/modules/me/routes.ts b/backend/src/modules/me/routes.ts index 07575cd44..9e2b2781e 100644 --- a/backend/src/modules/me/routes.ts +++ b/backend/src/modules/me/routes.ts @@ -3,7 +3,7 @@ import { isAuthenticated } from '#/middlewares/guard'; import { errorResponses, successWithDataSchema, successWithErrorsSchema, successWithoutDataSchema } from '#/utils/schema/common-responses'; import { idsQuerySchema } from '#/utils/schema/common-schemas'; import { updateUserBodySchema, userSchema } from '../users/schema'; -import { meUserSchema, signUpInfo, userMenuSchema } from './schema'; +import { leaveEntityQuerySchema, meUserSchema, signUpInfo, userMenuSchema } from './schema'; class MeRoutesConfig { public getSelf = createRouteConfig({ @@ -120,6 +120,31 @@ class MeRoutesConfig { ...errorResponses, }, }); + + public leaveEntity = createRouteConfig({ + method: 'delete', + path: '/leave', + guard: isAuthenticated, + tags: ['me'], + summary: 'Leave entity', + description: 'Leave any entity on your own.', + security: [], + request: { + query: leaveEntityQuerySchema, + }, + responses: { + 200: { + description: 'Passkey removed', + content: { + 'application/json': { + schema: successWithoutDataSchema, + }, + }, + }, + ...errorResponses, + }, + }); + public deletePasskey = createRouteConfig({ method: 'delete', path: '/passkey', diff --git a/backend/src/modules/me/schema.ts b/backend/src/modules/me/schema.ts index 7e2be4dd6..8336d7c23 100644 --- a/backend/src/modules/me/schema.ts +++ b/backend/src/modules/me/schema.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { config } from 'config'; import { type MenuSectionName, menuSections } from '#/entity-config'; -import { idSchema, imageUrlSchema, nameSchema, slugSchema } from '#/utils/schema/common-schemas'; +import { contextEntityTypeSchema, idOrSlugSchema, idSchema, imageUrlSchema, nameSchema, slugSchema } from '#/utils/schema/common-schemas'; import { membershipInfoSchema } from '../memberships/schema'; import { userSchema } from '../users/schema'; @@ -63,3 +63,8 @@ export const userMenuSchema = z.object( ); export type UserMenu = z.infer; + +export const leaveEntityQuerySchema = z.object({ + idOrSlug: idOrSlugSchema, + entityType: contextEntityTypeSchema, +}); diff --git a/backend/src/modules/memberships/index.ts b/backend/src/modules/memberships/index.ts index 610c7c730..f059c6fe0 100644 --- a/backend/src/modules/memberships/index.ts +++ b/backend/src/modules/memberships/index.ts @@ -225,20 +225,20 @@ const membershipsRoutes = app const errors: ErrorType[] = []; - const filters = and(eq(membershipsTable.type, entityType), or(eq(membershipsTable[entityIdField], entity.id))); + const filters = [eq(membershipsTable.type, entityType), eq(membershipsTable[entityIdField], entity.id)]; // Get user membership const [currentUserMembership]: (MembershipModel | undefined)[] = await db .select() .from(membershipsTable) - .where(and(filters, eq(membershipsTable.userId, user.id))) + .where(and(...filters, eq(membershipsTable.userId, user.id))) .limit(1); // Get target memberships const targets = await db .select() .from(membershipsTable) - .where(and(inArray(membershipsTable.userId, memberToDeleteIds), filters)); + .where(and(inArray(membershipsTable.userId, memberToDeleteIds), ...filters)); // Check if membership exist for (const id of memberToDeleteIds) { diff --git a/frontend/src/api/general.ts b/frontend/src/api/general.ts index d66c1bb01..7655130d7 100644 --- a/frontend/src/api/general.ts +++ b/frontend/src/api/general.ts @@ -84,5 +84,5 @@ export const acceptInvite = async ({ token, password, oauth }: AcceptInviteProps }); const json = await handleResponse(response); - return json.success; + return json.data; }; diff --git a/frontend/src/api/me.ts b/frontend/src/api/me.ts index 1a960deb4..a9a07c72a 100644 --- a/frontend/src/api/me.ts +++ b/frontend/src/api/me.ts @@ -1,5 +1,6 @@ import { config } from 'config'; import type { UpdateUserParams } from '~/api/users'; +import type { ContextEntity } from '~/types/common'; import { meHc } from '#/modules/me/hc'; import { clientConfig, handleResponse } from '.'; @@ -54,3 +55,12 @@ export const deletePasskey = async () => { const json = await handleResponse(response); return json.success; }; +// Leave entity +export const leaveEntity = async (query: { idOrSlug: string; entityType: ContextEntity }) => { + const response = await client.leave.$delete({ + query, + }); + + const json = await handleResponse(response); + return json.success; +}; diff --git a/frontend/src/modules/common/accept-invite.tsx b/frontend/src/modules/common/accept-invite.tsx index 191094dee..21e456f5e 100644 --- a/frontend/src/modules/common/accept-invite.tsx +++ b/frontend/src/modules/common/accept-invite.tsx @@ -15,6 +15,7 @@ import Spinner from '~/modules/common/spinner'; import { SubmitButton, buttonVariants } from '~/modules/ui/button'; import { acceptInviteRoute } from '~/routes/general'; import { cn } from '~/utils/cn'; +import { addMenuItem } from './nav-sheet/helpers/menu-operations'; type TokenData = z.infer; @@ -34,7 +35,8 @@ const AcceptInvite = () => { const { mutate: acceptInvite, isPending } = useMutation({ mutationFn: baseAcceptInvite, - onSuccess: () => { + onSuccess: (data) => { + if (data) addMenuItem(data.newItem, data.sectionName); toast.success(t('common:invitation_accepted')); navigate({ to: tokenData?.organizationSlug ? `/${tokenData.organizationSlug}` : config.defaultRedirectPath, diff --git a/frontend/src/modules/organizations/leave-button.tsx b/frontend/src/modules/organizations/leave-button.tsx index fc1b8f74c..e928e6304 100644 --- a/frontend/src/modules/organizations/leave-button.tsx +++ b/frontend/src/modules/organizations/leave-button.tsx @@ -4,39 +4,34 @@ import { config } from 'config'; import { Check, UserRoundX } from 'lucide-react'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { leaveEntity } from '~/api/me'; +import { useMutation } from '~/hooks/use-mutations'; import { showToast } from '~/lib/toasts'; -import { useMembersDeleteMutation } from '~/modules/common/query-client-provider/mutations/members'; import { Button } from '~/modules/ui/button'; import { Command, CommandGroup, CommandItem, CommandList } from '~/modules/ui/command'; import { Popover, PopoverContent, PopoverTrigger } from '~/modules/ui/popover'; -import { useUserStore } from '~/store/user'; import type { Organization } from '~/types/common'; const LeaveButton = ({ organization }: { organization: Organization }) => { const { t } = useTranslation(); - const { user } = useUserStore(); const navigate = useNavigate(); const [openPopover, setOpenPopover] = useState(false); - const { mutate: leave } = useMembersDeleteMutation(); + const { mutate: leave } = useMutation({ + mutationFn: () => + leaveEntity({ + idOrSlug: organization.slug, + entityType: 'organization', + }), + onSuccess: () => { + showToast(t('common:success.you_left_organization'), 'success'); + navigate({ to: config.defaultRedirectPath, replace: true }); + }, + }); const onLeave = () => { if (!onlineManager.isOnline()) return showToast(t('common:action.offline.text'), 'warning'); - - leave( - { - orgIdOrSlug: organization.id, - idOrSlug: organization.slug, - entityType: 'organization', - ids: [user.id], - }, - { - onSuccess: () => { - showToast(t('common:success.you_left_organization'), 'success'); - navigate({ to: config.defaultRedirectPath, replace: true }); - }, - }, - ); + leave(); }; return ( diff --git a/locales/en/common.json b/locales/en/common.json index 3f6905cc3..3a5b1491f 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -194,7 +194,7 @@ "invite": "Invite", "invite_by_email": "Invite with email addresses", "invite_by_name": "Invite by searching for users", - "invite_create_account": " Sign up to accept invitation", + "invite_create_account": "Sign up to accept invitation", "invite_members.text": "Invited members will receive an email to accept the invitation.", "invite_members_search.text": "Search for users to invite within {{appName}}.", "invite_only.text": "{{appName}} is currently invite-only. Can't wait? Contact us for an invite.",