From 0080f578c726264b4732523da8e8f213e4e888cc Mon Sep 17 00:00:00 2001 From: gintil Date: Sun, 7 Jun 2026 18:02:43 -0400 Subject: [PATCH 01/13] Lay down foundation for workspaces --- .gitignore | 1 + README.md | 6 +- docker-compose.e2e.yml | 13 + e2e/README.md | 12 +- e2e/e2e-mock.sh | 45 +- e2e/helpers/api.ts | 19 + e2e/helpers/constants.ts | 13 + e2e/helpers/log-to-file.ts | 26 + e2e/helpers/smtp.ts | 83 ++ e2e/helpers/workspaces-db.ts | 201 +++++ e2e/mock-discord-server.ts | 106 ++- e2e/mock-rss-server.ts | 3 + e2e/mock-smtp-server.ts | 218 +++++ e2e/playwright.config.ts | 17 + .../email-verification-resend.spec.ts | 83 ++ .../email-verification-target-cap.spec.ts | 75 ++ ...e-feed-management-invites-disabled.spec.ts | 95 ++ e2e/tests/workspaces/workspace-feeds.spec.ts | 90 ++ .../workspace-invitation-roundtrip.spec.ts | 163 ++++ .../workspaces/workspace-invitations.spec.ts | 110 +++ ...workspace-invite-self-accept-guard.spec.ts | 149 ++++ ...orkspace-invite-verification-guard.spec.ts | 66 ++ .../workspaces/workspace-members.spec.ts | 357 ++++++++ .../workspaces/workspaces-on-load.spec.ts | 54 ++ e2e/tests/workspaces/workspaces.spec.ts | 104 +++ package-lock.json | 152 ++-- services/backend-api/client/.eslintrc.js | 1 + .../adr/001-architecture-characteristics.md | 8 +- .../client/docs/adr/003-state-ownership.md | 6 +- .../client/docs/adr/005-team-scoping.md | 164 ---- .../client/docs/adr/005-workspace-scoping.md | 186 ++++ .../client/docs/adr/008-workspace-ui.md | 145 ++++ .../backend-api/client/docs/adr/README.md | 5 +- .../src/components/ConfirmModal/index.tsx | 7 + .../client/src/components/NewHeader/index.tsx | 6 + .../backend-api/client/src/constants/pages.ts | 13 +- .../src/features/discordUser/types/UserMe.ts | 5 + .../src/features/feed/api/createUserFeed.ts | 2 + .../src/features/feed/api/getUserFeeds.ts | 9 +- .../components/AddUserFeedDialog/index.tsx | 484 ----------- .../components/CloneUserFeedDialog/index.tsx | 9 +- .../components/FeedCard/FeedCard.test.tsx | 2 +- .../getCuratedFeedErrorMessage.test.ts | 2 +- .../FeedCard/getCuratedFeedErrorMessage.ts | 2 +- .../FeedCard/getPreviewErrorMessage.ts | 2 +- .../UrlValidationResult.test.tsx | 2 +- .../UrlValidationResult.tsx | 2 +- .../FeedLimitBar/FeedLimitBar.test.tsx | 36 + .../feed/components/FeedLimitBar/index.tsx | 30 +- .../feed/components/UserFeedDetail/index.tsx | 7 +- .../components/UserFeedHealthAlert/index.tsx | 4 + .../UserFeedLogs/DeliveryHistory/index.tsx | 3 + .../ArticleDeliveryDetails.tsx | 3 + .../UserFeedLogs/DeliveryPreview/index.tsx | 7 +- .../components/UserFeedsTable/columns.tsx | 25 +- .../feed/components/UserFeedsTable/index.tsx | 9 +- .../src/features/feed/components/index.ts | 1 - .../feed/contexts/FeedScopeContext.tsx | 27 + .../src/features/feed/contexts/index.ts | 1 + .../features/feed/hooks/useCreateUserFeed.tsx | 23 +- .../src/features/feed/hooks/useUserFeeds.tsx | 10 +- .../feed/hooks/useUserFeedsInfinite.tsx | 5 + .../src/features/feed/types/UserFeed.ts | 12 +- .../components/ConnectionCard/index.tsx | 4 +- .../components/ConnectionSettings/index.tsx | 8 +- .../DeleteConnectionButton/index.tsx | 4 +- .../ExternalPropertyPreview.tsx | 6 + .../components/MessageTabSection/index.tsx | 6 + .../UserFeedMiscSettingsTabSection/index.tsx | 416 ++++----- .../hooks/useGetUserFeedArticlesError.tsx | 2 +- .../InsertPlaceholderDialog.tsx | 4 +- .../messageBuilder/MessageBuilderPage.tsx | 9 +- .../workspaces/api/acceptWorkspaceInvite.ts | 24 + .../api/confirmEmailVerification.ts | 19 + .../workspaces/api/createWorkspace.ts | 28 + .../workspaces/api/createWorkspaceInvite.ts | 29 + .../workspaces/api/declineWorkspaceInvite.ts | 11 + .../workspaces/api/getMyWorkspaceInvites.ts | 17 + .../features/workspaces/api/getWorkspace.ts | 21 + .../workspaces/api/getWorkspaceInvite.ts | 17 + .../workspaces/api/getWorkspaceInvites.ts | 19 + .../workspaces/api/getWorkspaceMembers.ts | 19 + .../features/workspaces/api/getWorkspaces.ts | 17 + .../src/features/workspaces/api/index.ts | 18 + .../features/workspaces/api/leaveWorkspace.ts | 10 + .../workspaces/api/removeWorkspaceMember.ts | 18 + .../workspaces/api/resendWorkspaceInvite.ts | 19 + .../workspaces/api/revokeWorkspaceInvite.ts | 18 + .../workspaces/api/sendEmailVerification.ts | 18 + .../workspaces/api/sendInviteVerification.ts | 23 + .../workspaces/api/updateWorkspace.ts | 32 + .../CreateWorkspaceDialog.test.tsx | 295 +++++++ .../CreateWorkspaceDialog/index.tsx | 212 +++++ .../components/InvitePage/InvitePage.test.tsx | 273 ++++++ .../components/InvitePage/index.tsx | 270 ++++++ .../PendingInvitationsList.test.tsx | 171 ++++ .../PendingInvitationsList/index.tsx | 162 ++++ .../VerifyEmailStep/VerifyEmailStep.test.tsx | 182 ++++ .../components/VerifyEmailStep/index.tsx | 334 ++++++++ .../WorkspaceMembers.test.tsx | 406 +++++++++ .../components/WorkspaceMembers/index.tsx | 443 ++++++++++ .../WorkspaceScopeLayout.test.tsx | 109 +++ .../components/WorkspaceScopeLayout/index.tsx | 55 ++ .../WorkspaceSettings.test.tsx | 251 ++++++ .../components/WorkspaceSettings/index.tsx | 194 +++++ .../WorkspaceSwitcher.test.tsx | 180 ++++ .../components/WorkspaceSwitcher/index.tsx | 157 ++++ .../WorkspacesSettingsSection.test.tsx | 129 +++ .../WorkspacesSettingsSection/index.tsx | 135 +++ .../features/workspaces/components/index.ts | 9 + .../contexts/CurrentWorkspaceContext.tsx | 61 ++ .../src/features/workspaces/contexts/index.ts | 1 + .../src/features/workspaces/hooks/index.ts | 19 + .../hooks/useAcceptWorkspaceInvite.tsx | 29 + .../hooks/useConfirmEmailVerification.tsx | 24 + .../workspaces/hooks/useCreateWorkspace.tsx | 27 + .../hooks/useCreateWorkspaceInvite.tsx | 28 + .../hooks/useDeclineWorkspaceInvite.tsx | 24 + .../hooks/useIsWorkspacesEnabled.test.tsx | 58 ++ .../hooks/useIsWorkspacesEnabled.tsx | 15 + .../workspaces/hooks/useLeaveWorkspace.tsx | 24 + .../hooks/useMyWorkspaceInvites.tsx | 24 + .../hooks/useRemoveWorkspaceMember.tsx | 24 + .../hooks/useResendWorkspaceInvite.tsx | 18 + .../hooks/useRevokeWorkspaceInvite.tsx | 24 + .../hooks/useSendEmailVerification.tsx | 18 + .../hooks/useSendInviteVerification.tsx | 18 + .../workspaces/hooks/useUpdateWorkspace.tsx | 25 + .../workspaces/hooks/useWorkspace.tsx | 34 + .../workspaces/hooks/useWorkspaceInvite.tsx | 25 + .../hooks/useWorkspaceInvitesForWorkspace.tsx | 31 + .../workspaces/hooks/useWorkspaceMembers.tsx | 31 + .../workspaces/hooks/useWorkspaces.tsx | 26 + .../client/src/features/workspaces/index.ts | 5 + .../src/features/workspaces/types/index.ts | 122 +++ .../client/src/mocks/data/userMe.ts | 6 + .../client/src/mocks/data/workspaces.ts | 8 + .../backend-api/client/src/mocks/handlers.ts | 145 +++- .../client/src/pages/AddUserFeeds.tsx | 16 +- .../client/src/pages/AppHeader.tsx | 75 +- .../client/src/pages/UserFeeds.tsx | 36 +- .../client/src/pages/UserSettings.tsx | 2 + .../client/src/pages/WorkspaceSettings.tsx | 18 + .../backend-api/client/src/pages/index.tsx | 105 +++ .../client/src/types/RouteParams.ts | 2 +- .../backend-api/client/src/utils/fetchRest.ts | 2 +- ...copy.ts => getStandardErrorCodeMessage.ts} | 54 ++ .../backend-api/client/src/utils/slugify.ts | 43 + ...orkspace-membership-and-ownership-model.md | 181 ++++ services/backend-api/package.json | 1 + services/backend-api/src/app.ts | 19 + services/backend-api/src/config.ts | 34 +- services/backend-api/src/container.ts | 37 +- .../feed-connections.handlers.ts | 284 +----- ...feed-management-invites.exception-codes.ts | 4 + .../user-feeds/user-feeds.handlers.ts | 299 ++----- .../features/user-feeds/user-feeds.schemas.ts | 6 + .../users/email-verification.handlers.ts | 42 + .../users/email-verification.routes.ts | 42 + .../users/email-verification.schemas.ts | 29 + .../users/email-verification.service.ts | 197 +++++ .../users/email-verification.template.ts | 12 + .../src/features/users/users.handlers.ts | 8 + .../workspace-invites.handlers.ts | 120 +++ .../workspace-invites.routes.ts | 63 ++ .../workspace-invites.schemas.ts | 26 + .../workspaces/workspace-invite.template.ts | 14 + .../workspaces/workspaces.handlers.ts | 206 +++++ .../features/workspaces/workspaces.hooks.ts | 21 + .../features/workspaces/workspaces.routes.ts | 89 ++ .../features/workspaces/workspaces.schemas.ts | 72 ++ .../features/workspaces/workspaces.service.ts | 651 ++++++++++++++ services/backend-api/src/infra/email-from.ts | 20 + .../backend-api/src/infra/error-handler.ts | 14 +- services/backend-api/src/infra/smtp.ts | 8 +- .../interfaces/user-feed.types.ts | 32 + .../src/repositories/interfaces/user.types.ts | 4 + .../email-verification.mongoose.repository.ts | 205 +++++ .../mongoose/user-feed.mongoose.repository.ts | 194 ++++- .../mongoose/user.mongoose.repository.ts | 19 + .../mongoose/workspace.mongoose.repository.ts | 810 ++++++++++++++++++ .../notifications/notifications.service.ts | 14 +- .../services/supporters/supporters.service.ts | 31 + .../user-feed-management-invites.service.ts | 7 + .../src/services/user-feeds/types.ts | 8 + .../services/user-feeds/user-feeds.service.ts | 213 +++-- .../src/shared/constants/api-errors.ts | 62 ++ ...user-feed-management-invites.exceptions.ts | 1 + .../src/shared/utils/feed-access.ts | 125 +++ .../src/shared/utils/normalizeEmail.ts | 6 + .../src/shared/utils/redactEmail.ts | 15 + .../backend-api/src/shared/utils/slugify.ts | 39 + .../test/api/email-verification.test.ts | 396 +++++++++ .../oauth-verified-email-invariant.test.ts | 136 +++ .../api/user-feeds/workspace-feeds.test.ts | 362 ++++++++ services/backend-api/test/api/users.test.ts | 73 ++ .../test/api/workspace-invite-claim.test.ts | 586 +++++++++++++ .../api/workspace-invite-controls.test.ts | 353 ++++++++ .../test/api/workspace-invites.test.ts | 482 +++++++++++ .../test/api/workspace-members.test.ts | 280 ++++++ .../backend-api/test/api/workspaces.test.ts | 449 ++++++++++ .../backend-api/test/helpers/test-context.ts | 74 +- .../test/helpers/test-http-server.ts | 8 + .../user-feed-management-invites.harness.ts | 7 +- .../test/helpers/user-feeds.harness.ts | 15 + .../backend-api/test/infra/email-from.test.ts | 56 ++ .../services/notifications.service.test.ts | 9 +- ...er-feed-management-invites.service.test.ts | 20 + 208 files changed, 15475 insertions(+), 1691 deletions(-) create mode 100644 e2e/helpers/log-to-file.ts create mode 100644 e2e/helpers/smtp.ts create mode 100644 e2e/helpers/workspaces-db.ts create mode 100644 e2e/mock-smtp-server.ts create mode 100644 e2e/tests/workspaces/email-verification-resend.spec.ts create mode 100644 e2e/tests/workspaces/email-verification-target-cap.spec.ts create mode 100644 e2e/tests/workspaces/workspace-feed-management-invites-disabled.spec.ts create mode 100644 e2e/tests/workspaces/workspace-feeds.spec.ts create mode 100644 e2e/tests/workspaces/workspace-invitation-roundtrip.spec.ts create mode 100644 e2e/tests/workspaces/workspace-invitations.spec.ts create mode 100644 e2e/tests/workspaces/workspace-invite-self-accept-guard.spec.ts create mode 100644 e2e/tests/workspaces/workspace-invite-verification-guard.spec.ts create mode 100644 e2e/tests/workspaces/workspace-members.spec.ts create mode 100644 e2e/tests/workspaces/workspaces-on-load.spec.ts create mode 100644 e2e/tests/workspaces/workspaces.spec.ts delete mode 100644 services/backend-api/client/docs/adr/005-team-scoping.md create mode 100644 services/backend-api/client/docs/adr/005-workspace-scoping.md create mode 100644 services/backend-api/client/docs/adr/008-workspace-ui.md delete mode 100644 services/backend-api/client/src/features/feed/components/AddUserFeedDialog/index.tsx create mode 100644 services/backend-api/client/src/features/feed/contexts/FeedScopeContext.tsx create mode 100644 services/backend-api/client/src/features/workspaces/api/acceptWorkspaceInvite.ts create mode 100644 services/backend-api/client/src/features/workspaces/api/confirmEmailVerification.ts create mode 100644 services/backend-api/client/src/features/workspaces/api/createWorkspace.ts create mode 100644 services/backend-api/client/src/features/workspaces/api/createWorkspaceInvite.ts create mode 100644 services/backend-api/client/src/features/workspaces/api/declineWorkspaceInvite.ts create mode 100644 services/backend-api/client/src/features/workspaces/api/getMyWorkspaceInvites.ts create mode 100644 services/backend-api/client/src/features/workspaces/api/getWorkspace.ts create mode 100644 services/backend-api/client/src/features/workspaces/api/getWorkspaceInvite.ts create mode 100644 services/backend-api/client/src/features/workspaces/api/getWorkspaceInvites.ts create mode 100644 services/backend-api/client/src/features/workspaces/api/getWorkspaceMembers.ts create mode 100644 services/backend-api/client/src/features/workspaces/api/getWorkspaces.ts create mode 100644 services/backend-api/client/src/features/workspaces/api/index.ts create mode 100644 services/backend-api/client/src/features/workspaces/api/leaveWorkspace.ts create mode 100644 services/backend-api/client/src/features/workspaces/api/removeWorkspaceMember.ts create mode 100644 services/backend-api/client/src/features/workspaces/api/resendWorkspaceInvite.ts create mode 100644 services/backend-api/client/src/features/workspaces/api/revokeWorkspaceInvite.ts create mode 100644 services/backend-api/client/src/features/workspaces/api/sendEmailVerification.ts create mode 100644 services/backend-api/client/src/features/workspaces/api/sendInviteVerification.ts create mode 100644 services/backend-api/client/src/features/workspaces/api/updateWorkspace.ts create mode 100644 services/backend-api/client/src/features/workspaces/components/CreateWorkspaceDialog/CreateWorkspaceDialog.test.tsx create mode 100644 services/backend-api/client/src/features/workspaces/components/CreateWorkspaceDialog/index.tsx create mode 100644 services/backend-api/client/src/features/workspaces/components/InvitePage/InvitePage.test.tsx create mode 100644 services/backend-api/client/src/features/workspaces/components/InvitePage/index.tsx create mode 100644 services/backend-api/client/src/features/workspaces/components/PendingInvitationsList/PendingInvitationsList.test.tsx create mode 100644 services/backend-api/client/src/features/workspaces/components/PendingInvitationsList/index.tsx create mode 100644 services/backend-api/client/src/features/workspaces/components/VerifyEmailStep/VerifyEmailStep.test.tsx create mode 100644 services/backend-api/client/src/features/workspaces/components/VerifyEmailStep/index.tsx create mode 100644 services/backend-api/client/src/features/workspaces/components/WorkspaceMembers/WorkspaceMembers.test.tsx create mode 100644 services/backend-api/client/src/features/workspaces/components/WorkspaceMembers/index.tsx create mode 100644 services/backend-api/client/src/features/workspaces/components/WorkspaceScopeLayout/WorkspaceScopeLayout.test.tsx create mode 100644 services/backend-api/client/src/features/workspaces/components/WorkspaceScopeLayout/index.tsx create mode 100644 services/backend-api/client/src/features/workspaces/components/WorkspaceSettings/WorkspaceSettings.test.tsx create mode 100644 services/backend-api/client/src/features/workspaces/components/WorkspaceSettings/index.tsx create mode 100644 services/backend-api/client/src/features/workspaces/components/WorkspaceSwitcher/WorkspaceSwitcher.test.tsx create mode 100644 services/backend-api/client/src/features/workspaces/components/WorkspaceSwitcher/index.tsx create mode 100644 services/backend-api/client/src/features/workspaces/components/WorkspacesSettingsSection/WorkspacesSettingsSection.test.tsx create mode 100644 services/backend-api/client/src/features/workspaces/components/WorkspacesSettingsSection/index.tsx create mode 100644 services/backend-api/client/src/features/workspaces/components/index.ts create mode 100644 services/backend-api/client/src/features/workspaces/contexts/CurrentWorkspaceContext.tsx create mode 100644 services/backend-api/client/src/features/workspaces/contexts/index.ts create mode 100644 services/backend-api/client/src/features/workspaces/hooks/index.ts create mode 100644 services/backend-api/client/src/features/workspaces/hooks/useAcceptWorkspaceInvite.tsx create mode 100644 services/backend-api/client/src/features/workspaces/hooks/useConfirmEmailVerification.tsx create mode 100644 services/backend-api/client/src/features/workspaces/hooks/useCreateWorkspace.tsx create mode 100644 services/backend-api/client/src/features/workspaces/hooks/useCreateWorkspaceInvite.tsx create mode 100644 services/backend-api/client/src/features/workspaces/hooks/useDeclineWorkspaceInvite.tsx create mode 100644 services/backend-api/client/src/features/workspaces/hooks/useIsWorkspacesEnabled.test.tsx create mode 100644 services/backend-api/client/src/features/workspaces/hooks/useIsWorkspacesEnabled.tsx create mode 100644 services/backend-api/client/src/features/workspaces/hooks/useLeaveWorkspace.tsx create mode 100644 services/backend-api/client/src/features/workspaces/hooks/useMyWorkspaceInvites.tsx create mode 100644 services/backend-api/client/src/features/workspaces/hooks/useRemoveWorkspaceMember.tsx create mode 100644 services/backend-api/client/src/features/workspaces/hooks/useResendWorkspaceInvite.tsx create mode 100644 services/backend-api/client/src/features/workspaces/hooks/useRevokeWorkspaceInvite.tsx create mode 100644 services/backend-api/client/src/features/workspaces/hooks/useSendEmailVerification.tsx create mode 100644 services/backend-api/client/src/features/workspaces/hooks/useSendInviteVerification.tsx create mode 100644 services/backend-api/client/src/features/workspaces/hooks/useUpdateWorkspace.tsx create mode 100644 services/backend-api/client/src/features/workspaces/hooks/useWorkspace.tsx create mode 100644 services/backend-api/client/src/features/workspaces/hooks/useWorkspaceInvite.tsx create mode 100644 services/backend-api/client/src/features/workspaces/hooks/useWorkspaceInvitesForWorkspace.tsx create mode 100644 services/backend-api/client/src/features/workspaces/hooks/useWorkspaceMembers.tsx create mode 100644 services/backend-api/client/src/features/workspaces/hooks/useWorkspaces.tsx create mode 100644 services/backend-api/client/src/features/workspaces/index.ts create mode 100644 services/backend-api/client/src/features/workspaces/types/index.ts create mode 100644 services/backend-api/client/src/mocks/data/workspaces.ts create mode 100644 services/backend-api/client/src/pages/WorkspaceSettings.tsx rename services/backend-api/client/src/utils/{getStandardErrorCodeMessage copy.ts => getStandardErrorCodeMessage.ts} (67%) create mode 100644 services/backend-api/client/src/utils/slugify.ts create mode 100644 services/backend-api/docs/adr/002-workspace-membership-and-ownership-model.md create mode 100644 services/backend-api/src/features/users/email-verification.handlers.ts create mode 100644 services/backend-api/src/features/users/email-verification.routes.ts create mode 100644 services/backend-api/src/features/users/email-verification.schemas.ts create mode 100644 services/backend-api/src/features/users/email-verification.service.ts create mode 100644 services/backend-api/src/features/users/email-verification.template.ts create mode 100644 services/backend-api/src/features/workspace-invites/workspace-invites.handlers.ts create mode 100644 services/backend-api/src/features/workspace-invites/workspace-invites.routes.ts create mode 100644 services/backend-api/src/features/workspace-invites/workspace-invites.schemas.ts create mode 100644 services/backend-api/src/features/workspaces/workspace-invite.template.ts create mode 100644 services/backend-api/src/features/workspaces/workspaces.handlers.ts create mode 100644 services/backend-api/src/features/workspaces/workspaces.hooks.ts create mode 100644 services/backend-api/src/features/workspaces/workspaces.routes.ts create mode 100644 services/backend-api/src/features/workspaces/workspaces.schemas.ts create mode 100644 services/backend-api/src/features/workspaces/workspaces.service.ts create mode 100644 services/backend-api/src/infra/email-from.ts create mode 100644 services/backend-api/src/repositories/mongoose/email-verification.mongoose.repository.ts create mode 100644 services/backend-api/src/repositories/mongoose/workspace.mongoose.repository.ts create mode 100644 services/backend-api/src/shared/utils/feed-access.ts create mode 100644 services/backend-api/src/shared/utils/normalizeEmail.ts create mode 100644 services/backend-api/src/shared/utils/redactEmail.ts create mode 100644 services/backend-api/src/shared/utils/slugify.ts create mode 100644 services/backend-api/test/api/email-verification.test.ts create mode 100644 services/backend-api/test/api/oauth-verified-email-invariant.test.ts create mode 100644 services/backend-api/test/api/user-feeds/workspace-feeds.test.ts create mode 100644 services/backend-api/test/api/workspace-invite-claim.test.ts create mode 100644 services/backend-api/test/api/workspace-invite-controls.test.ts create mode 100644 services/backend-api/test/api/workspace-invites.test.ts create mode 100644 services/backend-api/test/api/workspace-members.test.ts create mode 100644 services/backend-api/test/api/workspaces.test.ts create mode 100644 services/backend-api/test/infra/email-from.test.ts diff --git a/.gitignore b/.gitignore index 7e80ab6e6..57f6eb480 100644 --- a/.gitignore +++ b/.gitignore @@ -145,6 +145,7 @@ auth*.json **/playwright-report/ _bmad _bmad-output +skills-lock.json !docs/ docs/* diff --git a/README.md b/README.md index 7344ac3c1..ed3cad722 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,11 @@ While email notifications are available so that you may get notified when feeds - `BACKEND_API_SMTP_HOST` - `BACKEND_API_SMTP_USERNAME` - `BACKEND_API_SMTP_PASSWORD` -- `BACKEND_API_SMTP_FROM` + +Plus exactly one of the following (config validation fails on boot if SMTP is configured without one of these): + +- `BACKEND_API_SMTP_FROM_DOMAIN` — your sending domain only (e.g. `mydomain.com`). MonitoRSS will send from distinct per-purpose addresses on this domain such as `alerts@mydomain.com` (feed/connection alerts) and `noreply@mydomain.com` (email verification). Recommended. +- `BACKEND_API_SMTP_FROM` — a full RFC 5322 sender (e.g. `"MyService" `). Used verbatim for every outbound email. Use this only if your SMTP provider restricts you to a single fixed sender address. Takes precedence over `BACKEND_API_SMTP_FROM_DOMAIN`. Make sure to opt into email notifications in the control panel's user settings page afterwards. diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml index 9bf640244..6467fedd7 100644 --- a/docker-compose.e2e.yml +++ b/docker-compose.e2e.yml @@ -13,6 +13,11 @@ services: - /data/db - /data/configdb ports: + # Default host 27019 (not 27018) so the e2e stack can run alongside the dev + # stack (docker-compose.dev.yml publishes mongo on 27018). Parameterized so + # concurrent e2e runs can each bind a distinct host port. The backend reaches + # mongo over the internal network (mongo:27017), unaffected by this mapping; + # only host-side e2e helpers use it (see e2e/helpers/constants.ts MONGO_URI). - "${E2E_MONGO_PORT:-27019}:27017" command: ["--replSet", "dbrs", "--bind_ip_all", "--port", "27017"] networks: @@ -266,6 +271,14 @@ services: - BACKEND_API_MONGODB_URI=mongodb://mongo:27017/rss?replicaSet=dbrs&directConnection=true - BACKEND_API_RABBITMQ_BROKER_URL=amqp://guest:guest@rabbitmq-broker:5672/ - BACKEND_API_LOGIN_REDIRECT_URI=http://localhost:${E2E_FRONTEND_PORT:-3100} + # Mock mailer (plain SMTP on the host, reached via host.docker.internal) so + # the email-verification one-time-code flow can be driven through the UI. + - BACKEND_API_SMTP_HOST=host.docker.internal + - BACKEND_API_SMTP_PORT=${E2E_MOCK_SMTP_PORT:-3004} + - BACKEND_API_SMTP_SECURE=false + - BACKEND_API_SMTP_USERNAME=mock + - BACKEND_API_SMTP_PASSWORD=mock + - BACKEND_API_SMTP_FROM_DOMAIN=example.com - BACKEND_API_PADDLE_KEY=${BACKEND_API_PADDLE_KEY:-} - BACKEND_API_PADDLE_URL=${BACKEND_API_PADDLE_URL:-} - BACKEND_API_PADDLE_WEBHOOK_SECRET=${BACKEND_API_PADDLE_WEBHOOK_SECRET:-} diff --git a/e2e/README.md b/e2e/README.md index 2a9265ae8..0c9095bbd 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -10,10 +10,16 @@ The E2E Docker stack (defined in `docker-compose.e2e.yml`) provides all required `e2e-mock.sh` is the canonical wrapper: it brings up the full Docker stack (`up -d --build --wait`), runs Playwright, and tears the stack down on exit. **Any arguments after the script name are forwarded straight to `playwright test`**, so you can scope a run to a single file and/or project. Always go through this script (or the `npm run e2e*` aliases) rather than starting the stack and Playwright by hand — `--build` is required because the `web-api` source is baked into its image (no bind mount), so backend changes won't take effect otherwise. -The script writes two logs to `e2e/logs/` (gitignored) that outlive the torn-down stack: +The script writes logs to `e2e/logs/` (gitignored) that outlive the torn-down stack. **If a run fails, read `logs/combined.log` first** — it is the one file containing everything, top to bottom: -- `logs/playwright.log` — the full Playwright run output. -- `logs/docker-stack.log` — `docker compose logs` for all services, captured just before teardown. This is the only place to inspect container-side behaviour after a run, e.g. inbound Paddle webhooks in `web-api` ("Paddle webhook received" / "Invalid signature received for paddle webhook event"). +- `logs/combined.log` — **read this first after a run ends.** Playwright run output + every container's logs + all three mock servers, concatenated under `===== SECTION =====` headers. Assembled on teardown. The script prints this path when the run starts and again when it ends. +- `logs/playwright.log` — the Playwright run output (written live via `tee`). +- `logs/docker-stack.log` — `docker compose logs --timestamps --follow` for all services, streamed **live** for the whole run. The place to inspect container-side behaviour, e.g. inbound Paddle webhooks in `web-api` ("Paddle webhook received" / "Invalid signature received for paddle webhook event"). +- `logs/mock-rss.log`, `logs/mock-discord.log`, `logs/mock-smtp.log` — the host-side mock servers Playwright launches, written live. Look here for things like `[mock-discord] Unmatched: ` when a request isn't being mocked. + +`combined.log` is assembled on teardown, so it only exists once the run ends. **While a run is still going (e.g. a hang), read the four source files above — they are all written live.** + +Concurrent runs (`E2E_INSTANCE > 0`) suffix every log file with `-` (e.g. `combined-1.log`). ```bash # Run all regular (non-paddle) tests via Docker stack (defaults to --project=e2e-web) diff --git a/e2e/e2e-mock.sh b/e2e/e2e-mock.sh index b53ced66d..0ba89bd96 100644 --- a/e2e/e2e-mock.sh +++ b/e2e/e2e-mock.sh @@ -92,6 +92,9 @@ PLAYWRIGHT_ARGS="${@:---project=e2e-web}" LOG_DIR="$SCRIPT_DIR/logs" RUN_LOG="$LOG_DIR/playwright${INSTANCE_SUFFIX}.log" DOCKER_LOG="$LOG_DIR/docker-stack${INSTANCE_SUFFIX}.log" +# Single file an agent can read top-to-bottom after a failure: Playwright run + +# every container's logs + all three host-side mock servers, with section headers. +COMBINED_LOG="$LOG_DIR/combined${INSTANCE_SUFFIX}.log" mkdir -p "$LOG_DIR" # When a Paddle key is present, create an EPHEMERAL notification setting for this run @@ -121,11 +124,32 @@ cleanup() { echo "Deleting ephemeral Paddle notification setting: $PADDLE_SETTING_EPHEMERAL" npx tsx "$SCRIPT_DIR/scripts/paddle-notification-setting.ts" delete "$PADDLE_SETTING_EPHEMERAL" || true fi - echo "Capturing container logs to $DOCKER_LOG ..." - docker compose -f "$COMPOSE_FILE" -p "$COMPOSE_PROJECT_NAME" logs --no-color \ - >"$DOCKER_LOG" 2>&1 || true + # Stop the live `logs --follow` (started after stack boot). $DOCKER_LOG is already + # populated in real time, so there's nothing to capture here — just end the stream. + if [ -n "${DOCKER_LOGS_PID:-}" ]; then + kill "$DOCKER_LOGS_PID" 2>/dev/null || true + wait "$DOCKER_LOGS_PID" 2>/dev/null || true + fi + + # Fold everything into one file so an agent can read a single log after a failure. + # The mock-*.log files are written by Playwright's webServers (see playwright.config.ts). + echo "Writing combined log to $COMBINED_LOG ..." + { + echo "===== PLAYWRIGHT =====" + cat "$RUN_LOG" 2>/dev/null || echo "(no playwright log)" + echo + echo "===== DOCKER STACK =====" + cat "$DOCKER_LOG" 2>/dev/null || echo "(no docker log)" + for mock in rss discord smtp; do + echo + echo "===== MOCK: $mock =====" + cat "$LOG_DIR/mock-${mock}${INSTANCE_SUFFIX}.log" 2>/dev/null || echo "(no mock-$mock log)" + done + } >"$COMBINED_LOG" 2>&1 || true + echo "Tearing down E2E Docker stack..." docker compose -f "$COMPOSE_FILE" -p "$COMPOSE_PROJECT_NAME" down --volumes --remove-orphans + echo "Combined log (read this first if the run failed): $COMBINED_LOG" } trap cleanup EXIT @@ -139,6 +163,21 @@ echo "Starting E2E Docker stack (instance: $E2E_INSTANCE, project: $COMPOSE_PROJ echo " backend=$E2E_BACKEND_PORT frontend=$E2E_FRONTEND_PORT mongo=$E2E_MONGO_PORT rss-mock=$E2E_MOCK_RSS_PORT discord-mock=$E2E_MOCK_DISCORD_PORT" docker compose -f "$COMPOSE_FILE" -p "$COMPOSE_PROJECT_NAME" up -d --build --wait +# Follow container logs into $DOCKER_LOG live, so an agent inspecting a hung/slow run +# sees current container output without waiting for teardown. The follower is stopped +# in cleanup. (Playwright and mock-server logs are already written live elsewhere.) +docker compose -f "$COMPOSE_FILE" -p "$COMPOSE_PROJECT_NAME" logs --no-color --timestamps --follow \ + >"$DOCKER_LOG" 2>&1 & +DOCKER_LOGS_PID=$! + echo "Running E2E tests... (output also written to $RUN_LOG)" +echo "On failure, read the combined log (Playwright + all containers + mock servers): $COMBINED_LOG" +# Playwright discovers its config from the CURRENT WORKING DIRECTORY, and the only +# config is e2e/playwright.config.ts (there is none at the repo root). This script +# does not cd, so it must be run with cwd = e2e/ (the `npm run e2e*` aliases do this). +# Run it from the repo root instead and Playwright finds no config: it falls back to +# an implicit unnamed project with no baseURL, so `page.goto("/feeds")` fails with +# "Cannot navigate to invalid URL" and `--project=e2e-web` errors with +# 'Available projects: ""'. Run from e2e/ (or via `npm run e2e -- `). E2E_BACKEND_URL="$BACKEND_URL" E2E_BASE_URL="$FRONTEND_URL" \ npx playwright test $PLAYWRIGHT_ARGS 2>&1 | tee "$RUN_LOG" diff --git a/e2e/helpers/api.ts b/e2e/helpers/api.ts index dbcaec276..aee261b49 100644 --- a/e2e/helpers/api.ts +++ b/e2e/helpers/api.ts @@ -401,6 +401,25 @@ export async function copyConnectionSettings( } } +export async function createWorkspace( + page: Page, + workspace: { name: string; slug: string }, +): Promise<{ id: string; name: string; slug: string }> { + const response = await page.request.post("/api/v1/workspaces", { data: workspace }); + + if (!response.ok()) { + const text = await response.text(); + throw new Error(`Failed to create workspace: ${response.status()} - ${text}`); + } + + const data = await response.json(); + return { + id: data.result.id, + name: data.result.name, + slug: data.result.slug, + }; +} + export async function getAllUserFeeds(page: Page): Promise { const response = await page.request.get( "/api/v1/user-feeds?limit=100&offset=0", diff --git a/e2e/helpers/constants.ts b/e2e/helpers/constants.ts index 216d4343c..3aa6f8db7 100644 --- a/e2e/helpers/constants.ts +++ b/e2e/helpers/constants.ts @@ -8,6 +8,19 @@ export const MOCK_RSS_FEED_404_URL = `http://${MOCK_RSS_HOST}:${MOCK_RSS_SERVER_ export const MOCK_RSS_HTML_PAGE_URL = `http://${MOCK_RSS_HOST}:${MOCK_RSS_SERVER_PORT}/html-with-feed?v=${Date.now()}`; export const FRONTEND_URL = process.env.E2E_BASE_URL || "http://localhost:3000"; +// Default host 27019 matches docker-compose.e2e.yml's mongo mapping, kept distinct +// from the dev stack's 27018 so both stacks can run at once; parameterized so +// concurrent e2e runs can each bind a distinct host port. export const MONGO_URI = `mongodb://127.0.0.1:${process.env.E2E_MONGO_PORT || 27019}/rss`; export const MOCK_DISCORD_SERVER_PORT = Number(process.env.E2E_MOCK_DISCORD_PORT) || 3002; + +// The mock mailer listens for plain SMTP on one port and exposes captured +// messages over HTTP on another (the HTTP port doubles as Playwright's readiness +// probe, since it can't health-check a raw SMTP socket). The backend (in Docker) +// reaches the SMTP port via host.docker.internal. +export const MOCK_SMTP_SERVER_PORT = + Number(process.env.E2E_MOCK_SMTP_PORT) || 3004; +export const MOCK_SMTP_HTTP_PORT = + Number(process.env.E2E_MOCK_SMTP_HTTP_PORT) || 3005; +export const MOCK_SMTP_HOST = "host.docker.internal"; diff --git a/e2e/helpers/log-to-file.ts b/e2e/helpers/log-to-file.ts new file mode 100644 index 000000000..07fa20096 --- /dev/null +++ b/e2e/helpers/log-to-file.ts @@ -0,0 +1,26 @@ +import { createWriteStream, mkdirSync } from "fs"; +import { join } from "path"; + +// Mock servers run on the HOST (launched by Playwright's webServer), so their console +// output is otherwise lost. Tee it to logs/.log — done in TS rather than a +// shell `| tee` so it works regardless of the OS shell Playwright spawns (cmd.exe on +// Windows has no tee). e2e-mock.sh folds these files into logs/combined.log on teardown. +export function teeConsoleToFile(name: string): void { + const suffix = + !process.env.E2E_INSTANCE || process.env.E2E_INSTANCE === "0" + ? "" + : `-${process.env.E2E_INSTANCE}`; + const logDir = join(__dirname, "..", "logs"); + mkdirSync(logDir, { recursive: true }); + const stream = createWriteStream(join(logDir, `${name}${suffix}.log`), { + flags: "w", + }); + + for (const level of ["log", "error", "warn", "info"] as const) { + const original = console[level].bind(console); + console[level] = (...args: unknown[]) => { + original(...args); + stream.write(`${args.map(String).join(" ")}\n`); + }; + } +} diff --git a/e2e/helpers/smtp.ts b/e2e/helpers/smtp.ts new file mode 100644 index 000000000..feed51c3b --- /dev/null +++ b/e2e/helpers/smtp.ts @@ -0,0 +1,83 @@ +import { MOCK_SMTP_HTTP_PORT } from "./constants"; + +// The mock mailer runs on the host (like the mock RSS/Discord servers), so its +// HTTP control surface is reachable on localhost from the Playwright process. +const SMTP_HTTP_URL = `http://localhost:${MOCK_SMTP_HTTP_PORT}`; + +/** + * Poll the mock mailer for the latest verification code captured for `email`. + * The send is async (UI click -> backend -> SMTP), so retry briefly until the + * code arrives. Returns the 6-digit code or throws if none arrives in time. + */ +export async function waitForVerificationCode( + email: string, + { timeoutMs = 10000, intervalMs = 250 }: { timeoutMs?: number; intervalMs?: number } = {}, +): Promise { + const deadline = Date.now() + timeoutMs; + const to = encodeURIComponent(email.trim().toLowerCase()); + + while (Date.now() < deadline) { + const res = await fetch(`${SMTP_HTTP_URL}/code?to=${to}`); + if (res.ok) { + const { code } = (await res.json()) as { code: string | null }; + if (code) return code; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + throw new Error(`No verification code captured for ${email} within ${timeoutMs}ms`); +} + +/** + * Poll the mock mailer for the invitation link captured for `email` (the + * /invites/ URL in the workspace-invitation notification email). Returns the + * absolute URL or throws if none arrives in time. + */ +export async function waitForInviteLink( + email: string, + { timeoutMs = 10000, intervalMs = 250 }: { timeoutMs?: number; intervalMs?: number } = {}, +): Promise { + const deadline = Date.now() + timeoutMs; + const to = encodeURIComponent(email.trim().toLowerCase()); + + while (Date.now() < deadline) { + const res = await fetch(`${SMTP_HTTP_URL}/invite-link?to=${to}`); + if (res.ok) { + const { inviteLink } = (await res.json()) as { inviteLink: string | null }; + if (inviteLink) return inviteLink; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + throw new Error(`No invitation link captured for ${email} within ${timeoutMs}ms`); +} + +/** + * Non-throwing counterpart to waitForVerificationCode: polls for a short, fixed + * window and returns the captured code if one arrives, or null if none does. + * Used to ASSERT NON-DELIVERY — that a code was never sent to a given address — + * without paying a long timeout for the expected-empty case. + */ +export async function peekVerificationCode( + email: string, + { windowMs = 3000, intervalMs = 250 }: { windowMs?: number; intervalMs?: number } = {}, +): Promise { + const deadline = Date.now() + windowMs; + const to = encodeURIComponent(email.trim().toLowerCase()); + + while (Date.now() < deadline) { + const res = await fetch(`${SMTP_HTTP_URL}/code?to=${to}`); + if (res.ok) { + const { code } = (await res.json()) as { code: string | null }; + if (code) return code; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + return null; +} + +/** Clear all captured mail (call before triggering a fresh send). */ +export async function resetCapturedMail(): Promise { + await fetch(`${SMTP_HTTP_URL}/reset`, { method: "POST" }); +} diff --git a/e2e/helpers/workspaces-db.ts b/e2e/helpers/workspaces-db.ts new file mode 100644 index 000000000..c8a117e37 --- /dev/null +++ b/e2e/helpers/workspaces-db.ts @@ -0,0 +1,201 @@ +import { MongoClient, ObjectId } from "mongodb"; +import { MONGO_URI } from "./constants"; + +async function withDb( + fn: (db: ReturnType) => Promise, +): Promise { + const client = new MongoClient(MONGO_URI, { directConnection: true }); + + try { + await client.connect(); + return await fn(client.db()); + } finally { + await client.close(); + } +} + +async function withUsersCollection( + fn: (collection: ReturnType["collection"]>) => Promise, +): Promise { + return withDb((db) => fn(db.collection("users"))); +} + +/** + * Seed the per-user workspaces rollout flag (`featureFlags.workspaces`) directly, + * mirroring `setSupporterStatusInDb`. This flag is the sole gate for the workspaces + * feature; without it the routes return 404. + */ +export async function enableWorkspacesFeatureInDb(discordUserId: string): Promise { + await withUsersCollection((users) => + users.updateOne( + { discordUserId }, + { $set: { "featureFlags.workspaces": true } }, + { upsert: true }, + ), + ); +} + +/** + * Write a verified email directly so the passwordless send/confirm flow needs no + * SMTP in tests. The email is unique per test user, so it never collides with + * the unique `verifiedEmail` index. + */ +export async function setVerifiedEmailInDb( + discordUserId: string, + email: string, +): Promise { + await withUsersCollection((users) => + users.updateOne( + { discordUserId }, + { + $set: { + verifiedEmail: email.trim().toLowerCase(), + verifiedEmailVerifiedAt: new Date(), + }, + }, + { upsert: true }, + ), + ); +} + +/** + * Seed a workspace plus a pending invitation addressed to `email` directly, + * mirroring how the backend's `createInvite` persists a row. The invitee (the + * authenticated test user) never owns the workspace — an arbitrary inviter + * ObjectId stands in for the owner — so the test exercises the pure invitee + * path: land on the invitation, verify the matching email, accept, and gain + * membership. Returns the invitation id (the `/invites/:inviteId` segment). + */ +export async function seedWorkspaceInviteInDb(input: { + workspaceName: string; + email: string; +}): Promise<{ inviteId: string; workspaceId: string }> { + return withDb(async (db) => { + const now = new Date(); + const inviterUserId = new ObjectId(); + const workspaceId = new ObjectId(); + const inviteId = new ObjectId(); + const slug = `seeded-${inviteId.toHexString()}`; + + await db.collection("workspaces").insertOne({ + _id: workspaceId, + name: input.workspaceName, + slug, + createdByUserId: inviterUserId, + createdAt: now, + updatedAt: now, + }); + + await db.collection("workspaceinvites").insertOne({ + _id: inviteId, + workspaceId, + email: input.email.trim().toLowerCase(), + role: "admin", + invitedByUserId: inviterUserId, + lastSentAt: now, + createdAt: now, + updatedAt: now, + }); + + return { inviteId: inviteId.toHexString(), workspaceId: workspaceId.toHexString() }; + }); +} + +/** + * Resolve a Discord user id to the user's Mongo `_id`. Membership rows bind to the + * Mongo user id (Discord-agnostic), so seeding the authenticated test user as a + * member needs their `_id`, not their Discord id. The user document is created on + * first authenticated request, so this is read after the app has loaded. + */ +export async function getUserMongoIdFromDiscordId(discordUserId: string): Promise { + return withUsersCollection(async (users) => { + const user = await users.findOne({ discordUserId }); + + if (!user) { + throw new Error(`No user found for discordUserId ${discordUserId}`); + } + + return user._id as ObjectId; + }); +} + +/** + * Seed a workspace owned/joined by the authenticated test user, plus any additional + * members and pending invitations, directly — mirroring how the backend persists + * memberships and invites. This exercises the owner/admin member-management view: + * the test user is a real member of a workspace with co-members and outstanding + * invitations to manage. Returns the workspace slug for navigation and the inviter + * Mongo id used for the seeded invites. + */ +export async function seedWorkspaceWithMembershipsInDb(input: { + workspaceName: string; + // The authenticated test user's Mongo id and the role they hold. + selfUserId: ObjectId; + selfRole: "owner" | "admin"; + // Additional members keyed by an arbitrary identity (Discord id stands in for a + // real co-member's user document, created here so the member list can render). + otherMembers?: Array<{ role: "owner" | "admin"; discordUserId: string }>; + // Pending invitations addressed to these emails, all invited by the test user. + invitedEmails?: string[]; + // Backdates the seeded invites' lastSentAt. Defaults to now (matching a freshly + // sent invite); pass an older date to put the invite past its resend cooldown so + // a resend succeeds immediately rather than tripping the per-invite window. + invitedLastSentAt?: Date; +}): Promise<{ workspaceId: string; slug: string }> { + return withDb(async (db) => { + const now = new Date(); + const workspaceId = new ObjectId(); + const slug = `seeded-${workspaceId.toHexString()}`; + + await db.collection("workspaces").insertOne({ + _id: workspaceId, + name: input.workspaceName, + slug, + createdByUserId: input.selfUserId, + createdAt: now, + updatedAt: now, + }); + + const memberships: Array> = [ + { + workspaceId, + userId: input.selfUserId, + role: input.selfRole, + createdAt: now, + updatedAt: now, + }, + ]; + + for (const member of input.otherMembers ?? []) { + const memberUserId = new ObjectId(); + await db + .collection("users") + .insertOne({ _id: memberUserId, discordUserId: member.discordUserId }); + memberships.push({ + workspaceId, + userId: memberUserId, + role: member.role, + createdAt: new Date(now.getTime() + 1), + updatedAt: now, + }); + } + + await db.collection("workspacememberships").insertMany(memberships); + + for (const email of input.invitedEmails ?? []) { + await db.collection("workspaceinvites").insertOne({ + _id: new ObjectId(), + workspaceId, + email: email.trim().toLowerCase(), + role: "admin", + invitedByUserId: input.selfUserId, + lastSentAt: input.invitedLastSentAt ?? now, + createdAt: now, + updatedAt: now, + }); + } + + return { workspaceId: workspaceId.toHexString(), slug }; + }); +} + diff --git a/e2e/mock-discord-server.ts b/e2e/mock-discord-server.ts index 3525fbcc5..57e343cd9 100644 --- a/e2e/mock-discord-server.ts +++ b/e2e/mock-discord-server.ts @@ -1,6 +1,7 @@ import { createServer } from "http"; import { URL } from "url"; import { MOCK_DISCORD_SERVER_PORT } from "./helpers/constants"; +import { teeConsoleToFile } from "./helpers/log-to-file"; import { MOCK_DISCORD_USER, MOCK_DISCORD_BOT_USER, @@ -11,6 +12,8 @@ import { MOCK_DISCORD_USER_ID, } from "./helpers/mock-discord-data"; +teeConsoleToFile("mock-discord"); + interface StoredWebhook { id: string; type: 1; @@ -82,7 +85,34 @@ interface Route { params: Record, req: import("http").IncomingMessage, body?: unknown, - ) => { status: number; body?: unknown }; + ) => { status: number; body?: unknown; headers?: Record }; +} + +// The interactive OAuth identity is carried end to end via the authorization +// code: the mock authorize endpoint mints `mock-code-`, the token +// endpoint echoes it back as `mock-token-`, and getUserFromRequest +// derives the id from that Bearer token. Each authorize call mints a fresh +// distinct Discord id, so a logged-out test bootstraps a brand-new user (the +// invite is keyed by email, never by Discord id, so the test needs no advance +// knowledge of the minted id). +const DEFAULT_OAUTH_DISCORD_ID = MOCK_DISCORD_USER_ID; + +let oauthUserCounter = 0; + +function mintOAuthDiscordId(): string { + // 17-digit snowflake-shaped id in a range that won't collide with the fixed + // MOCK_DISCORD_USER_ID or the fixtures' generated ids. + return String(800000000000000000n + BigInt(++oauthUserCounter)); +} + +function discordIdFromCode(code: string | undefined): string { + const match = /^mock-code-(\d+)$/.exec(code ?? ""); + return match ? match[1] : DEFAULT_OAUTH_DISCORD_ID; +} + +function parseFormBody(body: unknown): Record { + if (typeof body !== "string") return {}; + return Object.fromEntries(new URLSearchParams(body)); } function getUserFromRequest(req: import("http").IncomingMessage) { @@ -353,20 +383,63 @@ const routes: Route[] = [ pattern: "/api/v10/users/:id", handler: () => ({ status: 200, body: MOCK_DISCORD_BOT_USER }), }, - // OAuth token refresh fallback + // Interactive OAuth consent screen. The real Discord shows a consent page then + // 302s the browser to redirect_uri with ?code=&state=. The mock skips consent: + // it mints a fresh Discord id, encodes it in the code, and immediately + // redirects back with the state echoed byte-for-byte (the backend validates it + // against the value it stored in the session). + { + method: "GET", + pattern: "/api/v9/oauth2/authorize", + handler: (_params, req) => { + const url = new URL( + req.url || "/", + `http://localhost:${MOCK_DISCORD_SERVER_PORT}`, + ); + const redirectUri = url.searchParams.get("redirect_uri"); + const state = url.searchParams.get("state"); + + if (!redirectUri) { + return { status: 400, body: { message: "missing redirect_uri" } }; + } + + const code = `mock-code-${mintOAuthDiscordId()}`; + const location = new URL(redirectUri); + location.searchParams.set("code", code); + if (state !== null) { + location.searchParams.set("state", state); + } + + return { + status: 302, + headers: { Location: location.toString() }, + }; + }, + }, + // OAuth token exchange (authorization_code) and refresh. The access token + // carries the Discord id parsed from the code so /users/@me resolves to the + // user the authorize step minted; a refresh (no code) keeps the default user. { method: "POST", pattern: "/api/v9/oauth2/token", - handler: () => ({ - status: 200, - body: { - access_token: `refreshed-token-${MOCK_DISCORD_USER_ID}`, - token_type: "Bearer", - expires_in: 604800, - refresh_token: "new-mock-refresh-token", - scope: "identify guilds", - }, - }), + handler: (_params, _req, body) => { + const form = parseFormBody(body); + const discordId = + form.grant_type === "authorization_code" + ? discordIdFromCode(form.code) + : DEFAULT_OAUTH_DISCORD_ID; + + return { + status: 200, + body: { + access_token: `mock-token-${discordId}`, + token_type: "Bearer", + expires_in: 604800, + refresh_token: "new-mock-refresh-token", + scope: "identify guilds", + }, + }; + }, }, ]; @@ -398,6 +471,15 @@ const server = createServer((req, res) => { const params = matchRoute(route.pattern, pathname); if (params) { const result = route.handler(params, req, body); + + // A redirect (or any handler-supplied headers) bypasses the JSON + // responder so the browser-facing OAuth authorize step can 302. + if (result.headers) { + res.writeHead(result.status, result.headers); + res.end(); + return; + } + jsonResponse(res, result.status, result.body); return; } diff --git a/e2e/mock-rss-server.ts b/e2e/mock-rss-server.ts index d94c6f2dc..dc6b7525c 100644 --- a/e2e/mock-rss-server.ts +++ b/e2e/mock-rss-server.ts @@ -3,6 +3,9 @@ import { readFileSync } from "fs"; import { join } from "path"; import { URL } from "url"; import { MOCK_RSS_SERVER_PORT } from "./helpers/constants"; +import { teeConsoleToFile } from "./helpers/log-to-file"; + +teeConsoleToFile("mock-rss"); const server = createServer((req, res) => { const parsedUrl = new URL( diff --git a/e2e/mock-smtp-server.ts b/e2e/mock-smtp-server.ts new file mode 100644 index 000000000..0ac82448d --- /dev/null +++ b/e2e/mock-smtp-server.ts @@ -0,0 +1,218 @@ +import { createServer as createTcpServer, type Socket } from "net"; +import { createServer as createHttpServer } from "http"; +import { MOCK_SMTP_SERVER_PORT, MOCK_SMTP_HTTP_PORT } from "./helpers/constants"; +import { teeConsoleToFile } from "./helpers/log-to-file"; + +teeConsoleToFile("mock-smtp"); + +// A dependency-free mock mailer for e2e. It speaks just enough SMTP to accept a +// message from nodemailer over a plain (non-TLS) connection, captures the body, +// extracts the most recent 6-digit verification code per recipient, and exposes +// it over HTTP so a Playwright test can read the code it would have received by +// email. NOT a real mail server — no TLS, no auth enforcement, no delivery. + +interface CapturedMail { + to: string; + code: string | null; + // The first /invites/ link found in the body (the workspace-invitation + // notification email); null for other mails (e.g. the verification code mail). + inviteLink: string | null; + receivedAt: number; +} + +// Latest captured mail per (lowercased) recipient address. +const mailboxes = new Map(); + +function extractRecipient(rcptLine: string): string | null { + // RCPT TO: (case-insensitive, optional angle brackets / params) + const match = /RCPT TO:\s*\s]+)>?/i.exec(rcptLine); + return match ? match[1].toLowerCase() : null; +} + +// Nodemailer transfer-encodes the body (quoted-printable, sometimes base64), and +// quoted-printable soft-wraps long lines with "=\r\n" — which can fall in the +// middle of the 6-digit code. Decode both so the code is contiguous before +// extracting. The verification email renders the code as the only 6-digit run. +function decodeQuotedPrintable(input: string): string { + return input + .replace(/=\r?\n/g, "") // soft line breaks + .replace(/=([0-9A-Fa-f]{2})/g, (_m, hex) => + String.fromCharCode(parseInt(hex, 16)), + ); +} + +// Drop the SMTP/MIME headers (everything up to the first blank line) so a +// 6-digit run in a header — notably the recipient address, which in tests may +// contain digits — is never mistaken for the code. Only the message body is +// scanned. +function stripHeaders(raw: string): string { + const blank = raw.search(/\r?\n\r?\n/); + return blank === -1 ? raw : raw.slice(blank); +} + +function decodeBodyCandidates(raw: string): string[] { + const body = stripHeaders(raw); + const candidates = [body, decodeQuotedPrintable(body)]; + + // A base64 body is long runs of base64 chars; try decoding the longest such run. + const b64 = body.match(/[A-Za-z0-9+/=\r\n]{40,}/g); + if (b64) { + for (const chunk of b64) { + try { + candidates.push(Buffer.from(chunk.replace(/\s+/g, ""), "base64").toString("utf8")); + } catch { + // not base64 — ignore + } + } + } + + return candidates; +} + +function extractCode(body: string): string | null { + for (const candidate of decodeBodyCandidates(body)) { + const match = /(?"> button. + const match = /https?:\/\/[^\s"'<>]*\/invites\/[A-Za-z0-9]+/.exec(candidate); + if (match) return match[0]; + } + return null; +} + +const smtpServer = createTcpServer((socket: Socket) => { + socket.setEncoding("utf8"); + + let buffer = ""; + let inData = false; + let dataBuffer = ""; + const recipients: string[] = []; + + const send = (line: string) => socket.write(`${line}\r\n`); + + send("220 mock-smtp ready"); + + socket.on("data", (chunk: string) => { + buffer += chunk; + + // DATA mode: accumulate until the lone-dot terminator. + if (inData) { + dataBuffer += chunk; + const terminator = dataBuffer.indexOf("\r\n.\r\n"); + if (terminator !== -1) { + const message = dataBuffer.slice(0, terminator); + const inviteLink = extractInviteLink(message); + // The invitation email and the verification-code email are distinct + // mails. Never read a "code" out of an invitation email — its body + // carries a 6-digit-tailed invite ObjectId in the link that would + // otherwise be mistaken for a verification code. + const code = inviteLink ? null : extractCode(message); + // eslint-disable-next-line no-console + console.log( + `[mock-smtp] captured for ${recipients.join(",")}: code=${code ?? "NONE"} link=${inviteLink ?? "NONE"} (raw ${message.length} bytes)`, + ); + for (const to of recipients) { + mailboxes.set(to, { to, code, inviteLink, receivedAt: Date.now() }); + } + inData = false; + dataBuffer = ""; + buffer = ""; + send("250 OK: queued"); + } + return; + } + + let newlineIndex = buffer.indexOf("\r\n"); + while (newlineIndex !== -1) { + const line = buffer.slice(0, newlineIndex); + buffer = buffer.slice(newlineIndex + 2); + const verb = line.slice(0, 4).toUpperCase(); + + if (verb === "EHLO" || verb === "HELO") { + send("250 mock-smtp"); + } else if (verb === "MAIL") { + send("250 OK"); + } else if (verb === "RCPT") { + const addr = extractRecipient(line); + if (addr) recipients.push(addr); + send("250 OK"); + } else if (verb === "DATA") { + inData = true; + send("354 End data with ."); + // Anything already buffered after DATA is message content. + if (buffer.length) { + socket.emit("data", buffer); + buffer = ""; + } + return; + } else if (verb === "QUIT") { + send("221 Bye"); + socket.end(); + return; + } else if (verb === "RSET") { + recipients.length = 0; + send("250 OK"); + } else if (verb === "AUTH") { + // Accept any credentials — the mock does not enforce auth. + send("235 Authentication successful"); + } else { + send("250 OK"); + } + + newlineIndex = buffer.indexOf("\r\n"); + } + }); + + socket.on("error", () => { + // Ignore — nodemailer may drop the connection abruptly after QUIT. + }); +}); + +smtpServer.listen(MOCK_SMTP_SERVER_PORT, () => { + // eslint-disable-next-line no-console + console.log(`[mock-smtp] SMTP listening on ${MOCK_SMTP_SERVER_PORT}`); +}); + +// HTTP control surface: GET /code?to= returns the latest captured code. +// Also serves as Playwright's readiness probe (it can't health-check raw SMTP). +const httpServer = createHttpServer((req, res) => { + const url = new URL(req.url || "/", `http://localhost:${MOCK_SMTP_HTTP_PORT}`); + + if (url.pathname === "/code") { + const to = (url.searchParams.get("to") || "").toLowerCase(); + const captured = mailboxes.get(to); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ code: captured?.code ?? null })); + return; + } + + if (url.pathname === "/invite-link") { + const to = (url.searchParams.get("to") || "").toLowerCase(); + const captured = mailboxes.get(to); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ inviteLink: captured?.inviteLink ?? null })); + return; + } + + if (url.pathname === "/reset") { + mailboxes.clear(); + res.writeHead(204); + res.end(); + return; + } + + // Root: readiness probe. + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ status: "ok" })); +}); + +httpServer.listen(MOCK_SMTP_HTTP_PORT, () => { + // eslint-disable-next-line no-console + console.log(`[mock-smtp] HTTP control on ${MOCK_SMTP_HTTP_PORT}`); +}); diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index ae6d83baa..9644a1690 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -2,6 +2,7 @@ import { defineConfig, devices } from "@playwright/test"; import { MOCK_RSS_SERVER_PORT, MOCK_DISCORD_SERVER_PORT, + MOCK_SMTP_HTTP_PORT, } from "./helpers/constants"; const INSTANCE = process.env.E2E_INSTANCE || "0"; @@ -71,16 +72,32 @@ export default defineConfig({ testMatch: PADDLE_CHECKOUT_TESTS, }, ], + // Mock servers run on the HOST (not in Docker), so their output would otherwise be + // lost. Each server tees its console to logs/mock-*.log (see helpers/log-to-file.ts) + // so e2e-mock.sh can fold them into the combined failure log; stdout/stderr are piped + // so Playwright also surfaces them in its own run output. webServer: [ { command: "npx tsx mock-rss-server.ts", port: MOCK_RSS_SERVER_PORT, reuseExistingServer: false, + stdout: "pipe", + stderr: "pipe", }, { command: "npx tsx mock-discord-server.ts", port: MOCK_DISCORD_SERVER_PORT, reuseExistingServer: false, + stdout: "pipe", + stderr: "pipe", + }, + { + // Probed on its HTTP control port; it also opens a raw SMTP socket. + command: "npx tsx mock-smtp-server.ts", + port: MOCK_SMTP_HTTP_PORT, + reuseExistingServer: false, + stdout: "pipe", + stderr: "pipe", }, ], }); diff --git a/e2e/tests/workspaces/email-verification-resend.spec.ts b/e2e/tests/workspaces/email-verification-resend.spec.ts new file mode 100644 index 000000000..b40a1fa6d --- /dev/null +++ b/e2e/tests/workspaces/email-verification-resend.spec.ts @@ -0,0 +1,83 @@ +import { test, expect, type Page } from "../../fixtures/test-fixtures"; +import { getDiscordUserIdFromPage } from "../../helpers/paddle-db"; +import { enableWorkspacesFeatureInDb } from "../../helpers/workspaces-db"; +import { peekVerificationCode, resetCapturedMail } from "../../helpers/smtp"; + +// The verify step discloses the server's resend cooldown (RESEND_COOLDOWN_MS, 60s) +// and code TTL (CODE_TTL_MS, 10 min) so a too-soon resend isn't a silent failure. +// This drives the real create-team verify step through the browser: after a send, +// the resend control is inert with a countdown and the expiry is shown; resending +// to the same address while the server cooldown is still active surfaces the +// friendly 429 message in the UI. + +async function waitForAuthenticatedApp(page: Page): Promise { + await expect(page.getByRole("button", { name: "Account settings" })).toBeVisible({ + timeout: 15000, + }); +} + +async function openCreateTeamVerifyStep(page: Page): Promise { + await page.getByRole("button", { name: "Account settings" }).click(); + await page.getByRole("menuitem", { name: /create a team/i }).click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("button", { name: /send code/i })).toBeVisible({ + timeout: 15000, + }); +} + +test.describe("Email verification resend disclosures", () => { + test("discloses the cooldown and expiry, and surfaces the 429 on a too-soon resend", async ({ + page, + }) => { + await page.goto("/feeds"); + await waitForAuthenticatedApp(page); + + const discordUserId = await getDiscordUserIdFromPage(page); + await enableWorkspacesFeatureInDb(discordUserId); + // No verified email: the create-team dialog renders the editable verify step. + await page.reload(); + await waitForAuthenticatedApp(page); + + await resetCapturedMail(); + await openCreateTeamVerifyStep(page); + + const dialog = page.getByRole("dialog"); + const emailInput = dialog.getByLabel("Email address"); + const email = `resend-${discordUserId}@example.com`; + + await emailInput.fill(email); + await dialog.getByRole("button", { name: /^send code$/i }).click(); + + // The first code is dispatched, moving us to the code-entry view. + const firstCode = await peekVerificationCode(email); + expect(firstCode, "first send should dispatch a code").not.toBeNull(); + + // The TTL is disclosed up front, before any expiry error can occur. + await expect(dialog.getByText(/the code expires in 10 minutes/i)).toBeVisible(); + + // The resend control is inert and shows the live countdown rather than + // failing silently. The accessible name stays "Resend code"; the "(Ns)" is + // visual-only. + const resend = dialog.getByRole("button", { name: "Resend code" }); + await expect(resend).toHaveAttribute("aria-disabled", "true"); + await expect(resend).toHaveText(/resend code \(\d+s\)/i); + + // "Change email" clears the client-side cooldown guard, but the SERVER + // cooldown for this (user, address) is still inside its 60s window. + await dialog.getByRole("button", { name: /change email/i }).click(); + await expect(emailInput).toBeVisible(); + + await resetCapturedMail(); + await emailInput.fill(email); + await dialog.getByRole("button", { name: /^send code$/i }).click(); + + // The server rejects the too-soon resend; the UI shows the friendly message + // (not a raw server string) and no second code is dispatched. + await expect(dialog.getByText(/please wait a moment before requesting/i)).toBeVisible({ + timeout: 15000, + }); + + const blockedCode = await peekVerificationCode(email); + expect(blockedCode).toBeNull(); + }); +}); diff --git a/e2e/tests/workspaces/email-verification-target-cap.spec.ts b/e2e/tests/workspaces/email-verification-target-cap.spec.ts new file mode 100644 index 000000000..a0a85ee3a --- /dev/null +++ b/e2e/tests/workspaces/email-verification-target-cap.spec.ts @@ -0,0 +1,75 @@ +import { test, expect, type Page } from "../../fixtures/test-fixtures"; +import { getDiscordUserIdFromPage } from "../../helpers/paddle-db"; +import { enableWorkspacesFeatureInDb } from "../../helpers/workspaces-db"; +import { peekVerificationCode, resetCapturedMail } from "../../helpers/smtp"; + +// The generic email-verification send (used by the create-team verify step) caps +// how many DISTINCT addresses a single user can have codes sent to within the +// window (5/hour). This exercises that cap through the real UI: after sending to +// the cap's worth of distinct addresses, the next NEW address is refused and no +// code is dispatched to it. + +async function waitForAuthenticatedApp(page: Page): Promise { + await expect(page.getByRole("button", { name: "Account settings" })).toBeVisible({ + timeout: 15000, + }); +} + +async function openCreateTeamVerifyStep(page: Page): Promise { + await page.getByRole("button", { name: "Account settings" }).click(); + await page.getByRole("menuitem", { name: /create a team/i }).click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("button", { name: /send code/i })).toBeVisible({ + timeout: 15000, + }); +} + +test.describe("Email verification distinct-target cap", () => { + test("refuses to send a code to a new address once the per-user cap is reached", async ({ + page, + }) => { + await page.goto("/feeds"); + await waitForAuthenticatedApp(page); + + const discordUserId = await getDiscordUserIdFromPage(page); + await enableWorkspacesFeatureInDb(discordUserId); + // No verified email: the create-team dialog renders the editable verify step. + await page.reload(); + await waitForAuthenticatedApp(page); + + await resetCapturedMail(); + await openCreateTeamVerifyStep(page); + + const dialog = page.getByRole("dialog"); + const emailInput = dialog.getByLabel("Email address"); + + // Send to five distinct addresses (the cap). Each send moves the step to the + // code entry view; "Change email" returns to the address field for the next. + for (let i = 0; i < 5; i += 1) { + const email = `cap-${i}-${discordUserId}@example.com`; + await emailInput.fill(email); + await dialog.getByRole("button", { name: /^send code$/i }).click(); + + // Confirm the code actually went out for this allowed address. + const code = await peekVerificationCode(email); + expect(code, `code should be sent for distinct address #${i + 1}`).not.toBeNull(); + + await dialog.getByRole("button", { name: /change email/i }).click(); + await expect(emailInput).toBeVisible(); + } + + // A sixth, brand-new address must be refused by the distinct-target cap. + const sixth = `cap-6-${discordUserId}@example.com`; + await emailInput.fill(sixth); + await dialog.getByRole("button", { name: /^send code$/i }).click(); + + // The UI surfaces the cap error and stays on the address step. + await expect(dialog.getByText(/too many different email addresses/i)).toBeVisible({ + timeout: 15000, + }); + + // No code was dispatched to the sixth address. + const blockedCode = await peekVerificationCode(sixth); + expect(blockedCode).toBeNull(); + }); +}); diff --git a/e2e/tests/workspaces/workspace-feed-management-invites-disabled.spec.ts b/e2e/tests/workspaces/workspace-feed-management-invites-disabled.spec.ts new file mode 100644 index 000000000..a74b84c0c --- /dev/null +++ b/e2e/tests/workspaces/workspace-feed-management-invites-disabled.spec.ts @@ -0,0 +1,95 @@ +import { test, expect, type Page } from "../../fixtures/test-fixtures"; +import { getDiscordUserIdFromPage } from "../../helpers/paddle-db"; +import { enableWorkspacesFeatureInDb, setVerifiedEmailInDb } from "../../helpers/workspaces-db"; +import { MOCK_RSS_FEED_URL } from "../../helpers/constants"; + +// Per-user feed management invites (the "co-manage" / "transfer ownership" sharing on a +// single feed) are intentionally disabled for workspace feeds — access to a workspace +// feed is governed by workspace membership instead. The "Feed Management Invites" +// section on the feed Settings tab must therefore be absent for a workspace feed, while +// remaining present for a personal feed (the gate is conditional, not a blanket removal). + +async function waitForAuthenticatedApp(page: Page): Promise { + await expect(page.getByRole("button", { name: "Account settings" })).toBeVisible({ + timeout: 15000, + }); +} + +async function enableWorkspacesForCurrentUser(page: Page): Promise { + const discordUserId = await getDiscordUserIdFromPage(page); + await enableWorkspacesFeatureInDb(discordUserId); + await setVerifiedEmailInDb(discordUserId, `verified-${discordUserId}@example.com`); + await page.reload(); + await waitForAuthenticatedApp(page); +} + +async function createWorkspace(page: Page, workspaceName: string): Promise { + await page.getByRole("button", { name: "Account settings" }).click(); + await page.getByRole("menuitem", { name: /create a team/i }).click(); + const dialog = page.getByRole("dialog"); + await dialog.getByLabel("Team name").fill(workspaceName); + await dialog.getByRole("button", { name: "Create team" }).click(); + await expect(page).toHaveURL(/\/workspaces\/[^/]+\/feeds$/, { timeout: 15000 }); +} + +async function addFeedViaDiscovery(page: Page): Promise { + await expect( + page.getByRole("heading", { name: "Get news delivered to your Discord" }), + ).toBeVisible({ timeout: 15000 }); + const search = page.getByRole("textbox", { + name: "Search popular feeds or paste a URL", + }); + await search.fill(MOCK_RSS_FEED_URL); + await page.getByRole("button", { name: "Go", exact: true }).click(); + await page + .getByRole("button", { name: /^Add .+ feed$/i }) + .first() + .click(); + await page.getByRole("button", { name: /View your feeds/ }).click(); +} + +async function openFeedSettingsTab(page: Page): Promise { + await page.getByRole("link", { name: /^Configure/ }).first().click(); + await expect(page.getByRole("heading", { name: "Feed Overview" })).toBeVisible({ + timeout: 15000, + }); + await page.getByRole("tab", { name: "Settings" }).click(); + // The Settings tab always renders the Refresh Rate section, so use it to confirm the + // tab content has mounted before asserting on the (conditionally absent) invites section. + await expect(page.getByRole("heading", { name: "Refresh Rate" })).toBeVisible({ + timeout: 15000, + }); +} + +test.describe("Workspace feed management invites", () => { + test("hides the Feed Management Invites section for workspace feeds", async ({ page }) => { + await page.goto("/feeds"); + await waitForAuthenticatedApp(page); + await enableWorkspacesForCurrentUser(page); + + await createWorkspace(page, `E2E Invites Workspace ${Date.now()}`); + await addFeedViaDiscovery(page); + await expect(page.getByRole("link", { name: /^Configure/ })).toBeVisible(); + + await openFeedSettingsTab(page); + + await expect( + page.getByRole("heading", { name: "Feed Management Invites" }), + ).toHaveCount(0); + await expect(page.getByRole("button", { name: /Invite user to/i })).toHaveCount(0); + }); + + test("still shows the Feed Management Invites section for personal feeds", async ({ page }) => { + await page.goto("/feeds"); + await waitForAuthenticatedApp(page); + // Personal scope — add a feed without entering any workspace. + await addFeedViaDiscovery(page); + await expect(page.getByRole("link", { name: /^Configure/ })).toBeVisible(); + + await openFeedSettingsTab(page); + + await expect( + page.getByRole("heading", { name: "Feed Management Invites" }), + ).toBeVisible(); + }); +}); diff --git a/e2e/tests/workspaces/workspace-feeds.spec.ts b/e2e/tests/workspaces/workspace-feeds.spec.ts new file mode 100644 index 000000000..4a101edf2 --- /dev/null +++ b/e2e/tests/workspaces/workspace-feeds.spec.ts @@ -0,0 +1,90 @@ +import { test, expect, type Page } from "../../fixtures/test-fixtures"; +import { getDiscordUserIdFromPage } from "../../helpers/paddle-db"; +import { enableWorkspacesFeatureInDb, setVerifiedEmailInDb } from "../../helpers/workspaces-db"; +import { MOCK_RSS_FEED_URL } from "../../helpers/constants"; + +// Workspace-scoped feeds reuse the personal feeds dashboard verbatim (discovery UI +// + bulk add). A feed added while in workspace scope belongs to the workspace, +// navigation stays under /workspaces/:slug, and workspace feeds never appear in +// personal scope. + +async function waitForAuthenticatedApp(page: Page): Promise { + await expect(page.getByRole("button", { name: "Account settings" })).toBeVisible({ + timeout: 15000, + }); +} + +async function enableWorkspacesForCurrentUser(page: Page): Promise { + const discordUserId = await getDiscordUserIdFromPage(page); + await enableWorkspacesFeatureInDb(discordUserId); + await setVerifiedEmailInDb(discordUserId, `verified-${discordUserId}@example.com`); + await page.reload(); + await waitForAuthenticatedApp(page); +} + +async function createWorkspace(page: Page, workspaceName: string): Promise { + await page.getByRole("button", { name: "Account settings" }).click(); + await page.getByRole("menuitem", { name: /create a team/i }).click(); + const dialog = page.getByRole("dialog"); + await dialog.getByLabel("Team name").fill(workspaceName); + await dialog.getByRole("button", { name: "Create team" }).click(); + await expect(page).toHaveURL(/\/workspaces\/[^/]+\/feeds$/, { timeout: 15000 }); +} + +async function addFeedViaDiscovery(page: Page): Promise { + // 0 workspace feeds -> the page renders the same discovery UI as personal scope. + await expect( + page.getByRole("heading", { name: "Get news delivered to your Discord" }), + ).toBeVisible({ timeout: 15000 }); + const search = page.getByRole("textbox", { + name: "Search popular feeds or paste a URL", + }); + await search.fill(MOCK_RSS_FEED_URL); + await page.getByRole("button", { name: "Go", exact: true }).click(); + await page + .getByRole("button", { name: /^Add .+ feed$/i }) + .first() + .click(); + await page.getByRole("button", { name: /View your feeds/ }).click(); +} + +test.describe("Workspace feeds", () => { + test("adds a feed via the discovery UI and keeps navigation workspace-scoped", async ({ + page, + }) => { + await page.goto("/feeds"); + await waitForAuthenticatedApp(page); + await enableWorkspacesForCurrentUser(page); + + await createWorkspace(page, `E2E Feeds Workspace ${Date.now()}`); + const slug = page.url().match(/\/workspaces\/([^/]+)\/feeds/)?.[1]; + expect(slug).toBeTruthy(); + + await addFeedViaDiscovery(page); + + // Back on the workspace feeds table (still workspace-scoped) with the feed listed. + await expect(page).toHaveURL(new RegExp(`/workspaces/${slug}/feeds$`)); + await expect(page.getByRole("link", { name: /^Configure/ })).toBeVisible(); + + // Bulk add ("Add multiple feeds") is available and stays workspace-scoped. + await page.getByRole("button", { name: /Additional add feed options/i }).click(); + await page.getByRole("menuitem", { name: /add multiple feeds/i }).click(); + await expect(page).toHaveURL(new RegExp(`/workspaces/${slug}/add-feeds$`)); + }); + + test("workspace feeds do not appear in the personal feeds dashboard", async ({ page }) => { + await page.goto("/feeds"); + await waitForAuthenticatedApp(page); + await enableWorkspacesForCurrentUser(page); + + await createWorkspace(page, `E2E Sep Workspace ${Date.now()}`); + await addFeedViaDiscovery(page); + await expect(page.getByRole("link", { name: /^Configure/ })).toBeVisible(); + + // Switch back to the personal workspace; the workspace feed must not be listed. + await page.getByRole("button", { name: /Switch team/ }).click(); + await page.getByRole("menuitemradio", { name: /personal/i }).click(); + await expect(page).toHaveURL(/\/feeds$/); + await expect(page.getByRole("link", { name: /^Configure/ })).toHaveCount(0); + }); +}); diff --git a/e2e/tests/workspaces/workspace-invitation-roundtrip.spec.ts b/e2e/tests/workspaces/workspace-invitation-roundtrip.spec.ts new file mode 100644 index 000000000..6db5efec1 --- /dev/null +++ b/e2e/tests/workspaces/workspace-invitation-roundtrip.spec.ts @@ -0,0 +1,163 @@ +import { test, expect, type Page } from "../../fixtures/test-fixtures"; +import type { Browser } from "@playwright/test"; +import { getDiscordUserIdFromPage } from "../../helpers/paddle-db"; +import { + enableWorkspacesFeatureInDb, + getUserMongoIdFromDiscordId, + seedWorkspaceWithMembershipsInDb, + setVerifiedEmailInDb, +} from "../../helpers/workspaces-db"; +import { + waitForInviteLink, + waitForVerificationCode, + resetCapturedMail, +} from "../../helpers/smtp"; + +// Full inviter -> invitee round-trip with NO direct invite seeding. The owner +// invites by email through the UI (the backend really dispatches the notification +// email); a second, logged-out user opens the link captured from that email, +// bootstraps via Discord OAuth, verifies the invited address via the real +// one-time-code flow, and accepts. The owner's member list then shows them as a +// member. Every assertion goes through the rendered UI; the only thing read out +// of band is the email the invitee would have received (via the mock mailer). + +async function waitForAuthenticatedApp(page: Page): Promise { + await expect(page.getByRole("button", { name: "Account settings" })).toBeVisible({ + timeout: 20000, + }); +} + +async function gotoMembers(page: Page, workspaceName: string): Promise { + await page.getByRole("button", { name: "Account settings" }).click(); + await page.getByRole("menuitem", { name: /account settings/i }).click(); + await expect(page.getByRole("heading", { name: "Your teams" })).toBeVisible({ + timeout: 20000, + }); + await page.getByRole("link", { name: `${workspaceName} settings` }).click(); + await expect(page).toHaveURL(/\/workspaces\/[^/]+\/settings$/, { timeout: 20000 }); +} + +// A second, logged-out browser actor opens the invitation link from the email, +// bootstraps via Discord OAuth, enrols in the feature, verifies the invited +// email via the real one-time-code flow, accepts, and confirms they can see the +// workspace they joined in their own switcher. +async function inviteeAcceptsViaLink( + browser: Browser, + inviteLink: string, + invitedEmail: string, + workspaceName: string, +): Promise { + const context = await browser.newContext(); + const page = await context.newPage(); + + try { + // Open the invitation while logged out -> OAuth bootstrap (a brand-new user). + await page.goto(inviteLink); + await waitForAuthenticatedApp(page); + + // The new user lacks the per-user workspaces flag, so the invite endpoints + // 404 until it's enabled; enable it for the minted user and reload the link. + const discordUserId = await getDiscordUserIdFromPage(page); + await enableWorkspacesFeatureInDb(discordUserId); + await page.goto(inviteLink); + await waitForAuthenticatedApp(page); + + // Verify the invited email through the real one-time-code flow. + await expect(page.getByRole("button", { name: /send code/i })).toBeVisible({ + timeout: 20000, + }); + await page.getByLabel(/email address/i).fill(invitedEmail); + await page.getByRole("button", { name: /send code/i }).click(); + + const code = await waitForVerificationCode(invitedEmail); + await page.getByLabel(/verification code/i).fill(code); + await page.getByRole("button", { name: /verify|confirm/i }).click(); + + // Accept and gain the workspace. + await page.getByRole("button", { name: /accept invitation/i }).click({ + timeout: 20000, + }); + + // Accepting drops the invitee straight into the workspace they just joined + // (its scoped feeds view), rather than their personal feeds. + await expect(page).toHaveURL(/\/workspaces\/[^/]+\/feeds$/, { timeout: 20000 }); + + // The invitee themselves can now see they're part of the team: the workspace + // they just joined is listed in their own switcher. + const switcher = page.getByRole("button", { name: /Switch team/ }); + await expect(switcher).toBeVisible({ timeout: 20000 }); + await switcher.click(); + await expect( + page.getByRole("menuitemradio", { name: workspaceName }), + ).toBeVisible({ timeout: 20000 }); + } finally { + await context.close(); + } +} + +test.describe("Workspace invitations (inviter -> invitee round-trip)", () => { + test("owner invites by email; the invitee receives it, accepts, and appears as a member", async ({ + page, + browser, + }) => { + // --- Session A: the owner sets up a workspace and invites by email. --- + await page.goto("/feeds"); + await waitForAuthenticatedApp(page); + + const ownerDiscordId = await getDiscordUserIdFromPage(page); + await enableWorkspacesFeatureInDb(ownerDiscordId); + await setVerifiedEmailInDb(ownerDiscordId, `owner-${ownerDiscordId}@example.com`); + const ownerUserId = await getUserMongoIdFromDiscordId(ownerDiscordId); + + const workspaceName = `Roundtrip WS ${ownerDiscordId}`; + await seedWorkspaceWithMembershipsInDb({ + workspaceName, + selfUserId: ownerUserId, + selfRole: "owner", + }); + + // A fresh invitee address (no 6-digit run, so the captured code is never + // confused with digits in the address). + const invitedEmail = `invitee-${Date.now().toString(36)}-${Math.random() + .toString(36) + .slice(2, 8)}@example.com`; + await resetCapturedMail(); + + await page.reload(); + await waitForAuthenticatedApp(page); + await gotoMembers(page, workspaceName); + + // Invite by email through the UI — this triggers the real notification send. + await page.getByLabel("Invite by email").fill(invitedEmail); + await page.getByRole("button", { name: "Send invite" }).click(); + + // The pending invitation appears in the owner's rendered list. + const pending = page.getByRole("region", { name: "Pending invitations" }); + await expect(pending.getByRole("listitem").filter({ hasText: invitedEmail })).toBeVisible({ + timeout: 20000, + }); + + // --- The invitee receives the email and acts on its link. --- + const inviteLink = await waitForInviteLink(invitedEmail); + await inviteeAcceptsViaLink(browser, inviteLink, invitedEmail, workspaceName); + + // --- Session A: the owner now sees the invitee as a member, and the pending + // invitation is gone (it became a membership). --- + await page.reload(); + await waitForAuthenticatedApp(page); + await gotoMembers(page, workspaceName); + + const members = page.getByRole("region", { name: "Members" }); + await expect(members.getByRole("heading", { name: "Members" })).toBeVisible({ + timeout: 20000, + }); + // Two members now: the owner and the freshly-accepted admin invitee. + await expect(members.getByRole("listitem")).toHaveCount(2, { timeout: 20000 }); + await expect( + page + .getByRole("region", { name: "Pending invitations" }) + .getByRole("listitem") + .filter({ hasText: invitedEmail }), + ).toHaveCount(0); + }); +}); diff --git a/e2e/tests/workspaces/workspace-invitations.spec.ts b/e2e/tests/workspaces/workspace-invitations.spec.ts new file mode 100644 index 000000000..f5c642f00 --- /dev/null +++ b/e2e/tests/workspaces/workspace-invitations.spec.ts @@ -0,0 +1,110 @@ +import { test, expect, type Page } from "../../fixtures/test-fixtures"; +import { getDiscordUserIdFromPage } from "../../helpers/paddle-db"; +import { + enableWorkspacesFeatureInDb, + seedWorkspaceInviteInDb, + setVerifiedEmailInDb, +} from "../../helpers/workspaces-db"; + +// Invitee-side flow (slice 5): an invitation is addressed to an email. The +// authenticated test user is the invitee; the workspace + invite are seeded +// directly so the user is a pure invitee (never the owner). Assertions go +// through the rendered UI only — the switcher/list/landing page — never API. + +async function waitForAuthenticatedApp(page: Page): Promise { + await expect(page.getByRole("button", { name: "Account settings" })).toBeVisible({ + timeout: 15000, + }); +} + +test.describe("Workspace invitations (invitee side)", () => { + // The matching-email accept-and-gain-workspace path is covered end to end (with + // a real invite send + OTP) by workspace-invitation-roundtrip.spec.ts and + // workspace-invitation-anonymous.spec.ts, so it is not duplicated here. + + test("invitee whose verified email does not match is guided to verify the invited address", async ({ + page, + }) => { + await page.goto("/feeds"); + await waitForAuthenticatedApp(page); + + const discordUserId = await getDiscordUserIdFromPage(page); + await enableWorkspacesFeatureInDb(discordUserId); + // The user has a DIFFERENT verified email than the one the invite is for. + await setVerifiedEmailInDb(discordUserId, `someone-else-${discordUserId}@example.com`); + + const invitedEmail = `invited-${discordUserId}@example.com`; + const workspaceName = `Mismatch Workspace ${discordUserId}`; + const { inviteId } = await seedWorkspaceInviteInDb({ + workspaceName, + email: invitedEmail, + }); + + await page.goto(`/invites/${inviteId}`); + + await expect( + page.getByRole("heading", { name: new RegExp(workspaceName) }), + ).toBeVisible({ timeout: 15000 }); + + // The page guides the mismatched user to verify and offers to send a code. + await expect(page.getByRole("button", { name: /send code/i })).toBeVisible(); + // The full invited address is NOT disclosed to a non-matching caller (the + // server returns only a redacted hint); only the verified-match invitee sees + // it. So the mismatch page must NOT render the full invited email. + await expect(page.getByText(invitedEmail)).toHaveCount(0); + // No accept action until the invited email is verified. + await expect(page.getByRole("button", { name: /accept invitation/i })).toHaveCount(0); + }); + + test("invitee with multiple invitations sees them all and acts on each independently", async ({ + page, + }) => { + await page.goto("/feeds"); + await waitForAuthenticatedApp(page); + + const discordUserId = await getDiscordUserIdFromPage(page); + const email = `multi-${discordUserId}@example.com`; + await enableWorkspacesFeatureInDb(discordUserId); + await setVerifiedEmailInDb(discordUserId, email); + + const workspaceA = `Multi A ${discordUserId}`; + const workspaceB = `Multi B ${discordUserId}`; + await seedWorkspaceInviteInDb({ workspaceName: workspaceA, email }); + await seedWorkspaceInviteInDb({ workspaceName: workspaceB, email }); + + // The pending invitations surface lives in Account Settings. + await page.reload(); + await waitForAuthenticatedApp(page); + await page.getByRole("button", { name: "Account settings" }).click(); + await page.getByRole("menuitem", { name: /account settings/i }).click(); + + const pending = page.getByRole("region", { name: "Pending invitations" }); + await expect(pending.getByRole("heading", { name: "Pending invitations" })).toBeVisible({ + timeout: 15000, + }); + + // Both invitations are listed in the pending region, each in its own item. + const inviteA = pending.getByRole("listitem").filter({ hasText: workspaceA }); + const inviteB = pending.getByRole("listitem").filter({ hasText: workspaceB }); + await expect(inviteA).toBeVisible(); + await expect(inviteB).toBeVisible(); + + // Decline B independently — only B leaves the pending list; A stays actionable. + await inviteB.getByRole("button", { name: "Decline" }).click(); + await expect(pending.getByRole("listitem").filter({ hasText: workspaceB })).toHaveCount(0, { + timeout: 15000, + }); + await expect(pending.getByRole("listitem").filter({ hasText: workspaceA })).toBeVisible(); + + // Accept A — it leaves the pending list and becomes a workspace in the switcher. + await inviteA.getByRole("button", { name: "Accept" }).click(); + await expect(pending.getByRole("listitem").filter({ hasText: workspaceA })).toHaveCount(0, { + timeout: 15000, + }); + + const switcher = page.getByRole("button", { name: /Switch team/ }); + await expect(switcher).toBeVisible({ timeout: 15000 }); + await switcher.click(); + await expect(page.getByRole("menuitemradio", { name: workspaceA })).toBeVisible(); + }); +}); diff --git a/e2e/tests/workspaces/workspace-invite-self-accept-guard.spec.ts b/e2e/tests/workspaces/workspace-invite-self-accept-guard.spec.ts new file mode 100644 index 000000000..29e54af2c --- /dev/null +++ b/e2e/tests/workspaces/workspace-invite-self-accept-guard.spec.ts @@ -0,0 +1,149 @@ +import { test, expect, type Page } from "../../fixtures/test-fixtures"; +import { getDiscordUserIdFromPage } from "../../helpers/paddle-db"; +import { enableWorkspacesFeatureInDb } from "../../helpers/workspaces-db"; +import { waitForInviteLink, waitForVerificationCode, resetCapturedMail } from "../../helpers/smtp"; + +// End-to-end coverage of the self-accept dead-end. An owner (already a member of +// their own workspace) opens an invitation they sent to a DIFFERENT address. The +// landing page must recognise they are already a member and short-circuit BEFORE +// the verify step — so it never pushes them through email verification, which +// would overwrite their verified email for an accept the server rejects anyway. +// +// The decisive assertions, all read from the rendered UI: +// 1) The invite page shows an "already a member" message, with NO verify step +// and NO accept button. +// 2) The invitation stays pending (it was never consumed). +// 3) The owner's verified email is untouched — proven by re-opening the create +// team dialog and landing directly on the name field (it skips the verify +// step only when a verified email is still set). +// +// The single feature-flag enable is a rollout gate every workspaces spec sets, not +// fixture data for this scenario. + +async function waitForAuthenticatedApp(page: Page): Promise { + await expect(page.getByRole("button", { name: "Account settings" })).toBeVisible({ + timeout: 20000, + }); +} + +async function gotoMembers(page: Page, workspaceName: string): Promise { + await page.getByRole("button", { name: "Account settings" }).click(); + await page.getByRole("menuitem", { name: /account settings/i }).click(); + await expect(page.getByRole("heading", { name: "Your teams" })).toBeVisible({ + timeout: 20000, + }); + await page.getByRole("link", { name: `${workspaceName} settings` }).click(); + await expect(page).toHaveURL(/\/workspaces\/[^/]+\/settings$/, { timeout: 20000 }); +} + +test.describe("Workspace invite self-accept guard", () => { + test("an existing member opening their own invite is told they're already a member, with no verify step, and their verified email is untouched", async ({ + page, + }) => { + // Create workspace + invite + open the invite + re-verify the verified email + // is intact is a long multi-navigation flow; give it room beyond the 30s + // default rather than racing the budget. + test.slow(); + + await page.goto("/feeds"); + await waitForAuthenticatedApp(page); + + const ownerDiscordId = await getDiscordUserIdFromPage(page); + await enableWorkspacesFeatureInDb(ownerDiscordId); + await page.reload(); + await waitForAuthenticatedApp(page); + + // Two addresses. The owner verifies `ownerEmail` to create the workspace, then + // invites a DIFFERENT address `invitedEmail`. Inviting an address you ALREADY + // own is blocked at creation, so the scenario needs a distinct invited address. + // Fresh addresses with no 6-digit run, so a captured code is never confused + // with digits in the address. + const suffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + const ownerEmail = `owner-${suffix}@example.com`; + const invitedEmail = `invited-${suffix}@example.com`; + await resetCapturedMail(); + + // --- Create a workspace through the UI, verifying ownerEmail via real OTP. --- + await page.getByRole("button", { name: "Account settings" }).click(); + await page.getByRole("menuitem", { name: /create a team/i }).click(); + + const dialog = page.getByRole("dialog"); + // Workspace creation is gated behind a verified email, so the dialog opens on + // the verify step. + await dialog.getByLabel(/email address/i).fill(ownerEmail); + await dialog.getByRole("button", { name: /send code/i }).click(); + + const createCode = await waitForVerificationCode(ownerEmail); + await dialog.getByLabel(/verification code/i).fill(createCode); + await dialog.getByRole("button", { name: /verify|confirm/i }).click(); + + const workspaceName = `Self Accept WS ${ownerDiscordId}`; + await dialog.getByLabel("Team name").fill(workspaceName); + await dialog.getByRole("button", { name: "Create team" }).click(); + + await expect(page).toHaveURL(/\/workspaces\/[^/]+\/feeds$/, { timeout: 20000 }); + + // --- Invite a DIFFERENT address through the UI — a real notification send. + // The owner does not own this address, so creation succeeds. --- + await resetCapturedMail(); + await gotoMembers(page, workspaceName); + await page.getByLabel("Invite by email").fill(invitedEmail); + await page.getByRole("button", { name: "Send invite" }).click(); + + const pending = page.getByRole("region", { name: "Pending invitations" }); + await expect(pending.getByRole("listitem").filter({ hasText: invitedEmail })).toBeVisible({ + timeout: 20000, + }); + + // --- Open the invitation from its real email link. The owner is already a + // member, so the page must short-circuit to the "already a member" state + // WITHOUT ever offering the verify step. --- + const inviteLink = await waitForInviteLink(invitedEmail); + await page.goto(inviteLink); + await expect( + page.getByRole("heading", { name: new RegExp(workspaceName) }), + ).toBeVisible({ timeout: 20000 }); + + // The "already a member" message is shown... + await expect(page.getByText(/you're already a member/i)).toBeVisible({ timeout: 20000 }); + // ...and crucially, the verify step is NOT offered (no email field, no send-code + // button), so the owner's verified email is never overwritten. + await expect(page.getByRole("button", { name: /send code/i })).toHaveCount(0); + await expect(page.getByLabel(/email address/i)).toHaveCount(0); + await expect(page.getByRole("button", { name: /accept invitation/i })).toHaveCount(0); + + // --- State assertions, all read from the rendered UI. --- + await page.goto("/feeds"); + await waitForAuthenticatedApp(page); + + // 1) The invitation is still pending — it was NOT consumed, so the intended + // person can still claim it on a different account. + await gotoMembers(page, workspaceName); + await expect( + page + .getByRole("region", { name: "Pending invitations" }) + .getByRole("listitem") + .filter({ hasText: invitedEmail }), + ).toHaveCount(1, { timeout: 20000 }); + + // 2) The member list still shows exactly one member (no phantom second member). + const members = page.getByRole("region", { name: "Members" }); + await expect(members.getByRole("heading", { name: "Members" })).toBeVisible({ + timeout: 20000, + }); + await expect(members.getByRole("listitem")).toHaveCount(1, { timeout: 20000 }); + + // 3) The owner's verified email is untouched: re-opening the create team dialog + // lands directly on the name field (it skips the verify step only when a + // verified email is still set). Had the invite flow overwritten the verified + // email, this would instead show the verify step. + // + // Once the user has a workspace, "Create team" lives in the workspace switcher + // (the account-menu entry only appears at zero workspaces), so open it there. + await page.getByRole("button", { name: /switch team, current:/i }).click(); + await page.getByRole("menuitem", { name: /create team/i }).click(); + const dialog2 = page.getByRole("dialog"); + await expect(dialog2.getByLabel("Team name")).toBeVisible({ timeout: 20000 }); + await expect(dialog2.getByRole("button", { name: /send code/i })).toHaveCount(0); + }); +}); diff --git a/e2e/tests/workspaces/workspace-invite-verification-guard.spec.ts b/e2e/tests/workspaces/workspace-invite-verification-guard.spec.ts new file mode 100644 index 000000000..e842119bc --- /dev/null +++ b/e2e/tests/workspaces/workspace-invite-verification-guard.spec.ts @@ -0,0 +1,66 @@ +import { test, expect, type Page } from "../../fixtures/test-fixtures"; +import { getDiscordUserIdFromPage } from "../../helpers/paddle-db"; +import { + enableWorkspacesFeatureInDb, + seedWorkspaceInviteInDb, + setVerifiedEmailInDb, +} from "../../helpers/workspaces-db"; +import { peekVerificationCode, resetCapturedMail } from "../../helpers/smtp"; + +// Verifies the core guarantee of the invite-scoped verification send: when an +// invitee whose verified email does not match the invitation types an UNRELATED +// address into the verify step and attempts to send a code, the system must +// dispatch NO email to that unrelated address. The invite landing page only ever +// has the redacted hint, so it both guards the send client-side and routes +// through the invite-scoped endpoint, which the backend no-ops on a mismatch. + +async function waitForAuthenticatedApp(page: Page): Promise { + await expect(page.getByRole("button", { name: "Account settings" })).toBeVisible({ + timeout: 15000, + }); +} + +test.describe("Workspace invite verification guard", () => { + test("attempting to verify an unrelated email sends no code to that address", async ({ + page, + }) => { + await page.goto("/feeds"); + await waitForAuthenticatedApp(page); + + const discordUserId = await getDiscordUserIdFromPage(page); + await enableWorkspacesFeatureInDb(discordUserId); + // The invitee's verified email differs from the invited address, so the + // landing page renders the editable verify step (server withholds the full + // invited address, returning only a redacted hint). + await setVerifiedEmailInDb(discordUserId, `someone-else-${discordUserId}@example.com`); + + const invitedEmail = `invited-${discordUserId}@example.com`; + const workspaceName = `Guard Workspace ${discordUserId}`; + const { inviteId } = await seedWorkspaceInviteInDb({ + workspaceName, + email: invitedEmail, + }); + + await resetCapturedMail(); + await page.goto(`/invites/${inviteId}`); + await expect( + page.getByRole("heading", { name: new RegExp(workspaceName) }), + ).toBeVisible({ timeout: 15000 }); + + // Type a clearly-unrelated address and attempt to send the code. + const unrelatedEmail = `attacker-${discordUserId}@evil.example.net`; + await page.getByLabel(/email address/i).fill(unrelatedEmail); + await page.getByRole("button", { name: /send code/i }).click(); + + // The UI surfaces the guard, steering the user to the invited address rather + // than the one they typed. + await expect( + page.getByText(/enter the address this invitation was sent to/i), + ).toBeVisible({ timeout: 15000 }); + + // The decisive assertion: the mock mailer captured NO verification code for + // the unrelated address. (peek returns null instead of throwing on absence.) + const code = await peekVerificationCode(unrelatedEmail); + expect(code).toBeNull(); + }); +}); diff --git a/e2e/tests/workspaces/workspace-members.spec.ts b/e2e/tests/workspaces/workspace-members.spec.ts new file mode 100644 index 000000000..cd90a6668 --- /dev/null +++ b/e2e/tests/workspaces/workspace-members.spec.ts @@ -0,0 +1,357 @@ +import { test, expect, type Page } from "../../fixtures/test-fixtures"; +import { getDiscordUserIdFromPage } from "../../helpers/paddle-db"; +import { + enableWorkspacesFeatureInDb, + getUserMongoIdFromDiscordId, + seedWorkspaceWithMembershipsInDb, + setVerifiedEmailInDb, +} from "../../helpers/workspaces-db"; +import { resetCapturedMail, waitForInviteLink } from "../../helpers/smtp"; + +// Owner/admin member-management view (slice 6). The authenticated test user is a +// real member of a seeded workspace with co-members and pending invitations. The +// view is surfaced on the workspace settings page. Assertions go through the +// rendered UI only — never API. + +async function waitForAuthenticatedApp(page: Page): Promise { + await expect(page.getByRole("button", { name: "Account settings" })).toBeVisible({ + timeout: 15000, + }); +} + +// Open the member-management view via Account Settings -> "Your teams" -> the +// workspace's Settings link (the same entry point exercised by workspaces.spec.ts), +// landing on the workspace settings page where the member-management view lives. +async function gotoMembers(page: Page, workspaceName: string): Promise { + await page.getByRole("button", { name: "Account settings" }).click(); + await page.getByRole("menuitem", { name: /account settings/i }).click(); + await expect(page.getByRole("heading", { name: "Your teams" })).toBeVisible({ + timeout: 15000, + }); + await page.getByRole("link", { name: `${workspaceName} settings` }).click(); + await expect(page).toHaveURL(/\/workspaces\/[^/]+\/settings$/, { timeout: 15000 }); +} + +test.describe("Workspace member management (owner/admin view)", () => { + test("lists current members with their roles", async ({ page }) => { + await page.goto("/feeds"); + await waitForAuthenticatedApp(page); + + const discordUserId = await getDiscordUserIdFromPage(page); + await enableWorkspacesFeatureInDb(discordUserId); + await setVerifiedEmailInDb(discordUserId, `owner-${discordUserId}@example.com`); + const selfUserId = await getUserMongoIdFromDiscordId(discordUserId); + + const workspaceName = `Members WS ${discordUserId}`; + await seedWorkspaceWithMembershipsInDb({ + workspaceName, + selfUserId, + selfRole: "owner", + otherMembers: [{ role: "admin", discordUserId: `co-admin-${discordUserId}` }], + }); + + await page.reload(); + await waitForAuthenticatedApp(page); + await gotoMembers(page, workspaceName); + + const members = page.getByRole("region", { name: "Members" }); + await expect(members.getByRole("heading", { name: "Members" })).toBeVisible({ + timeout: 15000, + }); + + // Two member rows: the owner (the test user) and the co-admin, each showing + // its role. + const ownerRow = members.getByRole("listitem").filter({ hasText: /owner/i }); + const adminRow = members.getByRole("listitem").filter({ hasText: /admin/i }); + await expect(ownerRow).toHaveCount(1); + await expect(adminRow).toHaveCount(1); + }); + + test("lists outstanding pending invitations with inviter and creation time", async ({ page }) => { + await page.goto("/feeds"); + await waitForAuthenticatedApp(page); + + const discordUserId = await getDiscordUserIdFromPage(page); + await enableWorkspacesFeatureInDb(discordUserId); + await setVerifiedEmailInDb(discordUserId, `owner-${discordUserId}@example.com`); + const selfUserId = await getUserMongoIdFromDiscordId(discordUserId); + + const workspaceName = `Invites WS ${discordUserId}`; + const invitedEmail = `pending-${discordUserId}@example.com`; + await seedWorkspaceWithMembershipsInDb({ + workspaceName, + selfUserId, + selfRole: "owner", + invitedEmails: [invitedEmail], + }); + + await page.reload(); + await waitForAuthenticatedApp(page); + await gotoMembers(page, workspaceName); + + const pending = page.getByRole("region", { name: "Pending invitations" }); + await expect(pending.getByRole("heading", { name: "Pending invitations" })).toBeVisible({ + timeout: 15000, + }); + + const inviteRow = pending.getByRole("listitem").filter({ hasText: invitedEmail }); + await expect(inviteRow).toBeVisible(); + // Inviter (the test user invited it -> "you") and a relative creation time. + await expect(inviteRow.getByText(/invited by you/i)).toBeVisible(); + await expect(inviteRow.getByText(/ago|just now/i)).toBeVisible(); + }); + + test("owner revokes a pending invitation and it disappears from the list", async ({ page }) => { + await page.goto("/feeds"); + await waitForAuthenticatedApp(page); + + const discordUserId = await getDiscordUserIdFromPage(page); + await enableWorkspacesFeatureInDb(discordUserId); + await setVerifiedEmailInDb(discordUserId, `owner-${discordUserId}@example.com`); + const selfUserId = await getUserMongoIdFromDiscordId(discordUserId); + + const workspaceName = `Revoke WS ${discordUserId}`; + const invitedEmail = `revoke-me-${discordUserId}@example.com`; + await seedWorkspaceWithMembershipsInDb({ + workspaceName, + selfUserId, + selfRole: "owner", + invitedEmails: [invitedEmail], + }); + + await page.reload(); + await waitForAuthenticatedApp(page); + await gotoMembers(page, workspaceName); + + const pending = page.getByRole("region", { name: "Pending invitations" }); + const inviteRow = pending.getByRole("listitem").filter({ hasText: invitedEmail }); + await expect(inviteRow).toBeVisible({ timeout: 15000 }); + + // Exact accessible name: the per-row aria-label folds in the email, so a loose + // /revoke/i also matches the sibling Resend button when the address contains + // that substring. + await inviteRow + .getByRole("button", { name: `Revoke invitation to ${invitedEmail}` }) + .click(); + // Confirm in the dialog. + const dialog = page.getByRole("alertdialog"); + await dialog.getByRole("button", { name: "Revoke invitation" }).click(); + + await expect( + pending.getByRole("listitem").filter({ hasText: invitedEmail }), + ).toHaveCount(0, { timeout: 15000 }); + }); + + test("owner resends a pending invitation and a fresh email is dispatched", async ({ page }) => { + await page.goto("/feeds"); + await waitForAuthenticatedApp(page); + + const discordUserId = await getDiscordUserIdFromPage(page); + await enableWorkspacesFeatureInDb(discordUserId); + await setVerifiedEmailInDb(discordUserId, `owner-${discordUserId}@example.com`); + const selfUserId = await getUserMongoIdFromDiscordId(discordUserId); + + const workspaceName = `Resend WS ${discordUserId}`; + const invitedEmail = `pending-again-${discordUserId}@example.com`; + await seedWorkspaceWithMembershipsInDb({ + workspaceName, + selfUserId, + selfRole: "owner", + invitedEmails: [invitedEmail], + // Backdate past the per-invite resend cooldown so the resend dispatches now + // instead of being rejected as too-soon after the seeded send. + invitedLastSentAt: new Date(Date.now() - 60 * 60 * 1000), + }); + + // No mail must be captured for this address before the resend, so the link we + // observe afterwards is unambiguously the resent one. + await resetCapturedMail(); + + await page.reload(); + await waitForAuthenticatedApp(page); + await gotoMembers(page, workspaceName); + + const pending = page.getByRole("region", { name: "Pending invitations" }); + const inviteRow = pending.getByRole("listitem").filter({ hasText: invitedEmail }); + await expect(inviteRow).toBeVisible({ timeout: 15000 }); + + // Exact accessible name: the per-row aria-label folds in the email, and a + // loose /resend/i would also match the Revoke button when the address itself + // contains that substring. + await inviteRow + .getByRole("button", { name: `Resend invitation to ${invitedEmail}` }) + .click(); + const dialog = page.getByRole("alertdialog"); + await dialog.getByRole("button", { name: "Resend invitation" }).click(); + + // The success is surfaced in the rendered UI as a page alert, and the invite + // stays in the pending list (a resend does not consume it). + await expect(page.getByText(/invitation resent/i)).toBeVisible({ timeout: 15000 }); + await expect(pending.getByRole("listitem").filter({ hasText: invitedEmail })).toBeVisible(); + + // The side-effect: the backend really dispatched a fresh invitation email to the + // same address (read out of band via the mock mailer, the only non-UI check). + const inviteLink = await waitForInviteLink(invitedEmail); + expect(inviteLink).toMatch(/\/invites\/[^/]+$/); + }); + + test("owner removes a member and it disappears from the list", async ({ page }) => { + await page.goto("/feeds"); + await waitForAuthenticatedApp(page); + + const discordUserId = await getDiscordUserIdFromPage(page); + await enableWorkspacesFeatureInDb(discordUserId); + await setVerifiedEmailInDb(discordUserId, `owner-${discordUserId}@example.com`); + const selfUserId = await getUserMongoIdFromDiscordId(discordUserId); + + const coAdminDiscordId = `removable-${discordUserId}`; + const workspaceName = `Remove WS ${discordUserId}`; + await seedWorkspaceWithMembershipsInDb({ + workspaceName, + selfUserId, + selfRole: "owner", + otherMembers: [{ role: "admin", discordUserId: coAdminDiscordId }], + }); + + await page.reload(); + await waitForAuthenticatedApp(page); + await gotoMembers(page, workspaceName); + + const members = page.getByRole("region", { name: "Members" }); + // The other member's row carries a Remove control (owner-only). + const otherRow = members.getByRole("listitem").filter({ hasText: /admin/i }); + await expect(otherRow).toBeVisible({ timeout: 15000 }); + + await otherRow.getByRole("button", { name: /^remove/i }).click(); + const dialog = page.getByRole("alertdialog"); + await dialog.getByRole("button", { name: /remove/i }).click(); + + await expect(members.getByRole("listitem").filter({ hasText: /admin/i })).toHaveCount(0, { + timeout: 15000, + }); + }); + + test("an admin cannot remove other members (no remove control)", async ({ page }) => { + await page.goto("/feeds"); + await waitForAuthenticatedApp(page); + + const discordUserId = await getDiscordUserIdFromPage(page); + await enableWorkspacesFeatureInDb(discordUserId); + await setVerifiedEmailInDb(discordUserId, `admin-${discordUserId}@example.com`); + const selfUserId = await getUserMongoIdFromDiscordId(discordUserId); + + const workspaceName = `AdminView WS ${discordUserId}`; + await seedWorkspaceWithMembershipsInDb({ + workspaceName, + selfUserId, + // The test user is an ADMIN; another member is the owner. + selfRole: "admin", + otherMembers: [{ role: "owner", discordUserId: `the-owner-${discordUserId}` }], + }); + + await page.reload(); + await waitForAuthenticatedApp(page); + await gotoMembers(page, workspaceName); + + const members = page.getByRole("region", { name: "Members" }); + const ownerRow = members.getByRole("listitem").filter({ hasText: /owner/i }); + await expect(ownerRow).toBeVisible({ timeout: 15000 }); + + // No remove-other control is rendered anywhere in the members list for an admin. + await expect(members.getByRole("button", { name: /^remove/i })).toHaveCount(0); + // The admin can still leave the workspace themselves. + await expect(members.getByRole("button", { name: /leave/i })).toBeVisible(); + }); + + test("a member can leave the workspace from the view", async ({ page }) => { + await page.goto("/feeds"); + await waitForAuthenticatedApp(page); + + const discordUserId = await getDiscordUserIdFromPage(page); + await enableWorkspacesFeatureInDb(discordUserId); + await setVerifiedEmailInDb(discordUserId, `leaver-${discordUserId}@example.com`); + const selfUserId = await getUserMongoIdFromDiscordId(discordUserId); + + const workspaceName = `Leave WS ${discordUserId}`; + await seedWorkspaceWithMembershipsInDb({ + workspaceName, + selfUserId, + // Admin so leaving is allowed without tripping the last-owner invariant. + selfRole: "admin", + otherMembers: [{ role: "owner", discordUserId: `owner-of-${discordUserId}` }], + }); + + await page.reload(); + await waitForAuthenticatedApp(page); + await gotoMembers(page, workspaceName); + + const members = page.getByRole("region", { name: "Members" }); + await expect(members.getByRole("heading", { name: "Members" })).toBeVisible({ + timeout: 15000, + }); + + await members.getByRole("button", { name: /leave/i }).click(); + const dialog = page.getByRole("alertdialog"); + await dialog.getByRole("button", { name: /leave/i }).click(); + + // After leaving, the workspace is no longer in the switcher. + await expect(page).toHaveURL(/\/feeds$/, { timeout: 15000 }); + await expect(page.getByRole("button", { name: `Switch team, current: ${workspaceName}` })).toHaveCount( + 0, + ); + }); + + test("the sole owner cannot leave: the last-owner error is shown and they remain a member", async ({ + page, + }) => { + await page.goto("/feeds"); + await waitForAuthenticatedApp(page); + + const discordUserId = await getDiscordUserIdFromPage(page); + await enableWorkspacesFeatureInDb(discordUserId); + await setVerifiedEmailInDb(discordUserId, `sole-owner-${discordUserId}@example.com`); + const selfUserId = await getUserMongoIdFromDiscordId(discordUserId); + + const workspaceName = `SoleOwner WS ${discordUserId}`; + await seedWorkspaceWithMembershipsInDb({ + workspaceName, + selfUserId, + // Sole owner, no co-members: leaving would drop the last owner. + selfRole: "owner", + }); + + await page.reload(); + await waitForAuthenticatedApp(page); + await gotoMembers(page, workspaceName); + + const members = page.getByRole("region", { name: "Members" }); + await expect(members.getByRole("heading", { name: "Members" })).toBeVisible({ + timeout: 15000, + }); + + await members.getByRole("button", { name: /leave/i }).click(); + const dialog = page.getByRole("alertdialog"); + await dialog.getByRole("button", { name: /leave/i }).click(); + + // (a) The last-owner error is surfaced to the user in the dialog and the leave + // is rejected — the dialog stays open showing the CANNOT_REMOVE_LAST_OWNER message. + await expect( + dialog.getByText( + /A team must have at least one owner\. Transfer ownership before removing this member\./i, + ), + ).toBeVisible({ timeout: 15000 }); + + // Dismiss the dialog and confirm (b) the user is still a member: their own + // (owner) row still renders in the members list, and the workspace still + // appears in the switcher. + await dialog.getByRole("button", { name: /cancel/i }).click(); + const selfRow = members + .getByRole("listitem") + .filter({ hasText: /owner/i }) + .filter({ hasText: /you/i }); + await expect(selfRow).toHaveCount(1); + await expect( + page.getByRole("button", { name: `Switch team, current: ${workspaceName}` }), + ).toBeVisible(); + }); +}); diff --git a/e2e/tests/workspaces/workspaces-on-load.spec.ts b/e2e/tests/workspaces/workspaces-on-load.spec.ts new file mode 100644 index 000000000..a22496f32 --- /dev/null +++ b/e2e/tests/workspaces/workspaces-on-load.spec.ts @@ -0,0 +1,54 @@ +import { test, expect, type Page } from "../../fixtures/test-fixtures"; +import { createWorkspace } from "../../helpers/api"; +import { getDiscordUserIdFromPage } from "../../helpers/paddle-db"; +import { enableWorkspacesFeatureInDb, setVerifiedEmailInDb } from "../../helpers/workspaces-db"; + +// Covers the "existing workspace present on initial load" path: the header workspace +// switcher and the Account Settings "Your workspaces" section must render from a +// workspace that already exists in the DB (not one created through the UI in-test). + +async function waitForAuthenticatedApp(page: Page): Promise { + await expect(page.getByRole("button", { name: "Account settings" })).toBeVisible({ + timeout: 15000, + }); +} + +test.describe("Workspaces on initial load", () => { + test("shows the workspace switcher and Your workspaces for a pre-existing workspace", async ({ + page, + }) => { + await page.goto("/feeds"); + await waitForAuthenticatedApp(page); + + const discordUserId = await getDiscordUserIdFromPage(page); + await enableWorkspacesFeatureInDb(discordUserId); + await setVerifiedEmailInDb(discordUserId, `verified-${discordUserId}@example.com`); + const workspaceName = `Load Workspace ${discordUserId}`; + await createWorkspace(page, { + name: workspaceName, + // Full id (all digits) keeps the slug unique — e2e user ids share a prefix. + slug: `load-workspace-${discordUserId}`, + }); + + await page.reload(); + await waitForAuthenticatedApp(page); + + // The count-gated switcher renders because the user already has a workspace. + const switcher = page.getByRole("button", { name: /Switch team/ }); + await expect(switcher).toBeVisible(); + await switcher.click(); + await expect(page.getByRole("menuitemradio", { name: workspaceName })).toBeVisible(); + await page.keyboard.press("Escape"); + + // The workspace is listed under "Your workspaces" in Account Settings. + await page.getByRole("button", { name: "Account settings" }).click(); + await page.getByRole("menuitem", { name: /account settings/i }).click(); + await expect(page.getByRole("heading", { name: "Your teams" })).toBeVisible({ + timeout: 15000, + }); + // The workspace's Settings link is unique to the "Your workspaces" row. + await expect( + page.getByRole("link", { name: `${workspaceName} settings` }), + ).toBeVisible(); + }); +}); diff --git a/e2e/tests/workspaces/workspaces.spec.ts b/e2e/tests/workspaces/workspaces.spec.ts new file mode 100644 index 000000000..4817bb316 --- /dev/null +++ b/e2e/tests/workspaces/workspaces.spec.ts @@ -0,0 +1,104 @@ +import { test, expect, type Page } from "../../fixtures/test-fixtures"; +import { getDiscordUserIdFromPage } from "../../helpers/paddle-db"; +import { enableWorkspacesFeatureInDb, setVerifiedEmailInDb } from "../../helpers/workspaces-db"; + +// There is no /workspaces page. Switching lives in a count-gated header +// workspace switcher; at 0 workspaces the only entry to create one is the account +// (avatar) menu's "Create a workspace" item. + +// Stable post-auth element present on every authenticated page regardless of how +// many feeds the user has (a fresh user sees the zero-feed onboarding view, which +// has no "Add Feed" button — so we wait on the header instead). +async function waitForAuthenticatedApp(page: Page): Promise { + await expect(page.getByRole("button", { name: "Account settings" })).toBeVisible({ + timeout: 15000, + }); +} + +test.describe("Workspaces", () => { + test("creates a workspace from the account menu and lands in workspace scope", async ({ + page, + }) => { + await page.goto("/feeds"); + await waitForAuthenticatedApp(page); + + const discordUserId = await getDiscordUserIdFromPage(page); + await enableWorkspacesFeatureInDb(discordUserId); + await setVerifiedEmailInDb(discordUserId, `verified-${discordUserId}@example.com`); + await page.reload(); + await waitForAuthenticatedApp(page); + + // 0 workspaces -> no switcher; the create entry lives in the account menu. + await expect(page.getByRole("button", { name: /switch team/i })).toHaveCount(0); + await page.getByRole("button", { name: "Account settings" }).click(); + await page.getByRole("menuitem", { name: /create a team/i }).click(); + + const dialog = page.getByRole("dialog"); + const workspaceName = `E2E Workspace ${discordUserId}`; + await dialog.getByLabel("Team name").fill(workspaceName); + await dialog.getByRole("button", { name: "Create team" }).click(); + + // Redirected into the workspace — a fresh workspace has no feeds, so the + // scoped feeds page renders the same discovery UI as the personal dashboard. + await expect(page).toHaveURL(/\/workspaces\/[^/]+\/feeds$/, { timeout: 15000 }); + await expect( + page.getByRole("heading", { name: "Get news delivered to your Discord" }), + ).toBeVisible(); + + // The switcher now exists in the header and reflects the active workspace. + await expect( + page.getByRole("button", { name: `Switch team, current: ${workspaceName}` }), + ).toBeVisible(); + + // The workspace is also listed under "Your workspaces" in Account Settings + // (upgrade path B), with a working Settings link into its settings page. + await page.getByRole("button", { name: "Account settings" }).click(); + await page.getByRole("menuitem", { name: /account settings/i }).click(); + await expect(page.getByRole("heading", { name: "Your teams" })).toBeVisible({ + timeout: 15000, + }); + await page.getByRole("link", { name: `${workspaceName} settings` }).click(); + await expect(page).toHaveURL(/\/workspaces\/[^/]+\/settings$/, { timeout: 15000 }); + await expect(page.getByRole("heading", { name: "Team settings" })).toBeVisible(); + }); + + test("gates workspace creation behind email verification when no verified email exists", async ({ + page, + }) => { + await page.goto("/feeds"); + await waitForAuthenticatedApp(page); + + const discordUserId = await getDiscordUserIdFromPage(page); + await enableWorkspacesFeatureInDb(discordUserId); + // Deliberately no verified email. + await page.reload(); + await waitForAuthenticatedApp(page); + + await page.getByRole("button", { name: "Account settings" }).click(); + await page.getByRole("menuitem", { name: /create a team/i }).click(); + + const dialog = page.getByRole("dialog"); + await expect(dialog.getByLabel("Email address")).toBeVisible(); + await expect(dialog.getByRole("button", { name: /send code/i })).toBeVisible(); + // The name form / create action is not reachable until an email is verified. + await expect(dialog.getByLabel("Team name")).toHaveCount(0); + await expect(dialog.getByRole("button", { name: "Create team" })).toHaveCount(0); + }); + + test("exposes no workspaces UI without the feature flag", async ({ page }) => { + await page.goto("/feeds"); + await waitForAuthenticatedApp(page); + // No workspaces flag seeded for this user. + + // No switcher, and no create-workspace entry in the account menu. + await expect(page.getByRole("button", { name: /switch team/i })).toHaveCount(0); + await page.getByRole("button", { name: "Account settings" }).click(); + await expect(page.getByRole("menuitem", { name: /create a team/i })).toHaveCount(0); + }); + + test("leaves the personal feeds dashboard unchanged", async ({ page }) => { + await page.goto("/feeds"); + await waitForAuthenticatedApp(page); + await expect(page).toHaveURL(/\/feeds$/); + }); +}); diff --git a/package-lock.json b/package-lock.json index 6f851159c..92a67b33b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1573,6 +1573,45 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -2732,6 +2771,22 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -4157,6 +4212,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -9041,14 +9105,6 @@ "node": ">=0.10.0" } }, - "packages/logger/node_modules/require-from-string": { - "version": "2.0.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "packages/logger/node_modules/requires-port": { "version": "1.0.0", "dev": true, @@ -9807,6 +9863,7 @@ "@monitorss/contracts": "^0.1.0", "@monitorss/logger": "^1.1.2", "@sinclair/typebox": "^0.34.48", + "ajv-formats": "^3.0.1", "commander": "^12.0.0", "dayjs": "^1.11.19", "dotenv": "^17.2.3", @@ -11640,21 +11697,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "services/backend-api/node_modules/ajv-formats": { - "version": "3.0.1", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, "services/backend-api/node_modules/amqp-connection-manager": { "version": "5.0.0", "license": "MIT", @@ -12077,20 +12119,6 @@ "license": "MIT", "peer": true }, - "services/backend-api/node_modules/fast-uri": { - "version": "3.1.2", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, "services/backend-api/node_modules/fast-xml-builder": { "version": "1.2.0", "dev": true, @@ -12949,13 +12977,6 @@ "bare": ">=1.10.0" } }, - "services/backend-api/node_modules/require-from-string": { - "version": "2.0.2", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "services/backend-api/node_modules/requires-port": { "version": "1.0.0", "license": "MIT" @@ -26253,13 +26274,6 @@ "node": ">=0.10.0" } }, - "services/feed-requests/node_modules/require-from-string": { - "version": "2.0.2", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "services/feed-requests/node_modules/requires-port": { "version": "1.0.0", "license": "MIT" @@ -28044,21 +28058,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "services/user-feeds-next/node_modules/ajv-formats": { - "version": "3.0.1", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, "services/user-feeds-next/node_modules/amqp-connection-manager": { "version": "5.0.0", "license": "MIT", @@ -28447,20 +28446,6 @@ "fast-decode-uri-component": "^1.0.1" } }, - "services/user-feeds-next/node_modules/fast-uri": { - "version": "3.1.0", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, "services/user-feeds-next/node_modules/fast-xml-parser": { "version": "4.5.3", "funding": [ @@ -29260,13 +29245,6 @@ "@redis/time-series": "1.1.0" } }, - "services/user-feeds-next/node_modules/require-from-string": { - "version": "2.0.2", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "services/user-feeds-next/node_modules/requires-port": { "version": "1.0.0", "license": "MIT" diff --git a/services/backend-api/client/.eslintrc.js b/services/backend-api/client/.eslintrc.js index 1521a96c3..5dc774e87 100644 --- a/services/backend-api/client/.eslintrc.js +++ b/services/backend-api/client/.eslintrc.js @@ -13,6 +13,7 @@ module.exports = { "error", { endOfLine: "auto", + printWidth: 100, }, ], "linebreak-style": 0, diff --git a/services/backend-api/client/docs/adr/001-architecture-characteristics.md b/services/backend-api/client/docs/adr/001-architecture-characteristics.md index 03fa03437..415970106 100644 --- a/services/backend-api/client/docs/adr/001-architecture-characteristics.md +++ b/services/backend-api/client/docs/adr/001-architecture-characteristics.md @@ -17,7 +17,7 @@ The three driving characteristics for the React app, in priority order: 1. **Accessibility / usability.** Keyboard navigation, screen reader support, WCAG-equivalent semantics. This is already a first-class concern in `client/CLAUDE.md` (live regions, `aria-busy`, indeterminate progress bars, "announce start and finish" patterns, the `DiscordMessageDisplay isLoading` indicator). Any architectural change MUST preserve these guarantees and SHOULD make them easier to apply consistently. 2. **Maintainability / evolvability.** The app has a solo maintainer. Structural rules exist so the maintainer (and future contributors, including LLM coding agents) can answer "where does this new thing go" without needing to recall historical context. The repo prefers a small number of clear rules over a large number of subtle ones. -3. **Extensibility.** Two future capabilities — destinations beyond Discord, and a team/org container for shared feed management — are committed in principle but not yet driving timelines. The structure should leave honest seams for both without paying meaningful abstraction cost up front. +3. **Extensibility.** Two future capabilities — destinations beyond Discord, and a workspace container for shared feed management — are committed in principle but not yet driving timelines. The structure should leave honest seams for both without paying meaningful abstraction cost up front. The remaining characteristics (raw performance, cross-app reusability, deployment flexibility, internationalization beyond what's already wired) are explicitly **not** in the top 3. They aren't ignored, but they don't override the three above when they conflict. @@ -28,7 +28,7 @@ The remaining characteristics (raw performance, cross-app reusability, deploymen - Every other ADR in this folder cites a characteristic from this list as justification. The decisions chain. - Code review has a concrete frame: "this change improves perf but hurts maintainability" is a sentence with a known winner. - Trade-offs that look obvious aren't. *Premature memoization across feature boundaries* would help perf, hurt maintainability — we will say no by default. -- Adding a planned capability (destinations, teams) doesn't require re-arguing why "extensibility" matters. +- Adding a planned capability (destinations, workspaces) doesn't require re-arguing why "extensibility" matters. **What becomes harder:** @@ -44,11 +44,11 @@ The remaining characteristics (raw performance, cross-app reusability, deploymen - ADR-002 (folder model) is justified by *maintainability* — make the home for new code obvious. - ADR-003 (state ownership) is justified by *maintainability* + *a11y* (URL state survives reloads, supports deep linking and sharing). - ADR-004 (destination extensibility) is justified by *extensibility* with an explicit "don't pay cost now" trade-off. -- ADR-005 (team scoping) is justified by *extensibility* with the same trade-off. +- ADR-005 (workspace scoping) is justified by *extensibility* with the same trade-off. - ADR-006 (fitness functions) is justified by *maintainability* — automated enforcement of decisions that would otherwise rot under a solo maintainer. ## Alternatives considered -- **Top-3 as: performance, maintainability, a11y.** Rejected because the user's roadmap (destinations + teams) makes extensibility a near-term consideration, while performance is "good enough" via the existing Vite + React Query setup. +- **Top-3 as: performance, maintainability, a11y.** Rejected because the user's roadmap (destinations + workspaces) makes extensibility a near-term consideration, while performance is "good enough" via the existing Vite + React Query setup. - **Top-3 as: testability, maintainability, a11y.** Testability was strong (MSW + dev-mockapi flags, vitest, react-testing-library) but is well-served as a *consequence* of maintainability, not a peer. The consistent form/mutation/invalidation pattern makes testing structurally easy. - **More than 3 characteristics.** Explicitly rejected by Richards/Ford and by the maintainer. Picking more is picking none. diff --git a/services/backend-api/client/docs/adr/003-state-ownership.md b/services/backend-api/client/docs/adr/003-state-ownership.md index 8962a9393..50888ae50 100644 --- a/services/backend-api/client/docs/adr/003-state-ownership.md +++ b/services/backend-api/client/docs/adr/003-state-ownership.md @@ -22,7 +22,7 @@ The first four are used correctly. **The fifth has lost discipline** — Context - *State that only exists to avoid prop-drilling two levels:* `SourceFeedContext` (3 consumers — `AddUserFeeds` page + `SourceFeedSelector` component) is a one-page concern that uses Context to avoid passing a value through a single intermediate component. Local state + a prop would be clearer. - *State that should be URL-deep-linkable but isn't:* feed list filters, selected tab on a detail page, current page in a paginated list. Currently lives in Context (or component state); doesn't survive reload, doesn't share via link. -The lack of a rule for what goes where is also the lack of a story for the planned team plan, where "send me a link to your filtered feed view" is a natural ask that the current shape doesn't support. +The lack of a rule for what goes where is also the lack of a story for the planned workspace plan, where "send me a link to your filtered feed view" is a natural ask that the current shape doesn't support. ## Decision @@ -126,7 +126,7 @@ No deviations without a documented reason. - A new piece of state has a one-decision-tree home, not a "well, there are five places…" conversation. - Bug class eliminated: "Context value changed → 50 components re-rendered" doesn't happen because Context isn't being used for non-cross-cutting state. -- Deep links and shareable filtered views become trivial once filters move to URL. Particularly important for the planned team plan ("my teammate sent me this link"). +- Deep links and shareable filtered views become trivial once filters move to URL. Particularly important for the planned workspace plan ("my teammate sent me this link"). - React Query becomes the *single* source of truth for server state — no shadow caches in Context. **Harder:** @@ -139,7 +139,7 @@ No deviations without a documented reason. - The "wrap something in a Context for testability" pattern. Use React Query's QueryClientProvider for server state, and component props for local. The genuinely-cross-cutting contexts that remain can use Context-based test wrappers. -**Specifically for `UserFeedStatusFilterContext`:** it has been relocated to `features/feed/contexts/`, but it still stores filter state in user preferences rather than the URL. The remaining (optional) improvement is to convert it to `useSearchParams()` for `?statuses=active,disabled,...` — reading from and writing to the URL, while the existing `useUserMe` / `useUpdateUserMe` calls continue to seed-from / persist-to user prefs in the background — and then delete the context. This is a small change with outsized value for the team-plan roadmap. +**Specifically for `UserFeedStatusFilterContext`:** it has been relocated to `features/feed/contexts/`, but it still stores filter state in user preferences rather than the URL. The remaining (optional) improvement is to convert it to `useSearchParams()` for `?statuses=active,disabled,...` — reading from and writing to the URL, while the existing `useUserMe` / `useUpdateUserMe` calls continue to seed-from / persist-to user prefs in the background — and then delete the context. This is a small change with outsized value for the workspace-plan roadmap. ## Alternatives considered diff --git a/services/backend-api/client/docs/adr/005-team-scoping.md b/services/backend-api/client/docs/adr/005-team-scoping.md deleted file mode 100644 index b18d953f7..000000000 --- a/services/backend-api/client/docs/adr/005-team-scoping.md +++ /dev/null @@ -1,164 +0,0 @@ -# ADR-005 — Team/org scoping: hybrid `/me` vs `/teams/:teamId` routes, optional ownership field - -**Status:** Accepted -**Date:** 2026-05-28 -**Scope:** `services/backend-api/client/src/`. Backend changes assumed but out of scope here. - -## Context - -The maintainer's roadmap includes a "team plan" that lets multiple users manage feeds together, modeled as an **org/team as top-level container** (like Slack workspaces or GitHub orgs — users belong to teams, teams own feeds). The chosen URL shape is **hybrid: personal scope and team scope side-by-side**, in the style of Linear and Notion (`/me/...` for personal, `/teams/:teamId/...` for team). - -This is a meaningful architectural change because today: - -- All routes are user-scoped without a prefix: `/feeds`, `/feeds/:feedId`, `/feeds/:feedId/discord-channel-connections/:connectionId`, etc. -- The `UserFeed` Yup schema (`features/feed/types/UserFeed.ts`) has no `ownerUserId`, `teamId`, or any ownership field. Grep confirmed: 0 hits across the client for these names. -- `getUserFeeds()` (`features/feed/api/getUserFeeds.ts:44`) hits `/api/v1/user-feeds?` — implicitly the current user's feeds. No team parameter exists. -- `features/discordServers/` happens to be a scoping container of sorts (it's "what guilds can I pick from?"), but it's not a team model. A team is application-owned and can integrate with multiple Discord guilds; the existing concept is the wrong abstraction. -- `pages/index.tsx` has no layout route per scope — every route is registered at the top level. - -The team plan isn't being built right now, but its design constraints affect decisions being made today (e.g., should filters live in the URL — yes, per ADR-003, because "send me a link to your filtered view of team feeds" is a natural ask). - -## Decision - -### Position: design the seams now, don't build the feature - -Mirroring ADR-004's stance for destinations: we will **define the route shape, the API shape, and the data-model field** so that adding teams later is additive and small. We will NOT add a team picker, a team management page, or any UI that consumes the seams. The seams are forward compatibility, not vaporware. - -### URL shape - -``` -Personal scope (default, no prefix today): - /feeds ← user's personal feeds list - /feeds/:feedId - /feeds/:feedId/discord-channel-connections/:connectionId - /add-feeds - /settings - -Future personal scope (with explicit /me prefix): - /me/feeds ← same as above, just explicit - /me/feeds/:feedId - … - -Future team scope: - /teams/:teamId/feeds - /teams/:teamId/feeds/:feedId - /teams/:teamId/feeds/:feedId/discord-channel-connections/:connectionId - /teams/:teamId/settings -``` - -**Today's decision:** the existing unprefixed routes continue to resolve. When teams ship, they become aliases for `/me/...` routes (server-side rewrite or client-side redirect). New code should NOT add un-prefixed routes that would conflict with `/teams/:teamId/`. Specifically: - -- No top-level route at `/teams` for any purpose other than the future team scope. -- `pages.userFeeds()` and similar route builders accept an optional scope, falling back to today's shape: - - ```ts - // constants/pages.ts - type Scope = { kind: 'personal' } | { kind: 'team'; teamId: string }; - - const scopePrefix = (scope?: Scope) => - !scope || scope.kind === 'personal' ? '' : `/teams/${scope.teamId}`; - - userFeeds: (scope?: Scope) => `${scopePrefix(scope)}/feeds`, - userFeed: (feedId: string, opts?: { tab?, new?: boolean }, scope?: Scope) => - `${scopePrefix(scope)}/feeds/${feedId}${opts?.tab ?? ''}…`, - ``` - - All call sites that don't pass a scope continue to work unchanged. - -### Data model - -Add an **optional** `teamId?: string | null` to the `UserFeed` Yup schema. Backend may continue to ignore it for now. When the team plan ships, the field becomes the source of truth for ownership. - -```ts -// features/feed/types/UserFeed.ts -export const UserFeedSchema = object({ - // …existing fields… - teamId: string().nullable().optional(), -}); -``` - -API calls (`getUserFeeds`, `createUserFeed`, etc.) accept an optional `teamId` parameter; the backend can ignore it today. The seam exists. - -`shareManageOptions.invites[]` (which currently references Discord user IDs) stays — that's a different concept (per-feed sharing within a personal account, vs. per-team membership) and the two should not be conflated. - -### Routing tree - -The `` block in `pages/index.tsx` does NOT need to change today. When teams ship, it will gain layout routes: - -```tsx - - {/* Existing personal routes — kept as default */} - } /> - } /> - … - - {/* New team routes layered on top */} - }> - } /> - } /> - … - - -``` - -The page components (``, ``) are scope-agnostic — they read `teamId` from `useParams()` (or get `undefined`) and pass it through to React Query hooks as an optional arg. No page is rewritten; the scope flows through as a parameter. - -This is enabled by: - -- Page components reading `teamId` from route params (TBD when teams ship; not today). -- React Query hooks accepting `teamId` and including it in the queryKey so personal-scope and team-scope queries are cached independently. - -### Permission UI is out of scope here - -Whether the team plan has roles (admin/editor/viewer), audit logs, etc. is a UX/product decision that this ADR doesn't take. The route shape and ownership-field decisions above don't presuppose any specific permission model — they support any model the team plan eventually adopts. - -### What we are NOT doing now - -- Not building team CRUD. -- Not adding a team picker to the header. -- Not refactoring `features/discordServers/` — it stays as a Discord guild listing (which is what it is), separate from the eventual team concept. -- Not introducing role/permission types. -- Not adding `useCurrentScope()` or similar hooks — without consumers, that's speculation. - -## Consequences - -**Easier:** - -- When the team plan is committed, the route changes are additive (a layout route + an optional `teamId` in route builders and API calls). No global rewrite. -- Per-feed cache isolation is automatic once `teamId` is part of the queryKey — no team's data bleeds into another's. -- Existing personal-scope routes continue to work without change. No user-visible regression. - -**Harder:** - -- Route builders gain an optional parameter. Slightly more verbose at call sites that pass it (none today). -- The `UserFeed` schema gains a nullable field, which means form code that creates/edits feeds must handle it (today: ignore it; future: scope-aware). - -**Lost:** - -- The ability to introduce `/teams` for any other purpose (e.g. as a marketing page). The path is reserved. - -**Specifically for the team plan implementation phase (future):** - -- Backend ADR will be needed for the team membership / ownership model. The frontend ADR here is decoupled from that — only the API contract (does `getUserFeeds()` take a `teamId`?) is shared. -- An ADR-008 (or similar) will be needed for the team-picker UI, the team-management routes, and the permission UI. - -**Implementation note:** - -The forward-compatibility shell is in place: the route builders in `constants/pages.ts` accept an optional `RouteScope`, `getUserFeeds()` accepts an optional `teamId`, and `UserFeedSchema` carries an optional nullable `teamId`. No UI consumes these yet — they exist so the eventual team-plan work is additive rather than a rewrite. - -## Alternatives considered - -- **Ambient team via Context (no URL prefix).** Considered and rejected during review. URL-based scoping is required for shareable team-scoped links and is closer to GitHub/Linear/Notion conventions. -- **All routes go under `/me/` or `/teams/:teamId/` (no implicit personal default).** Rejected because it requires migrating every existing URL on day one, which breaks bookmarks and is unnecessary churn for a feature that isn't shipping yet. -- **`/orgs/:orgId/teams/:teamId/...` (two-level org/team).** Out of scope. Hybrid `/teams/:teamId` is the chosen shape; if orgs containing teams become a need, that's ADR-N. -- **Build the team feature now to "get it out of the way."** Rejected by the maintainer's roadmap framing. This ADR is forward-compatibility, not implementation. -- **Refactor `features/discordServers/` to become the team container.** Rejected — these are different concepts. A team is application-owned; a Discord guild is external. Conflating them would lock the team model to a 1:1 relationship with Discord guilds, which contradicts the destination-extensibility direction (ADR-004). - -## Decisions locked in - -- **Naming:** `team` / `teamId`. Familiar (Slack, Linear), short, matches the user-facing "team plan" framing. Backend and frontend share this name. -- **URL shape:** **implicit `/me`** — personal-scope routes stay unprefixed (`/feeds`, `/feeds/:feedId`). Team-scope routes use `/teams/:teamId/...` with **opaque IDs** (not slugs). Bookmarks survive; no redirect on personal URLs. Slug-based URLs are deferred — they're a UX-nicety that adds backend cost (uniqueness, rename handling) and can be added later as an ADR-N additive change if the team plan grows enough to demand readable URLs. - -## Deferred (not blocking acceptance) - -- **Scope-aware caching: include `teamId` in every `features/feed/hooks/useUserFeed*()` queryKey from day one (even when it's always undefined), or only at the point where the field becomes consumed?** Decision deferred to the team-plan implementation phase. Recommendation when that lands: only at the point of use, to avoid cache misses during the migration. diff --git a/services/backend-api/client/docs/adr/005-workspace-scoping.md b/services/backend-api/client/docs/adr/005-workspace-scoping.md new file mode 100644 index 000000000..4b3b3ca96 --- /dev/null +++ b/services/backend-api/client/docs/adr/005-workspace-scoping.md @@ -0,0 +1,186 @@ +# ADR-005 — Workspace scoping: hybrid `/me` vs `/workspaces/:workspaceSlug` routes, optional ownership field + +**Status:** Accepted +**Date:** 2026-05-28 +**Scope:** `services/backend-api/client/src/`. Backend changes assumed but out of scope here. + +## Context + +The maintainer's roadmap includes a "workspace plan" that lets multiple users manage feeds together, modeled as a **workspace as top-level container** (like Slack workspaces or GitHub orgs — users belong to workspaces, workspaces own feeds). The chosen URL shape is **hybrid: personal scope and workspace scope side-by-side**, in the style of Linear and Notion (`/me/...` for personal, `/workspaces/:workspaceSlug/...` for workspace). + +This is a meaningful architectural change because today: + +- All routes are user-scoped without a prefix: `/feeds`, `/feeds/:feedId`, `/feeds/:feedId/discord-channel-connections/:connectionId`, etc. +- The `UserFeed` Yup schema (`features/feed/types/UserFeed.ts`) has no `ownerUserId`, `workspaceId`, or any ownership field. Grep confirmed: 0 hits across the client for these names. +- `getUserFeeds()` (`features/feed/api/getUserFeeds.ts:44`) hits `/api/v1/user-feeds?` — implicitly the current user's feeds. No workspace parameter exists. +- `features/discordServers/` happens to be a scoping container of sorts (it's "what guilds can I pick from?"), but it's not a workspace model. A workspace is application-owned and can integrate with multiple Discord guilds; the existing concept is the wrong abstraction. +- `pages/index.tsx` has no layout route per scope — every route is registered at the top level. + +The workspace plan isn't being built right now, but its design constraints affect decisions being made today (e.g., should filters live in the URL — yes, per ADR-003, because "send me a link to your filtered view of workspace feeds" is a natural ask). + +## Decision + +### Position: design the seams now, don't build the feature + +Mirroring ADR-004's stance for destinations: we will **define the route shape, the API shape, and the data-model field** so that adding workspaces later is additive and small. We will NOT add a workspace picker, a workspace management page, or any UI that consumes the seams. The seams are forward compatibility, not vaporware. + +### URL shape + +``` +Personal scope (default, no prefix today): + /feeds ← user's personal feeds list + /feeds/:feedId + /feeds/:feedId/discord-channel-connections/:connectionId + /add-feeds + /settings + +Future personal scope (with explicit /me prefix): + /me/feeds ← same as above, just explicit + /me/feeds/:feedId + … + +Future workspace scope: + /workspaces/:workspaceSlug/feeds + /workspaces/:workspaceSlug/feeds/:feedId + /workspaces/:workspaceSlug/feeds/:feedId/discord-channel-connections/:connectionId + /workspaces/:workspaceSlug/settings +``` + +**Today's decision:** the existing unprefixed routes continue to resolve. When workspaces ship, they become aliases for `/me/...` routes (server-side rewrite or client-side redirect). New code should NOT add un-prefixed routes that would conflict with `/workspaces/:workspaceSlug/`. Specifically: + +- No top-level route at `/workspaces` for any purpose other than the future workspace scope. +- `pages.userFeeds()` and similar route builders accept an optional scope, falling back to today's shape: + + ```ts + // constants/pages.ts + type RouteScope = { workspaceSlug?: string }; + + const scopePrefix = (scope?: RouteScope) => + scope?.workspaceSlug ? `/workspaces/${scope.workspaceSlug}` : ''; + + userFeeds: (scope?: RouteScope) => `${scopePrefix(scope)}/feeds`, + userFeed: (feedId: string, opts?: { tab?, new?: boolean }, scope?: RouteScope) => + `${scopePrefix(scope)}/feeds/${feedId}${opts?.tab ?? ''}…`, + ``` + + All call sites that don't pass a scope continue to work unchanged. + +### Data model + +Add an **optional** `workspaceId?: string | null` to the `UserFeed` Yup schema. When the workspace plan ships, the field becomes the source of truth for ownership. + +> **Note (2026-05-31):** The "backend may continue to ignore it" framing originally here is superseded — see the Amendment (2026-05-31) below. `workspaceId` is now an active field: backend enforces it for ownership, quota, and scope isolation. + +```ts +// features/feed/types/UserFeed.ts +export const UserFeedSchema = object({ + // …existing fields… + workspaceId: string().nullable().optional(), +}); +``` + +API calls (`getUserFeeds`, `createUserFeed`, etc.) accept an optional `workspaceId` parameter. The seam exists. + +`shareManageOptions.invites[]` (which currently references Discord user IDs) stays — that's a different concept (per-feed sharing within a personal account, vs. per-workspace membership) and the two should not be conflated. + +### Routing tree + +The `` block in `pages/index.tsx` does NOT need to change today. When workspaces ship, it will gain layout routes: + +```tsx + + {/* Existing personal routes — kept as default */} + } /> + } /> + … + + {/* New workspace routes layered on top */} + }> + } /> + } /> + … + + +``` + +The page components (``, ``) are scope-agnostic — they read the scope from `useParams()` (or get `undefined`) and pass it through to React Query hooks as an optional arg. No page is rewritten; the scope flows through as a parameter. + +This is enabled by: + +- Page components reading `workspaceSlug` from route params (TBD when workspaces ship; not today). +- React Query hooks accepting the scope and including it in the queryKey so personal-scope and workspace-scope queries are cached independently. + +### Permission UI is out of scope here + +Whether the workspace plan has roles (owner/admin/etc.), audit logs, etc. is a UX/product decision that this ADR doesn't take. The route shape and ownership-field decisions above don't presuppose any specific permission model — they support any model the workspace plan eventually adopts. + +### What we are NOT doing now + +- Not building workspace CRUD. +- Not adding a workspace picker to the header. +- Not refactoring `features/discordServers/` — it stays as a Discord guild listing (which is what it is), separate from the eventual workspace concept. +- Not introducing role/permission types. +- Not adding `useCurrentScope()` or similar hooks — without consumers, that's speculation. + +## Consequences + +**Easier:** + +- When the workspace plan is committed, the route changes are additive (a layout route + an optional scope in route builders and API calls). No global rewrite. +- Per-feed cache isolation is automatic once the scope is part of the queryKey — no workspace's data bleeds into another's. +- Existing personal-scope routes continue to work without change. No user-visible regression. + +**Harder:** + +- Route builders gain an optional parameter. Slightly more verbose at call sites that pass it (none today). +- The `UserFeed` schema gains a nullable field, which means form code that creates/edits feeds must handle it (today: ignore it; future: scope-aware). + +**Lost:** + +- The ability to introduce `/workspaces` for any other purpose (e.g. as a marketing page). The path is reserved. + +**Specifically for the workspace plan implementation phase (future):** + +- Backend ADR will be needed for the workspace membership / ownership model. The frontend ADR here is decoupled from that — only the API contract (does `getUserFeeds()` take a scope?) is shared. +- An ADR-008 (or similar) will be needed for the workspace-picker UI, the workspace-management routes, and the permission UI. + +**Implementation note:** + +The forward-compatibility shell is in place: the route builders in `constants/pages.ts` accept an optional `RouteScope`, `getUserFeeds()` accepts an optional scope, and `UserFeedSchema` carries an optional nullable `workspaceId`. No UI consumes these yet — they exist so the eventual workspace-plan work is additive rather than a rewrite. + +## Alternatives considered + +- **Ambient workspace via Context (no URL prefix).** Considered and rejected during review. URL-based scoping is required for shareable workspace-scoped links and is closer to GitHub/Linear/Notion conventions. +- **All routes go under `/me/` or `/workspaces/:workspaceSlug/` (no implicit personal default).** Rejected because it requires migrating every existing URL on day one, which breaks bookmarks and is unnecessary churn for a feature that isn't shipping yet. +- **`/orgs/:orgId/workspaces/:workspaceSlug/...` (two-level org/workspace).** Out of scope. Hybrid `/workspaces/:workspaceSlug` is the chosen shape; if a containing org layer becomes a need, that's ADR-N. (This is also why "team" is left unused — it's the natural name for a future *inner* grouping; see backend ADR-002 §Context.) +- **Build the workspace feature now to "get it out of the way."** Rejected by the maintainer's roadmap framing. This ADR is forward-compatibility, not implementation. +- **Refactor `features/discordServers/` to become the workspace container.** Rejected — these are different concepts. A workspace is application-owned; a Discord guild is external. Conflating them would lock the workspace model to a 1:1 relationship with Discord guilds, which contradicts the destination-extensibility direction (ADR-004). + +## Decisions locked in + +> **Note (2026-05-31):** The "opaque IDs (not slugs)" decision and the "Slug-based URLs are deferred" rationale below are superseded — see the Amendment (2026-05-31). URLs shipped as slug-based (`/workspaces/:workspaceSlug/...`). + +- **Naming:** `workspace` / `workspaceId`. The outer membership + resource unit, matching better-auth's `organization` plugin and reference platforms (Slack/Linear/Notion workspaces, GitHub orgs); "team" is left free for a future inner grouping. Backend and frontend share this name. +- **URL shape:** **implicit `/me`** — personal-scope routes stay unprefixed (`/feeds`, `/feeds/:feedId`). Workspace-scope routes use `/workspaces/:workspaceId/...` with **opaque IDs** (not slugs). Bookmarks survive; no redirect on personal URLs. Slug-based URLs are deferred — they're a UX-nicety that adds backend cost (uniqueness, rename handling) and can be added later as an ADR-N additive change if the workspace plan grows enough to demand readable URLs. + +## Deferred (not blocking acceptance) + +- **Scope-aware caching: include the scope in every `features/feed/hooks/useUserFeed*()` queryKey from day one (even when it's always undefined), or only at the point where the field becomes consumed?** Decision deferred to the workspace-plan implementation phase. Recommendation when that lands: only at the point of use, to avoid cache misses during the migration. + +--- + +## Amendment (2026-05-31) — slug-based workspace URLs; `workspaceId` is an active field + +**Status:** Accepted (supersedes the "Decisions locked in" slug deferral and the "Data model" dormant-seam framing). + +### Slug-based URLs replace opaque IDs + +The locked-in decision of `/workspaces/:workspaceId/...` with opaque IDs was superseded before the branch shipped. Workspace scope URLs are `/workspaces/:workspaceSlug/...` throughout — backend routes, client route builders (`scopePrefix` in `constants/pages.ts`), `RouteParams` type, and `CurrentWorkspaceContext` all use `workspaceSlug`. + +The `RouteScope` type shipped as `{ workspaceSlug?: string }`. `pages.workspaceSettings()` takes a `workspaceSlug` argument. Mock handlers key on `slug`. The route tree is `}>`. + +**Why it was worth the backend cost.** The "Decisions locked in" rationale cited uniqueness and rename handling as the blocker. Both were addressed in the same round: a unique index on `slug` and the `WORKSPACE_SLUG_TAKEN` error code for conflicts. No backfill was needed — slugs ship with workspaces, so every workspace has one from creation. The cost was acceptable given that readable, shareable workspace URLs (e.g. `/workspaces/acme-marketing/feeds`) are materially better for UX than opaque ObjectId strings. See backend ADR-002 §6 for the full slug model (the shared `SLUG_PATTERN`/`SLUG_MAX` validation source of truth). + +### `workspaceId` is now an active field (supersedes the "Data model" dormant-seam framing) + +The "Backend may continue to ignore it" / dormant-seam framing in the Data model section is superseded. `workspaceId` on `UserFeed` is enforced by the backend: it gates ownership, quota, and scope isolation. See backend ADR-002 §7 for the full feed↔workspace model (workspace quota via `getWorkspaceBenefits`, insulation from personal supporter limits, scope isolation in queries). The client-side `workspaceId` in `UserFeedSchema` and the scope in `getUserFeeds()` are active, consumed parameters — not forward-compatibility seams. diff --git a/services/backend-api/client/docs/adr/008-workspace-ui.md b/services/backend-api/client/docs/adr/008-workspace-ui.md new file mode 100644 index 000000000..7f39ef627 --- /dev/null +++ b/services/backend-api/client/docs/adr/008-workspace-ui.md @@ -0,0 +1,145 @@ +# ADR-008 — Workspace UI: a count-gated header workspace switcher, scope-agnostic pages, owner/admin settings + +**Status:** Accepted +**Date:** 2026-05-29 (header switcher 2026-05-30; slugs + real workspace feeds 2026-05-31) +**Scope:** `services/backend-api/client/src/`. The API contract and data model are backend ADR-002. + +## Addendum (2026-06-07) — user-facing label is "Team", code/URLs/API stay "Workspace" + +The entity is named **Workspace** throughout the code, data model, URLs (`/workspaces/:workspaceSlug`), API routes, env vars, and error codes (see [[project_teams_renamed_workspaces]] for why `Workspace` was chosen over `Team` at the model layer — it aligns with better-auth's `organization` plugin and keeps `team` free for a future inner sub-grouping). + +However, **all user-visible copy presents it as "Team"** — it is the more obvious, familiar word for end users. So: headings, field labels, buttons, validation/error messages, `aria-label`s, live-region announcements, placeholders, and the standard error-code message map all say "team" (e.g. "Create a team", "Team name", "Team settings", "Your teams", "Switch team"). Component names, hooks, contexts, the `features/workspaces/` slice, route builders, query keys, and the `featureFlags.workspaces` flag remain "workspace". + +**Deliberately kept as "workspace" in UI:** the literal `/workspaces/` URL prefix shown in the slug input addon and the "URL preview: /workspaces/…" helper text — these display the real, navigable path (routes are unchanged), so showing anything else would be inaccurate. + +This is a copy-only mapping (label ≠ identifier); there is no second entity. When adding new workspace UI, write user-facing strings as "team" and keep all identifiers "workspace". The prose below predates this addendum and still says "workspace" when describing the UI — read those as the user seeing "team". + +## Context + +Client ADR-005 ("Workspace scoping") reserved this ADR for "the workspace-picker UI, the workspace-management routes, and the permission UI." ADR-005 built only seams (`RouteScope`/`scopePrefix` in `constants/pages.ts`, `getUserFeeds()`'s optional scope, `UserFeedSchema.workspaceId`) and consumed none. This ADR designs the UI that consumes them. + +Requirements: +- A way to enter either a workspace's dashboard or the existing personal dashboard (req #3). +- The **existing personal dashboard works exactly as before** (req #4) — no regression, no forced `/feeds` migration. +- Creating a workspace needs a verified email + a name (req #5); inviting members is out of scope (req #2). +- Two roles — owner/admin; every member edits, only an owner does owner-only actions (delete / transfer ownership). +- All workspace UI is feature-flag gated (req #7). +- Testable with existing E2E patterns, **no mail server** (NFR #1). + +Ground truth reused (not reinvented): +- **Routing:** react-router-dom v6, routes in `pages/index.tsx`, builders in `constants/pages.ts` (already scope-aware). +- **Server state:** React Query v4 + `fetchRest()` API layer + per-feature hooks (ADR-003: server state ⇒ React Query, shareable ⇒ URL, cross-cutting ⇒ Context). +- **Feature flags:** `useUserMe().featureFlags` (`externalProperties` precedent); backend adds `workspaces?: boolean`. +- **Current-entity context precedent:** `UserFeedContext` — id prop → query hook → memoized value → `useX()`. +- **No global sidebar:** navigation is the top header (`NewHeader`/`AppHeader`) + centered content; `SidebarLink` is page-local. +- **Client conventions (`client/CLAUDE.md`):** every request needs a mock handler + a visible, announced loading state + a near-the-action error state; native HTML preferred; forms = react-hook-form + yup → mutation → PageAlert + InlineErrorAlert. + +## Decision + +### 1. Switching lives in a count-gated header workspace switcher + +> An earlier draft made a dedicated hidden `/workspaces` page the entry point. It was replaced before shipping — there is no global sidebar to host a workspace list, and workspaces is a low-cardinality paid feature (most users have 0 workspaces; payers typically 1), so a full-width page rendering 1–2 rows is mostly empty space. Switching belongs in a content-proportional control, not a page. The `/workspaces` route name stays free for future use. + +A `workspaceSlot` on `NewHeader` (rendered between the logo and the search cluster; wired by `AppHeader` so the shared-base `NewHeader` stays feature-free). It is a native Chakra `Menu`: a button labelled with the active workspace, opening a list of "Personal" + each workspace. Selecting routes (`Personal → pages.userFeeds()`, workspace → `/workspaces/:workspaceSlug/feeds`). Active scope is **derived** from the router + `useCurrentWorkspace()` (`null` ⇒ Personal) — no new state mechanism. + +**Count-gated (progressive disclosure):** when `useWorkspaces()` returns **0 workspaces**, the switcher is not rendered — the header is byte-for-byte today's. It appears only at ≥1 workspace. Feature-flag gating (`useIsWorkspacesEnabled()`) still applies on top: flag off ⇒ no switcher regardless of count. + +**Two gating layers, both client-checked for rendering and re-enforced server-side** (backend ADR-002 §6/§8): the deployment toggle (self-hoster opt-in, surfaced through the `/users/@me` capability path — off ⇒ no workspaces surface at all) and the per-user rollout flag (`useUserMe().featureFlags.workspaces`). The client gate is UX only; correctness is the server's (it won't register routes / will `403`). + +### 2. Workspace scope reuses the existing pages via a layout route + +Page components stay scope-agnostic, read the workspace from `useParams()`, and pass it to hooks: + +```tsx +// pages/index.tsx — additive; existing personal routes untouched +}> + } /> + } /> + {/* …mirrors the personal subtree… */} + +``` + +``/`` are **not forked** — they read the route param (`undefined` in personal scope) and forward it to `useUserFeeds({ workspaceId })` etc. The hooks add it to their **queryKey** so personal and workspace caches never bleed (ADR-005's "include at point of use" recommendation, followed only now that it's consumed). `WorkspaceScopeLayout` self-gates via `useIsWorkspacesEnabled()` and validates the slug against the user's memberships (`useWorkspaces()`); a non-member or unknown slug renders not-found/forbidden rather than leaking an empty dashboard. + +URLs are **slug-based** (`/workspaces/:workspaceSlug`, not `:workspaceId`) — see backend ADR-002 §6 and ADR-005 Amendment for the slug model. `RouteParams` carries `workspaceSlug`; `RouteScope` is `{ workspaceSlug?: string }`; `scopePrefix` builds `/workspaces/${scope.workspaceSlug}`; `useWorkspace({ workspaceSlug })` and `["workspace", { workspaceSlug }]` keys are slug-keyed throughout. + +### 3. `CurrentWorkspaceContext` — the one new Context (ADR-003 Q4) + +Workspace scope needs `{ id, name, slug, myRole }` in two unrelated places — the header (which workspace you're in) and the settings page (role-gating). That's "cross-cutting, ≥2 unrelated consumers" ⇒ Context. Mirrors `UserFeedContext`: provided by `WorkspaceScopeLayout` from a `useWorkspace()` query, memoized, exposed via `useCurrentWorkspace()`. Personal scope renders no provider; `useCurrentWorkspace()` returns `null` and pages treat `null` as personal. No `useCurrentScope()` mega-hook (ADR-005 rejected speculative scope hooks). + +### 4. Workspace creation: gated on a verified, owned email captured passwordlessly + +Two UI steps, because the gate is an owned-and-verified email, not the Discord email (backend ADR-002 §4): + +- **Step A — verify an owned email (one-time, passwordless).** If `useUserMe()` shows no `verifiedEmail`, the create action becomes a "Verify an email" prompt: an email field pre-filled with the Discord email → `useSendEmailVerification()` emails a one-time code → a code input calls `useConfirmEmailVerification()` → on success `['user-me']` is invalidated and the verified email appears. Rate-limited resend + change-email available. No password field — this is proof-of-ownership. +- **Step B — create the workspace.** With `verifiedEmail` present, a name+slug form (the client previews a derived slug live via `slugifyPreview`; the slug is validated server-side) follows the standard pattern: `yupResolver` → `useCreateWorkspace()` → on success invalidate `['workspaces']` and navigate to the new workspace's feeds; on error `InlineErrorAlert` + `PageAlert`. + +The gate is **server-authoritative** (`403` if unverified); the client read of `verifiedEmail` only decides which step to show. + +### 5. Role-gated settings, built on role not identity + +A workspace settings surface (rename, change slug) reads `myRole` from `useCurrentWorkspace()`. Every member can edit these fields — there is no read-only tier — so the form is editable for owner and admin alike, wired to `useUpdateWorkspace()` (a taken slug surfaces `WORKSPACE_SLUG_TAKEN`, handled inline). The only role gate is **owner-only** actions (delete / transfer ownership), which are not built this round; when they land they'll be `can('deleteWorkspace', role)`-shaped client-side for UX and re-enforced by the backend `403`. No permissions framework — a single role check matching the backend's two-role model (ADR-002 §3). + +### 6. Data layer + mocks (client/CLAUDE.md compliance) + +New API files + hooks under a `features/workspaces/` slice, each with a mock handler in `src/mocks/handlers.ts` and `pickMockDelayMs`/`mockHasFlag` toggles so loading/error states are inspectable via `npm run dev-mockapi`. Every hook renders a visible + announced loading state and a near-the-action, recoverable error state (no swallowed `error`); lists use `` on initial load and `keepPreviousData` on background refetch. (The email-verification mutations may live in the user-settings slice instead, since verified email is not workspace-specific — decided at implementation.) + +### 7. Workspace-scoped feed data is wired + +The workspace-scoped `` renders **real** feeds: `UserFeed.workspaceId` is active (backend ADR-002 §7), membership gates access, and `useUserFeeds({ workspaceId })` passes the scope to the backend query with `workspaceId` in the queryKey for cache isolation. (An earlier draft showed an explicit empty state until feed↔workspace association landed; it shipped in the same round, so that state is gone.) + +### 8. Switcher behaviour and accessibility (WCAG 2.1 AA) + +- **Rows carry no role badge** — a row is monogram + name + active mark. Switching is choosing *where to work*, not auditing permissions; role surfaces where it gates action (§5). At >7 workspaces the list shows an inline `type="search"` filter (Personal and footer actions never filtered out). +- **Active vs. hover are distinct, non-color-only states.** Rows use `MenuRadioItemGroup` + `MenuRadioItem`, so the active row has a real `role="menuitemradio"` + `aria-checked`. Active is carried by a persistent mark (✓ + weight + left accent); hover/focus by a transient background — independent channels, never color- or background-alone (WCAG 1.4.1, 2.4.7). Keyboard focus and mouse hover share one visual state. (Exact tokens live in the component.) +- **Native Chakra `Menu` keyboard pattern** (Enter/Space/↓ open, arrows, Home/End, type-ahead, Esc restores focus to the trigger). The button's accessible name includes the current workspace ("Switch workspace, current: ``"). Workspaces loading: `aria-busy` + a visually-hidden `aria-live="polite"` region; error: `role="alert"` + a real Retry button (Personal is always present, needing no network). No false `aria-current`. Mobile: a compact trigger keeps its full accessible name, ≥44px targets (folding into the account menu is a kept fallback if header width proves tight). + +### 9. Entry points for create/manage + +- **Create a workspace** is reached from the switcher footer (`+ Create workspace`) and, at 0 workspaces (no switcher), from the top-right account menu. Both open `CreateWorkspaceDialog` (§4). +- **Manage a workspace** is reached from the switcher footer when the active workspace is a workspace (`⚙ settings` → `/workspaces/:workspaceSlug/settings`), hidden in Personal scope. Workspace management hangs off the workspace control (the Slack/Linear pattern), not the account menu — the account menu stays identity-only to avoid a scope mismatch between an identity surface and a role-gated workspace action. +- **A "Your workspaces" section** also lives on the existing Account Settings page (`UserSettings`, as `WorkspacesSettingsSection`, gated by `useIsWorkspacesEnabled()`), between Integrations and Preferences. It lists each workspace with a role badge + **Open** and **Settings** actions + a **Create workspace** action, with loading/error(+retry)/empty states. It complements the switcher footer: the footer reaches the *active* workspace's settings from anywhere; this section reaches *any* workspace's settings without switching first. **No "Leave" action** — there is no leave endpoint yet, and dead/disabled UI implying a working action is avoided; a Leave slots in per-row when the endpoint lands. + +### 10. Search stays scope-aware (feature parity) + +The header search (`SearchFeedsModal`, `SearchIcon` button → modal, Cmd/Ctrl+K) stays in the left cluster; permanent header order is **logo → switcher → search** (scope sets context, search acts within it). It reads the active scope (from `useCurrentWorkspace()`/params) and passes it to its `useUserFeeds`/`useUserFeedsInfinite` queries + `/feeds` nav targets, so it searches the active workspace's feeds. The header layout never changes. + +### What we are NOT building (this round) + +- No member list / invite / remove UI (req #2) — additive via the backend's email-keyed invitations (ADR-002 §10); an accept page would reuse the email-verification flow. +- No alternate-login UI — the identity seam is `request.userId` + unique `verifiedEmail` (ADR-002 §9); the consuming UI defers with that work. +- No new state mechanism — React Query + URL + the single `CurrentWorkspaceContext`. + +## E2E plan (NFR #1) + +Reuses the existing harness (`e2e/`, Playwright, mock Discord session via `/__test__/set-session`, direct-DB seeding): + +1. **Feature enabled:** deployment toggle on for the e2e stack (env in the backend service, no structural compose change); per-user `featureFlags.workspaces = true` seeded for the test user. +2. **Verified email without a mail server:** a `setVerifiedEmailInDb` helper (mirroring `setSupporterStatusInDb`) writes the verified state directly. The real send/confirm can be exercised against a mock mailer or asserted at the mutation boundary. +3. **Create flow:** with the email seeded, open `CreateWorkspaceDialog` from its entry (account menu at 0 workspaces, or the switcher footer once present) → fill name → submit → assert redirect to the new workspace's feeds and that the workspace appears in the switcher (open it, assert listed + active). +4. **Regression:** assert the personal `/feeds` dashboard is unchanged and the default landing (req #4), and that a 0-workspace user sees **no switcher** (count-gating). +5. **Negatives:** with no `verifiedEmail`, assert create is blocked and the backend `403` path runs; with the deployment toggle off, assert workspace routes `404`, the switcher is absent, and no workspaces surface renders (req #7). + +## Consequences + +**Easier:** +- Workspace scope is a layout route + a context + scope-aware hooks — additive, no rewrite (the payoff ADR-005 paid for upfront). +- Personal dashboard is provably unchanged (its routes/components aren't touched). +- Flag-gating is the existing `useUserMe()` pattern; flipping the flag GAs the feature with no code change. +- The empty-surface problem is resolved structurally (a content-proportional control + count-gating, no standalone page). + +**Harder:** +- Page components must stay genuinely scope-agnostic — a hard-coded personal assumption becomes a workspace-scope bug. Mitigated by the scope flowing params→hooks→queryKey uniformly. +- The switcher is globally mounted, so it must stay memoized and runs `useWorkspaces()` for flagged users on every page (cheap, `keepPreviousData`-cached; Personal renders without it). Active-state correctness depends on workspace routes being wrapped by `CurrentWorkspaceContext` (§3). + +**Lost:** +- The dedicated `/workspaces` page (and a single "see all workspaces" view, until the Account-Settings "Your workspaces" section, §9). The route name stays free. + +## Alternatives considered + +- **A dedicated `/workspaces` chooser page.** Rejected before shipping (§1) — no sidebar to host it, low cardinality makes it mostly empty, and its one durable job (member management) is per-workspace and belongs on `/workspaces/:workspaceSlug/settings`. +- **A read-only `member` role beneath `admin`.** Rejected — collaboration means every member edits, so a read-only tier is friction without benefit; the real gate is owner-only destructive actions (§5, backend ADR-002 §3 Alternatives). +- **Fork `UserFeeds`/`UserFeed` into workspace variants.** Rejected — doubles maintenance; the only difference is a parameter. +- **Ambient current-workspace via Context, no URL prefix.** Rejected (as ADR-005 did) — shareable workspace links require the scope in the URL. +- **Workspace settings in the top-right account menu.** Rejected (§9) — scope mismatch between an identity surface and a role-gated workspace action; management hangs off the workspace control. +- **Client-only feature gate.** Rejected — re-enforced server-side (ADR-002 §6); the client gate is UX, not security. diff --git a/services/backend-api/client/docs/adr/README.md b/services/backend-api/client/docs/adr/README.md index 4b323636c..9bdb9f27a 100644 --- a/services/backend-api/client/docs/adr/README.md +++ b/services/backend-api/client/docs/adr/README.md @@ -28,15 +28,16 @@ Each ADR follows the [Michael Nygard one-pager template](https://cognitect.com/b | [002](002-folder-model.md) | Folder model: thin pages, fat features (with destination sub-features), narrow shared base | Accepted | | [003](003-state-ownership.md) | State ownership: React Query for server, URL for shareable, Context only for cross-cutting | Accepted | | [004](004-destination-extensibility.md) | Destination extensibility: keep the FeedConnectionType shell honest via destination sub-features | Accepted | -| [005](005-team-scoping.md) | Team scoping: `team` / `teamId`, implicit `/me` + opaque `/teams/:teamId/...` | Accepted | +| [005](005-workspace-scoping.md) | Workspace scoping: implicit `/me` + slug-based `/workspaces/:workspaceSlug/...` | Accepted | | [006](006-fitness-functions.md) | Frontend fitness functions: three ESLint architecture rules | Accepted | | [007](007-styling-roles-tiers-contrast.md) | Styling: a semantic role system, encoding mechanisms, and a contrast gate | Accepted | +| [008](008-workspace-ui.md) | Workspace UI: a count-gated header workspace switcher, scope-agnostic pages, owner/admin settings | Accepted | ## When to write a new frontend ADR - A decision constrains where future code goes (folder model, state ownership). - A decision records a trade-off the maintainer will second-guess later. - A library or framework is being replaced. -- A feature crosses architectural boundaries (destinations, team scoping). +- A feature crosses architectural boundaries (destinations, workspace scoping). Implementation details visible from code don't need ADRs. diff --git a/services/backend-api/client/src/components/ConfirmModal/index.tsx b/services/backend-api/client/src/components/ConfirmModal/index.tsx index c3896b1e6..b592ab6cd 100644 --- a/services/backend-api/client/src/components/ConfirmModal/index.tsx +++ b/services/backend-api/client/src/components/ConfirmModal/index.tsx @@ -47,6 +47,7 @@ export const ConfirmModal = ({ const [uncontrolledOpen, setUncontrolledOpen] = useState(false); const isControlled = controlledOpen !== undefined; const open = isControlled ? controlledOpen : uncontrolledOpen; + const setOpen = (next: boolean) => { if (!isControlled) { setUncontrolledOpen(next); @@ -54,6 +55,7 @@ export const ConfirmModal = ({ onOpenChange?.(next); }; + const { t } = useTranslation(); const [loading, setLoading] = useState(false); const cancelRef = React.useRef(null); @@ -61,9 +63,14 @@ export const ConfirmModal = ({ const onClickConfirm = async () => { setLoading(true); + // A rejecting onConfirm keeps the modal open so the caller's `error` prop can be + // shown; swallow the rejection here rather than let it escape as an unhandled + // rejection (nothing awaits this click handler). try { await onConfirm(); setOpen(false); + } catch { + // Intentionally ignored — the failure is surfaced via the `error` prop. } finally { setLoading(false); } diff --git a/services/backend-api/client/src/components/NewHeader/index.tsx b/services/backend-api/client/src/components/NewHeader/index.tsx index 5d9a0a4e5..5254f2498 100644 --- a/services/backend-api/client/src/components/NewHeader/index.tsx +++ b/services/backend-api/client/src/components/NewHeader/index.tsx @@ -14,7 +14,9 @@ interface Props { isBotLoading?: boolean; botError?: { message: string } | null; user?: { iconUrl?: string; username?: string; id?: string }; + workspaceSlot?: React.ReactNode; searchSlot?: React.ReactNode; + accountMenuSlot?: React.ReactNode; logoutSlot?: React.ReactNode; } @@ -24,7 +26,9 @@ export const NewHeader = ({ isBotLoading, botError, user, + workspaceSlot, searchSlot, + accountMenuSlot, logoutSlot, }: Props) => { const navigate = useNavigate(); @@ -75,6 +79,7 @@ export const NewHeader = ({ )} {botError && } + {workspaceSlot && {workspaceSlot}} {searchSlot} @@ -120,6 +125,7 @@ export const NewHeader = ({ Discord Support Server + {accountMenuSlot} {logoutSlot} diff --git a/services/backend-api/client/src/constants/pages.ts b/services/backend-api/client/src/constants/pages.ts index f65bb321d..2bc3ee247 100644 --- a/services/backend-api/client/src/constants/pages.ts +++ b/services/backend-api/client/src/constants/pages.ts @@ -12,13 +12,12 @@ const getConnectionPathByType = (type: FeedConnectionType) => { }; /** - * Forward-compatibility shell per ADR-005 (team scoping). - * When teams ship, pass `{ teamId }` to scope a route to a team workspace. - * Personal-scope routes (no `teamId`) stay unprefixed so existing bookmarks survive. + * Workspace scope uses a human-readable slug. + * Personal-scope routes (no `workspaceSlug`) stay unprefixed so existing bookmarks survive. */ -export type RouteScope = { teamId?: string }; +export type RouteScope = { workspaceSlug?: string }; -const scopePrefix = (scope?: RouteScope) => (scope?.teamId ? `/teams/${scope.teamId}` : ""); +const scopePrefix = (scope?: RouteScope) => (scope?.workspaceSlug ? `/workspaces/${scope.workspaceSlug}` : ""); export const pages = { checkout: (priceId: string, feeds?: { quantity: number; priceId: string }) => @@ -29,8 +28,10 @@ export const pages = { connectionId: string; scope?: RouteScope; }) => `${pages.userFeedConnection(data)}/message-builder`, - addFeeds: () => "/add-feeds", + addFeeds: (scope?: RouteScope) => `${scopePrefix(scope)}/add-feeds`, userSettings: () => "/settings", + workspaceSettings: (workspaceSlug: string) => `/workspaces/${workspaceSlug}/settings`, + workspaceInvite: (inviteId: string) => `/invites/${inviteId}`, userFeeds: (scope?: RouteScope) => `${scopePrefix(scope)}/feeds`, notFound: () => "/not-found", testPaddle: () => "/test-paddle", diff --git a/services/backend-api/client/src/features/discordUser/types/UserMe.ts b/services/backend-api/client/src/features/discordUser/types/UserMe.ts index 5223bb1ba..d7c38e9ab 100644 --- a/services/backend-api/client/src/features/discordUser/types/UserMe.ts +++ b/services/backend-api/client/src/features/discordUser/types/UserMe.ts @@ -4,6 +4,7 @@ import { ProductKey } from "../../../constants"; export const UserMeSchema = object({ id: string().required(), email: string(), + verifiedEmail: string(), preferences: object({ alertOnDisabledFeeds: bool().default(false), dateFormat: string().nullable(), @@ -62,7 +63,11 @@ export const UserMeSchema = object({ enableBilling: bool(), featureFlags: object({ externalProperties: bool(), + workspaces: bool(), }), + capabilities: object({ + workspaces: bool(), + }).optional(), supporterFeatures: object({ exrternalProperties: object({ enabled: bool(), diff --git a/services/backend-api/client/src/features/feed/api/createUserFeed.ts b/services/backend-api/client/src/features/feed/api/createUserFeed.ts index 1d5f22323..ba6ff7548 100644 --- a/services/backend-api/client/src/features/feed/api/createUserFeed.ts +++ b/services/backend-api/client/src/features/feed/api/createUserFeed.ts @@ -8,6 +8,8 @@ export interface CreateUserFeedInput { url?: string; curatedFeedId?: string; sourceFeedId?: string; + // When set, the feed is created under this workspace. + workspaceId?: string; }; } diff --git a/services/backend-api/client/src/features/feed/api/getUserFeeds.ts b/services/backend-api/client/src/features/feed/api/getUserFeeds.ts index e2786ba51..eee6a9096 100644 --- a/services/backend-api/client/src/features/feed/api/getUserFeeds.ts +++ b/services/backend-api/client/src/features/feed/api/getUserFeeds.ts @@ -13,10 +13,9 @@ export interface GetUserFeedsInput { hasConnections?: boolean; }; /** - * Optional team scope. Undefined = personal feeds (current behavior). - * Forward-compatibility shell per ADR-005 — backend may ignore today. + * Optional workspace scope. Undefined = personal feeds; set = the workspace's feeds. */ - teamId?: string; + workspaceId?: string; } const GetUserFeedsOutputSchema = object({ @@ -44,8 +43,8 @@ export const getUserFeeds = async (options: GetUserFeedsInput): Promise; - -interface Props { - trigger?: React.ReactElement; -} - -const RESOLVABLE_ERRORS: string[] = [ - ApiErrorCode.FEED_REQUEST_FORBIDDEN, - ApiErrorCode.FEED_REQUEST_TOO_MANY_REQUESTS, -]; - -export const AddUserFeedDialog = ({ trigger }: Props) => { - const [open, setOpen] = useState(false); - const { t } = useTranslation(); - const { - handleSubmit, - control, - reset, - formState: { errors, isSubmitting }, - getValues, - watch, - } = useForm({ - resolver: yupResolver(formSchema), - }); - const [urlFromForm] = watch(["url"]); - const { data: discordUserMe } = useDiscordUserMe(); - const { data: userMe } = useUserMe(); - const { data: userFeedsResults } = useUserFeeds({ - limit: 1, - offset: 0, - }); - const { onOpen: onOpenPricingDialog } = useContext(PricingDialogContext); - const { mutateAsync, error: createError, reset: resetMutation } = useCreateUserFeed(); - const { - data: feedUrlValidationData, - mutateAsync: createUserFeedUrlValidation, - error: validationError, - reset: resetValidationMutation, - } = useCreateUserFeedUrlValidation(); - const navigate = useNavigate(); - const isConfirming = !!feedUrlValidationData?.result.resolvedToUrl; - - const onSubmit = async ({ title, url }: FormData) => { - if (isSubmitting) { - return; - } - - try { - if (!feedUrlValidationData) { - const { result } = await createUserFeedUrlValidation({ details: { url } }); - - if (result.resolvedToUrl) { - return; - } - } - - const { - result: { id }, - } = await mutateAsync({ - details: { - title, - url: feedUrlValidationData?.result.resolvedToUrl - ? feedUrlValidationData.result.resolvedToUrl - : url, - }, - }); - - reset(); - setOpen(false); - navigate(pages.userFeed(id), { - state: { - isNewFeed: true, - }, - }); - } catch (err) {} - }; - - useEffect(() => { - reset(); - resetMutation(); - resetValidationMutation(); - }, [open]); - - const error = createError || validationError; - const canResolveError = error?.errorCode && RESOLVABLE_ERRORS.includes(error.errorCode); - const isAtLimit = - userFeedsResults && discordUserMe && userFeedsResults?.total >= discordUserMe?.maxUserFeeds; - - return ( - <> - {trigger ? ( - React.cloneElement(trigger, { - onClick: () => setOpen(true), - }) - ) : ( - setOpen(true)} variant="solid"> - {t("features.userFeeds.components.addUserFeedDialog.addButton")} - - )} - setOpen(e.open)} size="xl"> - -
- - - {isConfirming - ? "Confirm feed link change" - : t("features.userFeeds.components.addUserFeedDialog.title")} - - - - - {isConfirming && ( - - - - - We found - - - - {feedUrlValidationData.result.resolvedToUrl} - - - - {" "} - - instead that might be related to the url you provided. Do you want to use - this feed link instead? - - - - Your original link - - {urlFromForm} - - will not be used. - - - - )} - {!isConfirming && ( - - - - - Limits - - - - }> - - - Feed Limit - - - - - - Daily Article Limit Per Feed - - - {userMe && - userMe.result.subscription.product.key !== ProductKey.Free && - 1000} - {userMe && - userMe.result.subscription.product.key === ProductKey.Free && - 50} - {!userMe && } - - - - - - ( - - )} - /> - - - ( - - )} - /> - - - - Frequently Asked Questions - - - - - - What is an RSS feed? - - - - - An RSS feed is a specially-formatted webpage with XML text that's - designed to contain news articles. An example of an RSS feed link is{" "} - - https://www.ign.com/rss/articles/feed - - . -
-
- To see if a link is a valid RSS feed, you may search for "online feed - validators" and input feed URLs to test. -
-
-
- - - - How do I find RSS feeds? - - - - - You can find RSS feed pages by searching for what you're looking for, - plus "RSS feed", such as "podcast RSS feeds". You may - also contact site owners for links to RSS feeds they may have. An example - RSS feed link is{" "} - - https://www.ign.com/rss/articles/feed - - . -
-
- You may also try submitting links to regular webpages and MonitoRSS will - attempt to find RSS feeds related to the webpage. -
-
-
- - - - When do new articles get delivered? - - - - - With RSS, article delivery is not instant. New articles are checked on a - regular interval (every 20 minutes by default for free). Once new articles - are found, they are automatically delivered. - - - -
- {error && ( - - - You've reached your feed limit. Consider supporting - MonitoRSS's open-source development by upgrading your plan and - increasing your feed limit. - - - - -
- ) : ( - - {error.message} - - ) - } - /> - )} - {canResolveError && ( - onSubmit(getValues())} - /> - )} - - )} -
- - - - {isSubmitting && "Saving..."} - {!isSubmitting && isConfirming && "Add feed with updated link"} - {!isSubmitting && !isConfirming && "Save"} - - - -
-
- - ); -}; diff --git a/services/backend-api/client/src/features/feed/components/CloneUserFeedDialog/index.tsx b/services/backend-api/client/src/features/feed/components/CloneUserFeedDialog/index.tsx index fba3ceb9e..9610d4f5e 100644 --- a/services/backend-api/client/src/features/feed/components/CloneUserFeedDialog/index.tsx +++ b/services/backend-api/client/src/features/feed/components/CloneUserFeedDialog/index.tsx @@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next"; import { FaUpRightFromSquare } from "react-icons/fa6"; import { PrimaryActionButton } from "@/components/PrimaryActionButton"; import { useCreateUserFeedClone } from "../../hooks"; +import { useFeedScope } from "../../contexts/FeedScopeContext"; import { InlineErrorAlert, InlineErrorIncompleteFormAlert, @@ -43,6 +44,7 @@ interface Props { } export const CloneUserFeedDialog = ({ feedId, defaultValues, trigger }: Props) => { + const { workspaceSlug } = useFeedScope(); const { handleSubmit, control, @@ -82,7 +84,12 @@ export const CloneUserFeedDialog = ({ feedId, defaultValues, trigger }: Props) = description: ( + ); + if (isAtLimit) { return ( @@ -39,9 +51,11 @@ export const FeedLimitBar = ({ showOnlyWhenConstrained = false }: FeedLimitBarPr Feed limit reached ({currentCount}/{maxCount}) - + {!isWorkspaceScope && ( + + )} ); } @@ -56,9 +70,7 @@ export const FeedLimitBar = ({ showOnlyWhenConstrained = false }: FeedLimitBarPr Feed Limit: {currentCount}/{maxCount} · {remaining} remaining - + {increaseLimitsButton} ); } @@ -68,9 +80,7 @@ export const FeedLimitBar = ({ showOnlyWhenConstrained = false }: FeedLimitBarPr Feed Limit: {currentCount}/{maxCount} - + {increaseLimitsButton} ); }; diff --git a/services/backend-api/client/src/features/feed/components/UserFeedDetail/index.tsx b/services/backend-api/client/src/features/feed/components/UserFeedDetail/index.tsx index cb75b8566..2febf2f50 100644 --- a/services/backend-api/client/src/features/feed/components/UserFeedDetail/index.tsx +++ b/services/backend-api/client/src/features/feed/components/UserFeedDetail/index.tsx @@ -65,6 +65,7 @@ import { useUpdateUserFeedManagementInviteStatus, } from "../../hooks"; import { useUserFeedContext } from "../../contexts"; +import { useFeedScope } from "../../contexts/FeedScopeContext"; import { UpdateUserFeedInput } from "../../api"; import { UserFeedDisabledCode } from "../../types"; import { CloneUserFeedDialog } from "../CloneUserFeedDialog"; @@ -105,6 +106,9 @@ const tabValues = ["connections", "comparisons", "external-properties", "setting export const UserFeedDetail: React.FC = () => { const { feedId } = useParams(); + // Keep navigation in the current (workspace) scope when rendered under a workspace route. + const { workspaceSlug } = useFeedScope(); + const scope = workspaceSlug ? { workspaceSlug } : undefined; const { open: editIsOpen, onClose: editOnClose, onOpen: editOnOpen } = useDisclosure(); const { open: copySettingsIsOpen, @@ -289,7 +293,7 @@ export const UserFeedDetail: React.FC = () => { - Feeds + Feeds @@ -298,6 +302,7 @@ export const UserFeedDetail: React.FC = () => { {feed?.title} diff --git a/services/backend-api/client/src/features/feed/components/UserFeedHealthAlert/index.tsx b/services/backend-api/client/src/features/feed/components/UserFeedHealthAlert/index.tsx index 9216736a4..c8eb6730a 100644 --- a/services/backend-api/client/src/features/feed/components/UserFeedHealthAlert/index.tsx +++ b/services/backend-api/client/src/features/feed/components/UserFeedHealthAlert/index.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { UserFeedDisabledCode, UserFeedRequestStatus } from "../../types"; import { useUserFeedContext } from "../../contexts/UserFeedContext"; +import { useFeedScope } from "../../contexts/FeedScopeContext"; import { getErrorMessageForArticleRequestStatus } from "../../utils"; import { useCreateUserFeedManualRequest, useUserFeedRequestsWithPagination } from "../../hooks"; import ApiAdapterError from "../../../../utils/ApiAdapterError"; @@ -19,6 +20,8 @@ const RESOLVABLE_STATUS_CODES = [429, 403, 401]; export const UserFeedHealthAlert = () => { const { t } = useTranslation(); const { userFeed } = useUserFeedContext(); + const { workspaceSlug } = useFeedScope(); + const scope = workspaceSlug ? { workspaceSlug } : undefined; const { data, status } = useUserFeedRequestsWithPagination({ feedId: userFeed.id, data: {}, @@ -101,6 +104,7 @@ export const UserFeedHealthAlert = () => { navigate( pages.userFeed(userFeed.id, { tab: UserFeedTabSearchParam.Logs, + scope, }), ) } diff --git a/services/backend-api/client/src/features/feed/components/UserFeedLogs/DeliveryHistory/index.tsx b/services/backend-api/client/src/features/feed/components/UserFeedLogs/DeliveryHistory/index.tsx index 05f6dd620..8b9a5f04d 100644 --- a/services/backend-api/client/src/features/feed/components/UserFeedLogs/DeliveryHistory/index.tsx +++ b/services/backend-api/client/src/features/feed/components/UserFeedLogs/DeliveryHistory/index.tsx @@ -27,6 +27,7 @@ import { InlineErrorAlert } from "../../../../../components"; import { pages } from "../../../../../constants"; import { FeedConnectionType } from "../../../../../types"; import { useUserFeedContext } from "../../../contexts/UserFeedContext"; +import { useFeedScope } from "../../../contexts/FeedScopeContext"; import { DialogRoot, DialogContent, @@ -97,6 +98,7 @@ const createStatusLabel = ({ status }: { status: UserFeedDeliveryLogStatus }) => export const DeliveryHistory = () => { const [detailsData, setDetailsData] = useState(""); const { articleFormatOptions, userFeed } = useUserFeedContext(); + const { workspaceSlug } = useFeedScope(); const { data, status, error, skip, nextPage, prevPage, fetchStatus, limit } = useUserFeedDeliveryLogsWithPagination({ feedId: userFeed.id, @@ -245,6 +247,7 @@ export const DeliveryHistory = () => { feedId: userFeed.id, connectionType: connection?.key as FeedConnectionType, connectionId: item.mediumId, + scope: workspaceSlug ? { workspaceSlug } : undefined, })} > {connection?.name || item.mediumId} diff --git a/services/backend-api/client/src/features/feed/components/UserFeedLogs/DeliveryPreview/ArticleDeliveryDetails.tsx b/services/backend-api/client/src/features/feed/components/UserFeedLogs/DeliveryPreview/ArticleDeliveryDetails.tsx index 359599fdd..ca4f76ead 100644 --- a/services/backend-api/client/src/features/feed/components/UserFeedLogs/DeliveryPreview/ArticleDeliveryDetails.tsx +++ b/services/backend-api/client/src/features/feed/components/UserFeedLogs/DeliveryPreview/ArticleDeliveryDetails.tsx @@ -27,6 +27,7 @@ import { MediumDeliveryResult, } from "../../../types/DeliveryPreview"; import { useUserFeedContext } from "../../../contexts/UserFeedContext"; +import { useFeedScope } from "../../../contexts/FeedScopeContext"; import { pages } from "../../../../../constants"; import { FeedConnectionType } from "../../../../../types"; import { DeliveryChecksModal } from "./DeliveryChecksModal"; @@ -132,6 +133,7 @@ interface ConnectionResultRowProps { const ConnectionResultRow = ({ mediumResult }: ConnectionResultRowProps) => { const { userFeed } = useUserFeedContext(); + const { workspaceSlug } = useFeedScope(); const connection = userFeed.connections.find((c) => c.id === mediumResult.mediumId); const connectionName = connection?.name || mediumResult.mediumId; @@ -151,6 +153,7 @@ const ConnectionResultRow = ({ mediumResult }: ConnectionResultRowProps) => { feedId: userFeed.id, connectionType: connection.key as FeedConnectionType, connectionId: mediumResult.mediumId, + scope: workspaceSlug ? { workspaceSlug } : undefined, })} target="_blank" rel="noopener noreferrer" diff --git a/services/backend-api/client/src/features/feed/components/UserFeedLogs/DeliveryPreview/index.tsx b/services/backend-api/client/src/features/feed/components/UserFeedLogs/DeliveryPreview/index.tsx index 1559adf94..56a46f855 100644 --- a/services/backend-api/client/src/features/feed/components/UserFeedLogs/DeliveryPreview/index.tsx +++ b/services/backend-api/client/src/features/feed/components/UserFeedLogs/DeliveryPreview/index.tsx @@ -6,6 +6,7 @@ import relativeTime from "dayjs/plugin/relativeTime"; import { PrimaryActionButton } from "@/components/PrimaryActionButton"; import { SafeLoadingButton } from "@/components/SafeLoadingButton"; import { useUserFeedContext } from "../../../contexts/UserFeedContext"; +import { useFeedScope } from "../../../contexts/FeedScopeContext"; import { pages } from "../../../../../constants"; import { UserFeedTabSearchParam } from "../../../../../constants/userFeedTabSearchParam"; import { useDeliveryPreviewWithPagination } from "../../../hooks/useDeliveryPreviewWithPagination"; @@ -254,6 +255,7 @@ export const DeliveryPreviewPresentational = ({ export const DeliveryPreview = () => { const { userFeed } = useUserFeedContext(); + const { workspaceSlug } = useFeedScope(); const { results, status, @@ -303,7 +305,10 @@ export const DeliveryPreview = () => { nextRetryAtIso={nextRetryAtIso} nextRetryReason={nextRetryReason} cacheDurationMs={latestFreshnessLifetimeMs} - addConnectionUrl={pages.userFeed(userFeed.id, { tab: UserFeedTabSearchParam.Connections })} + addConnectionUrl={pages.userFeed(userFeed.id, { + tab: UserFeedTabSearchParam.Connections, + scope: workspaceSlug ? { workspaceSlug } : undefined, + })} lastCheckedFormatted={formatLastChecked()} onRefresh={refresh} onLoadMore={loadMore} diff --git a/services/backend-api/client/src/features/feed/components/UserFeedsTable/columns.tsx b/services/backend-api/client/src/features/feed/components/UserFeedsTable/columns.tsx index 176dee7e2..95ca3723f 100644 --- a/services/backend-api/client/src/features/feed/components/UserFeedsTable/columns.tsx +++ b/services/backend-api/client/src/features/feed/components/UserFeedsTable/columns.tsx @@ -8,6 +8,7 @@ import { RowData } from "./types"; import { UserFeedComputedStatus } from "../../types"; import { UserFeedStatusTag } from "./UserFeedStatusTag"; import { DATE_FORMAT, pages } from "../../../../constants"; +import type { RouteScope } from "../../../../constants"; import { formatRefreshRateSeconds } from "../../../../utils/formatRefreshRateSeconds"; const columnHelper = createColumnHelper(); @@ -15,7 +16,11 @@ const columnHelper = createColumnHelper(); interface ColumnConfig { id: string; header: string; - cell: (info: CellContext, search: string) => React.ReactNode; + cell: ( + info: CellContext, + search: string, + scope?: RouteScope, + ) => React.ReactNode; accessor?: keyof RowData | ((row: RowData) => unknown); sortable?: boolean; } @@ -32,21 +37,21 @@ const columnConfigs: ColumnConfig[] = [ id: "title", header: "Title", accessor: "title", - cell: (info, search) => { + cell: (info, search, scope) => { const value = info.getValue() as string; const feedId = info.row.original.id; if (!search) { return ( - {value} + {value} ); } return ( - + {value} @@ -199,7 +204,7 @@ function createSelectColumn(): ColumnDef { }); } -function createConfigureColumn(): ColumnDef { +function createConfigureColumn(scope?: RouteScope): ColumnDef { return columnHelper.display({ id: "configure", header: () => null, @@ -211,7 +216,7 @@ function createConfigureColumn(): ColumnDef { size="sm" aria-label={`Configure ${row.original.title}`} > - + Configure @@ -220,23 +225,23 @@ function createConfigureColumn(): ColumnDef { }); } -export function createTableColumns(search: string): ColumnDef[] { +export function createTableColumns(search: string, scope?: RouteScope): ColumnDef[] { const selectColumn = createSelectColumn(); - const configureColumn = createConfigureColumn(); + const configureColumn = createConfigureColumn(scope); const dataColumns = columnConfigs.map((config) => { if (typeof config.accessor === "function") { return columnHelper.accessor(config.accessor, { id: config.id, header: () => {config.header}, - cell: (info) => config.cell(info as CellContext, search), + cell: (info) => config.cell(info as CellContext, search, scope), }); } return columnHelper.accessor(config.accessor as keyof RowData, { id: config.id, header: () => {config.header}, - cell: (info) => config.cell(info as CellContext, search), + cell: (info) => config.cell(info as CellContext, search, scope), }); }) as ColumnDef[]; diff --git a/services/backend-api/client/src/features/feed/components/UserFeedsTable/index.tsx b/services/backend-api/client/src/features/feed/components/UserFeedsTable/index.tsx index 8dace55ef..2d7e957e9 100644 --- a/services/backend-api/client/src/features/feed/components/UserFeedsTable/index.tsx +++ b/services/backend-api/client/src/features/feed/components/UserFeedsTable/index.tsx @@ -25,6 +25,7 @@ import { Panel } from "@/components/Panel"; import { UserFeedComputedStatus } from "../../types"; import { UserFeedStatusFilterContext } from "../../contexts/UserFeedStatusFilterContext"; import { useMultiSelectUserFeedContext } from "../../contexts/MultiSelectUserFeedContext"; +import { useFeedScope } from "../../contexts/FeedScopeContext"; import { useTablePreferences, useTableSearch, useFeedTableData } from "./hooks"; import { ActiveFilterChips, @@ -43,6 +44,7 @@ export const UserFeedsTable: React.FC = () => { const { statusFilters, setStatusFilters } = useContext(UserFeedStatusFilterContext); const { selectedFeeds, setSelectedFeeds } = useMultiSelectUserFeedContext(); + const { workspaceSlug } = useFeedScope(); // Preferences (sorting, column visibility, column order) const { @@ -97,8 +99,11 @@ export const UserFeedsTable: React.FC = () => { [setSearchParams], ); - // Columns with search highlighting - const columns = useMemo(() => createTableColumns(search), [search]); + // Columns with search highlighting; links stay in the current (workspace) scope. + const columns = useMemo( + () => createTableColumns(search, workspaceSlug ? { workspaceSlug } : undefined), + [search, workspaceSlug], + ); const searchInputRef = useRef(null); diff --git a/services/backend-api/client/src/features/feed/components/index.ts b/services/backend-api/client/src/features/feed/components/index.ts index a7ef7d13b..51e43a808 100644 --- a/services/backend-api/client/src/features/feed/components/index.ts +++ b/services/backend-api/client/src/features/feed/components/index.ts @@ -1,6 +1,5 @@ export * from "./RefreshUserFeedButton"; export * from "./EditUserFeedDialog"; -export * from "./AddUserFeedDialog"; export * from "./UserFeedsTable"; export * from "./ArticleSelectDialog"; export * from "./UserFeedDisabledAlert"; diff --git a/services/backend-api/client/src/features/feed/contexts/FeedScopeContext.tsx b/services/backend-api/client/src/features/feed/contexts/FeedScopeContext.tsx new file mode 100644 index 000000000..60fe5cfbf --- /dev/null +++ b/services/backend-api/client/src/features/feed/contexts/FeedScopeContext.tsx @@ -0,0 +1,27 @@ +import { createContext, useContext } from "react"; + +/** + * The scope the feeds UI operates in. + * + * The default (empty) value is the personal scope. When a workspace-scoped page + * provides a value, every feed query, mutation, and link inside it becomes + * workspace-scoped — a single chokepoint that lets the personal feeds UI be reused + * verbatim in workspace scope without threading `workspaceId` through every component. + * + * Lives in the `feed` feature (not `workspaces`) so feed hooks can consume it + * without importing `workspaces`, which would create a circular dependency. + */ +export interface FeedScope { + /** The current workspace's id; undefined in personal scope. */ + workspaceId?: string; + /** The current workspace's slug, for building workspace-scoped route links. */ + workspaceSlug?: string; + /** The current workspace's feed limit, for the feed-limit bar. */ + maxFeeds?: number; +} + +const FeedScopeContext = createContext({}); + +export const FeedScopeProvider = FeedScopeContext.Provider; + +export const useFeedScope = () => useContext(FeedScopeContext); diff --git a/services/backend-api/client/src/features/feed/contexts/index.ts b/services/backend-api/client/src/features/feed/contexts/index.ts index 37af7035d..03a3d15cb 100644 --- a/services/backend-api/client/src/features/feed/contexts/index.ts +++ b/services/backend-api/client/src/features/feed/contexts/index.ts @@ -1,3 +1,4 @@ +export * from "./FeedScopeContext"; export * from "./SourceFeedContext"; export * from "./MultiSelectUserFeedContext"; export * from "./UserFeedStatusFilterContext"; diff --git a/services/backend-api/client/src/features/feed/hooks/useCreateUserFeed.tsx b/services/backend-api/client/src/features/feed/hooks/useCreateUserFeed.tsx index 8d5988070..7d71c39ce 100644 --- a/services/backend-api/client/src/features/feed/hooks/useCreateUserFeed.tsx +++ b/services/backend-api/client/src/features/feed/hooks/useCreateUserFeed.tsx @@ -1,22 +1,31 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import ApiAdapterError from "@/utils/ApiAdapterError"; import { createUserFeed, CreateUserFeedInput, CreateUserFeedOutput } from "../api"; +import { useFeedScope } from "../contexts/FeedScopeContext"; export const useCreateUserFeed = () => { const queryClient = useQueryClient(); + const { workspaceId } = useFeedScope(); const { mutateAsync, status, error, reset } = useMutation< CreateUserFeedOutput, ApiAdapterError, CreateUserFeedInput - >((details) => createUserFeed(details), { - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ["user-feeds"], - exact: false, - }); + >( + // In workspace scope, new feeds are created under the workspace. + (input) => + createUserFeed({ + details: { ...input.details, workspaceId: input.details.workspaceId ?? workspaceId }, + }), + { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["user-feeds"], + exact: false, + }); + }, }, - }); + ); return { mutateAsync, diff --git a/services/backend-api/client/src/features/feed/hooks/useUserFeeds.tsx b/services/backend-api/client/src/features/feed/hooks/useUserFeeds.tsx index ec65ecbf8..74b0393c6 100644 --- a/services/backend-api/client/src/features/feed/hooks/useUserFeeds.tsx +++ b/services/backend-api/client/src/features/feed/hooks/useUserFeeds.tsx @@ -4,6 +4,7 @@ import { pick } from "lodash"; import { getUserFeeds, GetUserFeedsInput, GetUserFeedsOutput } from "../api"; import ApiAdapterError from "../../../utils/ApiAdapterError"; import { UserFeed } from "../types"; +import { useFeedScope } from "../contexts/FeedScopeContext"; export const useUserFeeds = ( input: GetUserFeedsInput, @@ -14,11 +15,16 @@ export const useUserFeeds = ( const [search, setSearch] = useState(""); const [hasErrored, setHasErrored] = useState(false); const queryClient = useQueryClient(); + const { workspaceId } = useFeedScope(); + + // In workspace scope, list/count this workspace's feeds; in personal scope, the user's. + // Merged into the query key so the two scopes cache separately. + const scopedInput: GetUserFeedsInput = { ...input, workspaceId: input.workspaceId ?? workspaceId }; const queryKey = [ "user-feeds", { - input, + input: scopedInput, }, ]; @@ -28,7 +34,7 @@ export const useUserFeeds = ( >( queryKey, async () => { - const result = await getUserFeeds(input); + const result = await getUserFeeds(scopedInput); return result; }, diff --git a/services/backend-api/client/src/features/feed/hooks/useUserFeedsInfinite.tsx b/services/backend-api/client/src/features/feed/hooks/useUserFeedsInfinite.tsx index 21bf0548a..7de4e9e96 100644 --- a/services/backend-api/client/src/features/feed/hooks/useUserFeedsInfinite.tsx +++ b/services/backend-api/client/src/features/feed/hooks/useUserFeedsInfinite.tsx @@ -2,6 +2,7 @@ import { useInfiniteQuery } from "@tanstack/react-query"; import { useState } from "react"; import { getUserFeeds, GetUserFeedsInput, GetUserFeedsOutput } from "../api"; import ApiAdapterError from "../../../utils/ApiAdapterError"; +import { useFeedScope } from "../contexts/FeedScopeContext"; export const useUserFeedsInfinite = ( input: Omit, @@ -11,12 +12,15 @@ export const useUserFeedsInfinite = ( ) => { const [search, setSearch] = useState(""); const useLimit = input.limit || 10; + const { workspaceId } = useFeedScope(); + const scopedWorkspaceId = input.workspaceId ?? workspaceId; const queryKey = [ "user-feeds", { input: { ...input, + workspaceId: scopedWorkspaceId, infinite: true, limit: useLimit, search, @@ -39,6 +43,7 @@ export const useUserFeedsInfinite = ( async ({ pageParam: newOffset }) => { const result = await getUserFeeds({ ...input, + workspaceId: scopedWorkspaceId, offset: newOffset, search, }); diff --git a/services/backend-api/client/src/features/feed/types/UserFeed.ts b/services/backend-api/client/src/features/feed/types/UserFeed.ts index 16f9f624c..12f3208ce 100644 --- a/services/backend-api/client/src/features/feed/types/UserFeed.ts +++ b/services/backend-api/client/src/features/feed/types/UserFeed.ts @@ -10,11 +10,15 @@ export const UserFeedSchema = object({ url: string().required(), inputUrl: string(), /** - * Optional team ownership. Null/undefined = personal feed. - * Forward-compatibility shell per ADR-005 (team scoping). Backend may ignore - * today; becomes the source of truth for team ownership when teams ship. + * Optional workspace ownership. Null/undefined = personal feed; set = the workspace + * that owns the feed. */ - teamId: string().nullable().optional(), + workspaceId: string().nullable().optional(), + /** + * Authoritative flag for workspace-owned feeds. Per-user feed management invites + * are disabled for these; access is managed through workspace membership instead. + */ + isWorkspaceFeed: bool(), sharedAccessDetails: object({ inviteId: string().required(), }).optional(), diff --git a/services/backend-api/client/src/features/feedConnections/discordChannel/connection/components/ConnectionCard/index.tsx b/services/backend-api/client/src/features/feedConnections/discordChannel/connection/components/ConnectionCard/index.tsx index 3e3eedee7..c1d381644 100644 --- a/services/backend-api/client/src/features/feedConnections/discordChannel/connection/components/ConnectionCard/index.tsx +++ b/services/backend-api/client/src/features/feedConnections/discordChannel/connection/components/ConnectionCard/index.tsx @@ -1,7 +1,7 @@ import { Button, Card, Badge, Box, HStack, Stack, Text } from "@chakra-ui/react"; import { FaChevronRight } from "react-icons/fa6"; import { Link as RouterLink } from "react-router-dom"; -import { UserFeed } from "@/features/feed"; +import { UserFeed, useFeedScope } from "@/features/feed"; import { FeedConnectionDisabledCode, FeedConnectionType, @@ -24,6 +24,7 @@ const DISABLED_CODES_FOR_ERROR = [ ]; export const ConnectionCard = ({ feedId, connection }: Props) => { + const { workspaceSlug } = useFeedScope(); const isError = DISABLED_CODES_FOR_ERROR.includes( connection.disabledCode as FeedConnectionDisabledCode, ); @@ -94,6 +95,7 @@ export const ConnectionCard = ({ feedId, connection }: Props) => { feedId: feedId as string, connectionType: connection.key, connectionId: connection.id, + scope: workspaceSlug ? { workspaceSlug } : undefined, })} > Manage diff --git a/services/backend-api/client/src/features/feedConnections/discordChannel/connection/components/ConnectionSettings/index.tsx b/services/backend-api/client/src/features/feedConnections/discordChannel/connection/components/ConnectionSettings/index.tsx index 153a412dc..791d9d2c1 100644 --- a/services/backend-api/client/src/features/feedConnections/discordChannel/connection/components/ConnectionSettings/index.tsx +++ b/services/backend-api/client/src/features/feedConnections/discordChannel/connection/components/ConnectionSettings/index.tsx @@ -28,6 +28,7 @@ import { useUserFeedConnectionContext, UserFeedProvider, useUserFeedContext, + useFeedScope, } from "@/features/feed"; import { LogicalFilterExpression, @@ -96,6 +97,8 @@ export const ConnectionDiscordChannelSettings: React.FC = () => { const ConnectionDiscordChannelSettingsInner: React.FC = () => { const { feedId, connectionId } = useParams(); const navigate = useNavigate(); + const { workspaceSlug } = useFeedScope(); + const scope = workspaceSlug ? { workspaceSlug } : undefined; const { search: urlSearch } = useLocation(); const actionsButtonRef = useRef(null); const { userFeed: feed } = useUserFeedContext(); @@ -177,13 +180,13 @@ const ConnectionDiscordChannelSettingsInner: React.FC = () => { - Feeds + Feeds - + {feed?.title} @@ -194,6 +197,7 @@ const ConnectionDiscordChannelSettingsInner: React.FC = () => { Connections diff --git a/services/backend-api/client/src/features/feedConnections/discordChannel/connection/components/DeleteConnectionButton/index.tsx b/services/backend-api/client/src/features/feedConnections/discordChannel/connection/components/DeleteConnectionButton/index.tsx index 1849fe5fa..2a80b1d82 100644 --- a/services/backend-api/client/src/features/feedConnections/discordChannel/connection/components/DeleteConnectionButton/index.tsx +++ b/services/backend-api/client/src/features/feedConnections/discordChannel/connection/components/DeleteConnectionButton/index.tsx @@ -8,6 +8,7 @@ import { FeedConnectionType } from "@/types"; import { useConnection, useDeleteConnection } from "../../hooks"; import { pages } from "@/constants"; import { usePageAlertContext } from "@/contexts/PageAlertContext"; +import { useFeedScope } from "@/features/feed"; interface Props { feedId: string; @@ -20,6 +21,7 @@ export const DeleteConnectionButton = ({ feedId, connectionId, type, trigger }: const { t } = useTranslation(); const { mutateAsync, status, error, reset } = useDeleteConnection(type); const navigate = useNavigate(); + const { workspaceSlug } = useFeedScope(); const { createSuccessAlert } = usePageAlertContext(); const { connection } = useConnection({ feedId, @@ -31,7 +33,7 @@ export const DeleteConnectionButton = ({ feedId, connectionId, type, trigger }: feedId, connectionId, }); - navigate(pages.userFeed(feedId)); + navigate(pages.userFeed(feedId, { scope: workspaceSlug ? { workspaceSlug } : undefined })); createSuccessAlert({ title: `Successfully deleted feed connection: ${connection?.name}`, }); diff --git a/services/backend-api/client/src/features/feedConnections/discordChannel/connection/components/ExternalPropertiesTabSection/ExternalPropertyPreview.tsx b/services/backend-api/client/src/features/feedConnections/discordChannel/connection/components/ExternalPropertiesTabSection/ExternalPropertyPreview.tsx index 59476f2ec..2e5df9183 100644 --- a/services/backend-api/client/src/features/feedConnections/discordChannel/connection/components/ExternalPropertiesTabSection/ExternalPropertyPreview.tsx +++ b/services/backend-api/client/src/features/feedConnections/discordChannel/connection/components/ExternalPropertiesTabSection/ExternalPropertyPreview.tsx @@ -19,6 +19,7 @@ import { Field } from "@/components/ui/field"; import { NativeSelectRoot, NativeSelectField } from "@/components/ui/native-select"; import { useUserFeedContext, + useFeedScope, UserFeedConnectionContext, UserFeedConnectionProvider, useUserFeedConnectionContext, @@ -234,6 +235,8 @@ const ArticlesSection = ({ externalProperties, articleId }: Props & { articleId? export const ExternalPropertyPreview = ({ externalProperties: inputExternalProperties }: Props) => { const { userFeed, articleFormatOptions } = useUserFeedContext(); + const { workspaceSlug } = useFeedScope(); + const scope = workspaceSlug ? { workspaceSlug } : undefined; const [selectedConnectionId, setSelectedConnectionId] = useState( userFeed.connections[0]?.id, ); @@ -290,10 +293,12 @@ export const ExternalPropertyPreview = ({ externalProperties: inputExternalPrope The preview is disabled because there are no connections within this feed to preview with. To create connections, visit the{" "} + {/* eslint-disable-next-line jsx-a11y/anchor-is-valid -- href comes from the RouterLink child via asChild */} Connections @@ -347,6 +352,7 @@ export const ExternalPropertyPreview = ({ externalProperties: inputExternalPrope feedId: userFeed.id, connectionId: connectionContext.connection.id, connectionType: connectionContext.connection.key, + scope, })} target="_blank" rel="noopener noreferrer" diff --git a/services/backend-api/client/src/features/feedConnections/discordChannel/connection/components/MessageTabSection/index.tsx b/services/backend-api/client/src/features/feedConnections/discordChannel/connection/components/MessageTabSection/index.tsx index 0f278c025..799551d93 100644 --- a/services/backend-api/client/src/features/feedConnections/discordChannel/connection/components/MessageTabSection/index.tsx +++ b/services/backend-api/client/src/features/feedConnections/discordChannel/connection/components/MessageTabSection/index.tsx @@ -28,6 +28,7 @@ import { useUserFeedArticles, ArticleSelectDialog, useUserFeedConnectionContext, + useFeedScope, } from "@/features/feed"; import { ArticlePlaceholderTable } from "../ArticlePlaceholderTable"; import { DiscordMessageForm, SaveExtra } from "../DiscordMessageForm"; @@ -64,6 +65,8 @@ interface Props { export const MessageTabSection = ({ onMessageUpdated, guildId }: Props) => { const { userFeed, connection, articleFormatOptions } = useUserFeedConnectionContext(); + const { workspaceSlug } = useFeedScope(); + const scope = workspaceSlug ? { workspaceSlug } : undefined; const [isOpen, setIsOpen] = useState(false); const onOpen = () => setIsOpen(true); const onClose = () => setIsOpen(false); @@ -240,6 +243,7 @@ export const MessageTabSection = ({ onMessageUpdated, guildId }: Props) => { feedId: userFeed.id, connectionId: connection.id, connectionType: connection.key, + scope, })} > {hasComponentsV2 ? "Open Message Builder" : "Check it out"} @@ -367,6 +371,7 @@ export const MessageTabSection = ({ onMessageUpdated, guildId }: Props) => { feedId: userFeed.id, connectionId: connection.id, connectionType: connection.key, + scope, }, { tab: UserFeedConnectionTabSearchParam.CustomPlaceholders, @@ -381,6 +386,7 @@ export const MessageTabSection = ({ onMessageUpdated, guildId }: Props) => { External Properties diff --git a/services/backend-api/client/src/features/feedConnections/discordChannel/connection/components/UserFeedMiscSettingsTabSection/index.tsx b/services/backend-api/client/src/features/feedConnections/discordChannel/connection/components/UserFeedMiscSettingsTabSection/index.tsx index 348373df2..e637e3b18 100644 --- a/services/backend-api/client/src/features/feedConnections/discordChannel/connection/components/UserFeedMiscSettingsTabSection/index.tsx +++ b/services/backend-api/client/src/features/feedConnections/discordChannel/connection/components/UserFeedMiscSettingsTabSection/index.tsx @@ -34,6 +34,7 @@ import { useDeleteUserFeedManagementInvite, useUpdateUserFeed, useUserFeed, + useFeedScope, } from "@/features/feed"; import { DiscordUsername, useDiscordUserMe } from "@/features/discordUser"; import { pages, UserFeedManagerInviteType, UserFeedManagerStatus } from "@/constants"; @@ -101,6 +102,7 @@ type FormValues = InferType; export const UserFeedMiscSettingsTabSection = ({ feedId }: Props) => { const { t } = useTranslation(); + const { workspaceSlug } = useFeedScope(); const { status: feedStatus, feed, @@ -324,216 +326,222 @@ export const UserFeedMiscSettingsTabSection = ({ feedId }: Props) => { Miscellaneous Feed Settings - - - - Feed Management Invites - - - Share this feed with users who you would like to also manage this feed. After they - accept it, this shared feed will count towards their feed limit. To revoke access, - delete their invite. - - {!feed?.connections.length && ( - - - - - - )} - - - - - {feed && feed.sharedAccessDetails && ( - - Only the feed owner can manage feed management invites. - - )} - {feed && !feed.sharedAccessDetails && ( - - -
+ )} + + + + + {feed && feed.sharedAccessDetails && ( + + Only the feed owner can manage feed management invites. + + )} + {feed && !feed.sharedAccessDetails && ( + + + - - )} - {!!feed?.connections.length && ( - - - - - - setIsComanageDialogOpen(true)}> - Co-manage feed - - setIsTransferDialogOpen(true)} - > - Transfer ownership - - - - )} - - - This user will have full ownership of this feed, and you will lose access to - it after they accept the invite. They must accept the invite by logging in. - - } - title="Invite User to Transfer Ownership" - okButtonText="Invite User to Transfer Ownership" - onAdded={({ id }) => - onAddUser({ - id, - type: UserFeedManagerInviteType.Transfer, - connections: [], - }) - } - onClosed={resetCreateInvite} - error={createInviteError?.message} - /> + + + ); + })} + + + + + )} + {!!feed?.connections.length && ( + + + + + + setIsComanageDialogOpen(true)}> + Co-manage feed + + setIsTransferDialogOpen(true)} + > + Transfer ownership + + + + )} + + + This user will have full ownership of this feed, and you will lose access to + it after they accept the invite. They must accept the invite by logging in. + + } + title="Invite User to Transfer Ownership" + okButtonText="Invite User to Transfer Ownership" + onAdded={({ id }) => + onAddUser({ + id, + type: UserFeedManagerInviteType.Transfer, + connections: [], + }) + } + onClosed={resetCreateInvite} + error={createInviteError?.message} + /> + - + )} diff --git a/services/backend-api/client/src/features/feedConnections/discordChannel/connection/hooks/useGetUserFeedArticlesError.tsx b/services/backend-api/client/src/features/feedConnections/discordChannel/connection/hooks/useGetUserFeedArticlesError.tsx index 053bf17e4..3a332df0f 100644 --- a/services/backend-api/client/src/features/feedConnections/discordChannel/connection/hooks/useGetUserFeedArticlesError.tsx +++ b/services/backend-api/client/src/features/feedConnections/discordChannel/connection/hooks/useGetUserFeedArticlesError.tsx @@ -6,7 +6,7 @@ import { getErrorMessageForArticleRequestStatus, } from "@/features/feed"; import ApiAdapterError from "@/utils/ApiAdapterError"; -import { ApiErrorCode } from "@/utils/getStandardErrorCodeMessage copy"; +import { ApiErrorCode } from "@/utils/getStandardErrorCodeMessage"; interface Props { getUserFeedArticlesOutput?: GetUserFeedArticlesOutput; diff --git a/services/backend-api/client/src/features/feedConnections/discordChannel/messageBuilder/InsertPlaceholderDialog.tsx b/services/backend-api/client/src/features/feedConnections/discordChannel/messageBuilder/InsertPlaceholderDialog.tsx index 0d16f1f24..e00decb94 100644 --- a/services/backend-api/client/src/features/feedConnections/discordChannel/messageBuilder/InsertPlaceholderDialog.tsx +++ b/services/backend-api/client/src/features/feedConnections/discordChannel/messageBuilder/InsertPlaceholderDialog.tsx @@ -23,7 +23,7 @@ import { DialogCloseTrigger, } from "@/components/ui/dialog"; import { useMessageBuilderContext } from "./MessageBuilderContext"; -import { useUserFeedConnectionContext } from "@/features/feed"; +import { useUserFeedConnectionContext, useFeedScope } from "@/features/feed"; import { pages } from "@/constants"; import { UserFeedConnectionTabSearchParam } from "@/constants/userFeedConnectionTabSearchParam"; @@ -122,6 +122,7 @@ export const InsertPlaceholderDialog: React.FC = ({ }) => { const { error, isLoading, currentArticle } = useMessageBuilderContext(); const { connection, userFeed } = useUserFeedConnectionContext(); + const { workspaceSlug } = useFeedScope(); const [searchTerm, setSearchTerm] = React.useState(""); const searchInputRef = React.useRef(null); @@ -283,6 +284,7 @@ export const InsertPlaceholderDialog: React.FC = ({ feedId: userFeed.id, connectionId: connection.id, connectionType: connection.key, + scope: workspaceSlug ? { workspaceSlug } : undefined, }, { tab: UserFeedConnectionTabSearchParam.CustomPlaceholders, diff --git a/services/backend-api/client/src/features/feedConnections/discordChannel/messageBuilder/MessageBuilderPage.tsx b/services/backend-api/client/src/features/feedConnections/discordChannel/messageBuilder/MessageBuilderPage.tsx index ae128b2b4..9084d6c8e 100644 --- a/services/backend-api/client/src/features/feedConnections/discordChannel/messageBuilder/MessageBuilderPage.tsx +++ b/services/backend-api/client/src/features/feedConnections/discordChannel/messageBuilder/MessageBuilderPage.tsx @@ -54,6 +54,7 @@ import { UserFeedConnectionProvider, useUserFeedConnectionContext, useUserFeedArticles, + useFeedScope, } from "@/features/feed"; import { LogoutButton } from "@/features/auth"; import { useDiscordBot, useDiscordUserMe } from "@/features/discordUser"; @@ -213,6 +214,8 @@ const MessageBuilderContent: React.FC = () => { const mobileTreeRef = useRef(null); const [scrollToComponentId, setScrollToComponentId] = useState(null); const { feedId, connectionId } = useParams(); + const { workspaceSlug } = useFeedScope(); + const scope = workspaceSlug ? { workspaceSlug } : undefined; const { mutateAsync: updateConnection, status: updateStatus } = useUpdateDiscordChannelConnection(); const { createSuccessAlert, createErrorAlert } = usePageAlertContext(); @@ -523,7 +526,7 @@ const MessageBuilderContent: React.FC = () => { - + Feeds @@ -531,7 +534,7 @@ const MessageBuilderContent: React.FC = () => { - + {userFeed.title} @@ -542,6 +545,7 @@ const MessageBuilderContent: React.FC = () => { @@ -557,6 +561,7 @@ const MessageBuilderContent: React.FC = () => { feedId: userFeed.id, connectionType: FeedConnectionType.DiscordChannel, connectionId: connection.id, + scope, })} color="text.link" > diff --git a/services/backend-api/client/src/features/workspaces/api/acceptWorkspaceInvite.ts b/services/backend-api/client/src/features/workspaces/api/acceptWorkspaceInvite.ts new file mode 100644 index 000000000..5890f0e8c --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/api/acceptWorkspaceInvite.ts @@ -0,0 +1,24 @@ +import { InferType, object, string } from "yup"; +import fetchRest from "@/utils/fetchRest"; + +const AcceptWorkspaceInviteOutputSchema = object({ + result: object({ + workspaceSlug: string().required(), + }).required(), +}).required(); + +export type AcceptWorkspaceInviteOutput = InferType; + +export const acceptWorkspaceInvite = async ( + inviteId: string, +): Promise => { + const res = await fetchRest(`/api/v1/workspace-invites/${inviteId}/accept`, { + requestOptions: { + method: "POST", + body: JSON.stringify({}), + }, + validateSchema: AcceptWorkspaceInviteOutputSchema, + }); + + return res as AcceptWorkspaceInviteOutput; +}; diff --git a/services/backend-api/client/src/features/workspaces/api/confirmEmailVerification.ts b/services/backend-api/client/src/features/workspaces/api/confirmEmailVerification.ts new file mode 100644 index 000000000..b4814e5e4 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/api/confirmEmailVerification.ts @@ -0,0 +1,19 @@ +import fetchRest from "@/utils/fetchRest"; + +export interface ConfirmEmailVerificationInput { + details: { + email: string; + code: string; + }; +} + +export const confirmEmailVerification = async ({ + details, +}: ConfirmEmailVerificationInput): Promise => { + await fetchRest("/api/v1/users/@me/email-verification/confirm", { + requestOptions: { + method: "POST", + body: JSON.stringify(details), + }, + }); +}; diff --git a/services/backend-api/client/src/features/workspaces/api/createWorkspace.ts b/services/backend-api/client/src/features/workspaces/api/createWorkspace.ts new file mode 100644 index 000000000..f48dc829e --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/api/createWorkspace.ts @@ -0,0 +1,28 @@ +import { InferType, object } from "yup"; +import fetchRest from "@/utils/fetchRest"; +import { WorkspaceDetailsSchema } from "../types"; + +export interface CreateWorkspaceInput { + details: { + name: string; + slug: string; + }; +} + +const CreateWorkspaceOutputSchema = object({ + result: WorkspaceDetailsSchema, +}).required(); + +export type CreateWorkspaceOutput = InferType; + +export const createWorkspace = async ({ details }: CreateWorkspaceInput): Promise => { + const res = await fetchRest("/api/v1/workspaces", { + validateSchema: CreateWorkspaceOutputSchema, + requestOptions: { + method: "POST", + body: JSON.stringify(details), + }, + }); + + return res as CreateWorkspaceOutput; +}; diff --git a/services/backend-api/client/src/features/workspaces/api/createWorkspaceInvite.ts b/services/backend-api/client/src/features/workspaces/api/createWorkspaceInvite.ts new file mode 100644 index 000000000..dc3c5d0e5 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/api/createWorkspaceInvite.ts @@ -0,0 +1,29 @@ +import { InferType, object } from "yup"; +import fetchRest from "@/utils/fetchRest"; +import { WorkspaceManagedInviteSchema } from "../types"; + +export interface CreateWorkspaceInviteInput { + workspaceSlug: string; + email: string; +} + +const CreateWorkspaceInviteOutputSchema = object({ + result: WorkspaceManagedInviteSchema, +}).required(); + +export type CreateWorkspaceInviteOutput = InferType; + +export const createWorkspaceInvite = async ({ + workspaceSlug, + email, +}: CreateWorkspaceInviteInput): Promise => { + const res = await fetchRest(`/api/v1/workspaces/${workspaceSlug}/invites`, { + validateSchema: CreateWorkspaceInviteOutputSchema, + requestOptions: { + method: "POST", + body: JSON.stringify({ email }), + }, + }); + + return res as CreateWorkspaceInviteOutput; +}; diff --git a/services/backend-api/client/src/features/workspaces/api/declineWorkspaceInvite.ts b/services/backend-api/client/src/features/workspaces/api/declineWorkspaceInvite.ts new file mode 100644 index 000000000..a3ea64354 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/api/declineWorkspaceInvite.ts @@ -0,0 +1,11 @@ +import fetchRest from "@/utils/fetchRest"; + +export const declineWorkspaceInvite = async (inviteId: string): Promise => { + await fetchRest(`/api/v1/workspace-invites/${inviteId}/decline`, { + requestOptions: { + method: "POST", + body: JSON.stringify({}), + }, + skipJsonParse: true, + }); +}; diff --git a/services/backend-api/client/src/features/workspaces/api/getMyWorkspaceInvites.ts b/services/backend-api/client/src/features/workspaces/api/getMyWorkspaceInvites.ts new file mode 100644 index 000000000..b612ae463 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/api/getMyWorkspaceInvites.ts @@ -0,0 +1,17 @@ +import { array, InferType, object } from "yup"; +import fetchRest from "@/utils/fetchRest"; +import { WorkspaceInviteSchema } from "../types"; + +const GetMyWorkspaceInvitesOutputSchema = object({ + result: array(WorkspaceInviteSchema.required()).required(), +}).required(); + +export type GetMyWorkspaceInvitesOutput = InferType; + +export const getMyWorkspaceInvites = async (): Promise => { + const res = await fetchRest("/api/v1/workspace-invites/@me", { + validateSchema: GetMyWorkspaceInvitesOutputSchema, + }); + + return res as GetMyWorkspaceInvitesOutput; +}; diff --git a/services/backend-api/client/src/features/workspaces/api/getWorkspace.ts b/services/backend-api/client/src/features/workspaces/api/getWorkspace.ts new file mode 100644 index 000000000..8715e67a1 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/api/getWorkspace.ts @@ -0,0 +1,21 @@ +import { InferType, object } from "yup"; +import fetchRest from "@/utils/fetchRest"; +import { WorkspaceSchema } from "../types"; + +export interface GetWorkspaceInput { + workspaceSlug: string; +} + +const GetWorkspaceOutputSchema = object({ + result: WorkspaceSchema, +}).required(); + +export type GetWorkspaceOutput = InferType; + +export const getWorkspace = async ({ workspaceSlug }: GetWorkspaceInput): Promise => { + const res = await fetchRest(`/api/v1/workspaces/${workspaceSlug}`, { + validateSchema: GetWorkspaceOutputSchema, + }); + + return res as GetWorkspaceOutput; +}; diff --git a/services/backend-api/client/src/features/workspaces/api/getWorkspaceInvite.ts b/services/backend-api/client/src/features/workspaces/api/getWorkspaceInvite.ts new file mode 100644 index 000000000..b271d8fef --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/api/getWorkspaceInvite.ts @@ -0,0 +1,17 @@ +import { InferType, object } from "yup"; +import fetchRest from "@/utils/fetchRest"; +import { WorkspaceInviteContextSchema } from "../types"; + +const GetWorkspaceInviteOutputSchema = object({ + result: WorkspaceInviteContextSchema, +}).required(); + +export type GetWorkspaceInviteOutput = InferType; + +export const getWorkspaceInvite = async (inviteId: string): Promise => { + const res = await fetchRest(`/api/v1/workspace-invites/${inviteId}`, { + validateSchema: GetWorkspaceInviteOutputSchema, + }); + + return res as GetWorkspaceInviteOutput; +}; diff --git a/services/backend-api/client/src/features/workspaces/api/getWorkspaceInvites.ts b/services/backend-api/client/src/features/workspaces/api/getWorkspaceInvites.ts new file mode 100644 index 000000000..fc4028448 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/api/getWorkspaceInvites.ts @@ -0,0 +1,19 @@ +import { array, InferType, object } from "yup"; +import fetchRest from "@/utils/fetchRest"; +import { WorkspaceManagedInviteSchema } from "../types"; + +const GetWorkspaceInvitesOutputSchema = object({ + result: array(WorkspaceManagedInviteSchema.required()).required(), +}).required(); + +export type GetWorkspaceInvitesOutput = InferType; + +export const getWorkspaceInvites = async ( + workspaceSlug: string, +): Promise => { + const res = await fetchRest(`/api/v1/workspaces/${workspaceSlug}/invites`, { + validateSchema: GetWorkspaceInvitesOutputSchema, + }); + + return res as GetWorkspaceInvitesOutput; +}; diff --git a/services/backend-api/client/src/features/workspaces/api/getWorkspaceMembers.ts b/services/backend-api/client/src/features/workspaces/api/getWorkspaceMembers.ts new file mode 100644 index 000000000..bdfc8a8c5 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/api/getWorkspaceMembers.ts @@ -0,0 +1,19 @@ +import { array, InferType, object } from "yup"; +import fetchRest from "@/utils/fetchRest"; +import { WorkspaceMemberSchema } from "../types"; + +const GetWorkspaceMembersOutputSchema = object({ + result: array(WorkspaceMemberSchema.required()).required(), +}).required(); + +export type GetWorkspaceMembersOutput = InferType; + +export const getWorkspaceMembers = async ( + workspaceSlug: string, +): Promise => { + const res = await fetchRest(`/api/v1/workspaces/${workspaceSlug}/members`, { + validateSchema: GetWorkspaceMembersOutputSchema, + }); + + return res as GetWorkspaceMembersOutput; +}; diff --git a/services/backend-api/client/src/features/workspaces/api/getWorkspaces.ts b/services/backend-api/client/src/features/workspaces/api/getWorkspaces.ts new file mode 100644 index 000000000..379bfe9c8 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/api/getWorkspaces.ts @@ -0,0 +1,17 @@ +import { array, InferType, object } from "yup"; +import fetchRest from "@/utils/fetchRest"; +import { WorkspaceSchema } from "../types"; + +const GetWorkspacesOutputSchema = object({ + result: array(WorkspaceSchema.required()).required(), +}).required(); + +export type GetWorkspacesOutput = InferType; + +export const getWorkspaces = async (): Promise => { + const res = await fetchRest("/api/v1/workspaces", { + validateSchema: GetWorkspacesOutputSchema, + }); + + return res as GetWorkspacesOutput; +}; diff --git a/services/backend-api/client/src/features/workspaces/api/index.ts b/services/backend-api/client/src/features/workspaces/api/index.ts new file mode 100644 index 000000000..c706c22f9 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/api/index.ts @@ -0,0 +1,18 @@ +export * from "./getWorkspaces"; +export * from "./getWorkspace"; +export * from "./createWorkspace"; +export * from "./updateWorkspace"; +export * from "./sendEmailVerification"; +export * from "./sendInviteVerification"; +export * from "./confirmEmailVerification"; +export * from "./getWorkspaceInvite"; +export * from "./getMyWorkspaceInvites"; +export * from "./acceptWorkspaceInvite"; +export * from "./declineWorkspaceInvite"; +export * from "./getWorkspaceMembers"; +export * from "./getWorkspaceInvites"; +export * from "./createWorkspaceInvite"; +export * from "./resendWorkspaceInvite"; +export * from "./revokeWorkspaceInvite"; +export * from "./removeWorkspaceMember"; +export * from "./leaveWorkspace"; diff --git a/services/backend-api/client/src/features/workspaces/api/leaveWorkspace.ts b/services/backend-api/client/src/features/workspaces/api/leaveWorkspace.ts new file mode 100644 index 000000000..6f0871213 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/api/leaveWorkspace.ts @@ -0,0 +1,10 @@ +import fetchRest from "@/utils/fetchRest"; + +export const leaveWorkspace = async (workspaceSlug: string): Promise => { + await fetchRest(`/api/v1/workspaces/${workspaceSlug}/members/@me`, { + requestOptions: { + method: "DELETE", + }, + skipJsonParse: true, + }); +}; diff --git a/services/backend-api/client/src/features/workspaces/api/removeWorkspaceMember.ts b/services/backend-api/client/src/features/workspaces/api/removeWorkspaceMember.ts new file mode 100644 index 000000000..393a1d7d2 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/api/removeWorkspaceMember.ts @@ -0,0 +1,18 @@ +import fetchRest from "@/utils/fetchRest"; + +export interface RemoveWorkspaceMemberInput { + workspaceSlug: string; + userId: string; +} + +export const removeWorkspaceMember = async ({ + workspaceSlug, + userId, +}: RemoveWorkspaceMemberInput): Promise => { + await fetchRest(`/api/v1/workspaces/${workspaceSlug}/members/${userId}`, { + requestOptions: { + method: "DELETE", + }, + skipJsonParse: true, + }); +}; diff --git a/services/backend-api/client/src/features/workspaces/api/resendWorkspaceInvite.ts b/services/backend-api/client/src/features/workspaces/api/resendWorkspaceInvite.ts new file mode 100644 index 000000000..ba6c43522 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/api/resendWorkspaceInvite.ts @@ -0,0 +1,19 @@ +import fetchRest from "@/utils/fetchRest"; + +export interface ResendWorkspaceInviteInput { + workspaceSlug: string; + inviteId: string; +} + +export const resendWorkspaceInvite = async ({ + workspaceSlug, + inviteId, +}: ResendWorkspaceInviteInput): Promise => { + await fetchRest(`/api/v1/workspaces/${workspaceSlug}/invites/${inviteId}/resend`, { + requestOptions: { + method: "POST", + body: JSON.stringify({}), + }, + skipJsonParse: true, + }); +}; diff --git a/services/backend-api/client/src/features/workspaces/api/revokeWorkspaceInvite.ts b/services/backend-api/client/src/features/workspaces/api/revokeWorkspaceInvite.ts new file mode 100644 index 000000000..efad807ca --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/api/revokeWorkspaceInvite.ts @@ -0,0 +1,18 @@ +import fetchRest from "@/utils/fetchRest"; + +export interface RevokeWorkspaceInviteInput { + workspaceSlug: string; + inviteId: string; +} + +export const revokeWorkspaceInvite = async ({ + workspaceSlug, + inviteId, +}: RevokeWorkspaceInviteInput): Promise => { + await fetchRest(`/api/v1/workspaces/${workspaceSlug}/invites/${inviteId}`, { + requestOptions: { + method: "DELETE", + }, + skipJsonParse: true, + }); +}; diff --git a/services/backend-api/client/src/features/workspaces/api/sendEmailVerification.ts b/services/backend-api/client/src/features/workspaces/api/sendEmailVerification.ts new file mode 100644 index 000000000..7ee8de2d0 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/api/sendEmailVerification.ts @@ -0,0 +1,18 @@ +import fetchRest from "@/utils/fetchRest"; + +export interface SendEmailVerificationInput { + details: { + email: string; + }; +} + +export const sendEmailVerification = async ({ + details, +}: SendEmailVerificationInput): Promise => { + await fetchRest("/api/v1/users/@me/email-verification", { + requestOptions: { + method: "POST", + body: JSON.stringify(details), + }, + }); +}; diff --git a/services/backend-api/client/src/features/workspaces/api/sendInviteVerification.ts b/services/backend-api/client/src/features/workspaces/api/sendInviteVerification.ts new file mode 100644 index 000000000..af061bcdd --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/api/sendInviteVerification.ts @@ -0,0 +1,23 @@ +import fetchRest from "@/utils/fetchRest"; + +export interface SendInviteVerificationInput { + inviteId: string; + details: { + email: string; + }; +} + +// Invite-scoped verification send. The server dispatches a code only when the +// submitted address matches the invited address (uniform response either way), +// so the invite flow can never email an unrelated address. +export const sendInviteVerification = async ({ + inviteId, + details, +}: SendInviteVerificationInput): Promise => { + await fetchRest(`/api/v1/workspace-invites/${inviteId}/verification`, { + requestOptions: { + method: "POST", + body: JSON.stringify(details), + }, + }); +}; diff --git a/services/backend-api/client/src/features/workspaces/api/updateWorkspace.ts b/services/backend-api/client/src/features/workspaces/api/updateWorkspace.ts new file mode 100644 index 000000000..3af217a83 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/api/updateWorkspace.ts @@ -0,0 +1,32 @@ +import { InferType, object } from "yup"; +import fetchRest from "@/utils/fetchRest"; +import { WorkspaceDetailsSchema } from "../types"; + +export interface UpdateWorkspaceInput { + workspaceSlug: string; + details: { + name?: string; + slug?: string; + }; +} + +const UpdateWorkspaceOutputSchema = object({ + result: WorkspaceDetailsSchema, +}).required(); + +export type UpdateWorkspaceOutput = InferType; + +export const updateWorkspace = async ({ + workspaceSlug, + details, +}: UpdateWorkspaceInput): Promise => { + const res = await fetchRest(`/api/v1/workspaces/${workspaceSlug}`, { + validateSchema: UpdateWorkspaceOutputSchema, + requestOptions: { + method: "PATCH", + body: JSON.stringify(details), + }, + }); + + return res as UpdateWorkspaceOutput; +}; diff --git a/services/backend-api/client/src/features/workspaces/components/CreateWorkspaceDialog/CreateWorkspaceDialog.test.tsx b/services/backend-api/client/src/features/workspaces/components/CreateWorkspaceDialog/CreateWorkspaceDialog.test.tsx new file mode 100644 index 000000000..20fe31f75 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/components/CreateWorkspaceDialog/CreateWorkspaceDialog.test.tsx @@ -0,0 +1,295 @@ +import "@testing-library/jest-dom"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { ChakraProvider } from "@chakra-ui/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { system } from "@/utils/theme"; +import { CreateWorkspaceDialog } from "./index"; +import { useUserMe } from "@/features/discordUser"; +import ApiAdapterError from "@/utils/ApiAdapterError"; + +const h = vi.hoisted(() => ({ + create: vi.fn(), + send: vi.fn(), + confirm: vi.fn(), + navigate: vi.fn(), + createError: { current: null as null | { message: string; errorCode?: string } }, +})); + +vi.mock("@/features/discordUser", () => ({ + useUserMe: vi.fn(), +})); + +vi.mock("../../hooks", () => ({ + useCreateWorkspace: () => ({ + mutateAsync: h.create, + status: "idle", + error: h.createError.current, + reset: vi.fn(), + }), + useSendEmailVerification: () => ({ + mutateAsync: h.send, + status: "idle", + error: null, + reset: vi.fn(), + }), + useConfirmEmailVerification: () => ({ + mutateAsync: h.confirm, + status: "idle", + error: null, + reset: vi.fn(), + }), +})); + +vi.mock("react-router-dom", async (importOriginal) => { + const actual = await importOriginal(); + + return { ...actual, useNavigate: () => h.navigate }; +}); + +const mockUnverified = () => + vi.mocked(useUserMe).mockReturnValue({ + data: { + result: { email: "discord@example.com", verifiedEmail: undefined }, + }, + } as never); + +const mockVerified = () => + vi.mocked(useUserMe).mockReturnValue({ + data: { + result: { + email: "discord@example.com", + verifiedEmail: "owned@example.com", + }, + }, + } as never); + +const renderDialog = (onClose = vi.fn()) => { + render( + + + , + ); + + return onClose; +}; + +describe("CreateWorkspaceDialog", () => { + beforeEach(() => { + vi.clearAllMocks(); + h.createError.current = null; + }); + + it("requires email verification before the name form when no verified email exists", () => { + mockUnverified(); + + renderDialog(); + + expect(screen.getByLabelText(/email address/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /send code/i })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /create team/i })).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/team name/i)).not.toBeInTheDocument(); + }); + + it("pre-fills the Discord email in the verification step", () => { + mockUnverified(); + + renderDialog(); + + expect(screen.getByLabelText(/email address/i)).toHaveValue("discord@example.com"); + }); + + it("shows the name and slug form once a verified email exists", () => { + mockVerified(); + + renderDialog(); + + expect(screen.getByLabelText(/team name/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/team url/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /create team/i })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /send code/i })).not.toBeInTheDocument(); + }); + + it("auto-fills the slug from the workspace name while pristine", async () => { + mockVerified(); + renderDialog(); + + fireEvent.change(screen.getByLabelText(/team name/i), { + target: { value: "My Awesome Workspace" }, + }); + + await waitFor(() => + expect(screen.getByLabelText(/team url/i)).toHaveValue("my-awesome-workspace"), + ); + }); + + it("stops auto-filling once the slug field is manually edited", async () => { + mockVerified(); + renderDialog(); + + const slugInput = screen.getByLabelText(/team url/i); + fireEvent.focus(slugInput); + fireEvent.change(slugInput, { target: { value: "my-custom-slug" } }); + + fireEvent.change(screen.getByLabelText(/team name/i), { + target: { value: "Other Name" }, + }); + + await waitFor(() => + // slug should not change to "other-name"; stays "my-custom-slug" + expect(slugInput).toHaveValue("my-custom-slug"), + ); + }); + + it("creates the workspace and navigates to its workspace using the slug", async () => { + mockVerified(); + h.create.mockResolvedValue({ result: { id: "workspace-x", slug: "my-workspace" } }); + const onClose = renderDialog(); + + fireEvent.change(screen.getByLabelText(/team name/i), { + target: { value: "My Workspace" }, + }); + await waitFor(() => expect(screen.getByLabelText(/team url/i)).toHaveValue("my-workspace")); + fireEvent.click(screen.getByRole("button", { name: /create team/i })); + + await waitFor(() => + expect(h.create).toHaveBeenCalledWith({ + details: { name: "My Workspace", slug: "my-workspace" }, + }), + ); + expect(h.navigate).toHaveBeenCalledWith("/workspaces/my-workspace/feeds"); + expect(onClose).toHaveBeenCalled(); + }); + + it("keeps the dialog open when creation fails", async () => { + mockVerified(); + h.create.mockRejectedValue(new Error("boom")); + const onClose = renderDialog(); + + fireEvent.change(screen.getByLabelText(/team name/i), { + target: { value: "My Workspace" }, + }); + await waitFor(() => expect(screen.getByLabelText(/team url/i)).toHaveValue("my-workspace")); + fireEvent.click(screen.getByRole("button", { name: /create team/i })); + + await waitFor(() => expect(h.create).toHaveBeenCalled()); + expect(h.navigate).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("sets a slug field error on WORKSPACE_SLUG_TAKEN", async () => { + mockVerified(); + h.create.mockRejectedValue( + new ApiAdapterError("slug taken", { errorCode: "WORKSPACE_SLUG_TAKEN" }), + ); + renderDialog(); + + fireEvent.change(screen.getByLabelText(/team name/i), { + target: { value: "My Workspace" }, + }); + await waitFor(() => expect(screen.getByLabelText(/team url/i)).toHaveValue("my-workspace")); + fireEvent.click(screen.getByRole("button", { name: /create team/i })); + + expect(await screen.findByText(/this url is already taken/i)).toBeInTheDocument(); + }); + + it("surfaces a create error in an inline alert", () => { + mockVerified(); + h.createError.current = { message: "Workspace name already taken" }; + + renderDialog(); + + expect(screen.getByText("Failed to create team")).toBeInTheDocument(); + expect(screen.getByText("Workspace name already taken")).toBeInTheDocument(); + }); + + it("renders the friendly mapped message for a coded create error, not the raw string", () => { + mockVerified(); + h.createError.current = { message: "raw server detail", errorCode: "EMAIL_NOT_VERIFIED" }; + + renderDialog(); + + expect(screen.getByText("Failed to create team")).toBeInTheDocument(); + expect(screen.getByText(/a verified email is required/i)).toBeInTheDocument(); + expect(screen.queryByText(/raw server detail/i)).not.toBeInTheDocument(); + }); + + it("does not duplicate a slug-taken failure as a generic alert (shown on the field instead)", () => { + // The slug code is surfaced on the slug field; the generic alert must suppress + // it to avoid showing the same failure twice. + mockVerified(); + h.createError.current = { message: "slug taken", errorCode: "WORKSPACE_SLUG_TAKEN" }; + + renderDialog(); + + expect(screen.queryByText("Failed to create team")).not.toBeInTheDocument(); + }); + + it("blocks submission and shows a validation error for an empty name", async () => { + mockVerified(); + renderDialog(); + + fireEvent.click(screen.getByRole("button", { name: /create team/i })); + + expect(await screen.findByText("Team name is required")).toBeInTheDocument(); + expect(h.create).not.toHaveBeenCalled(); + }); + + it("blocks a reserved slug client-side without calling the API", async () => { + mockVerified(); + renderDialog(); + + const slugInput = screen.getByLabelText(/team url/i); + fireEvent.focus(slugInput); + fireEvent.change(slugInput, { target: { value: "settings" } }); + fireEvent.change(screen.getByLabelText(/team name/i), { + target: { value: "My Workspace" }, + }); + fireEvent.click(screen.getByRole("button", { name: /create team/i })); + + expect(await screen.findByText(/this url is reserved/i)).toBeInTheDocument(); + expect(h.create).not.toHaveBeenCalled(); + }); + + it("surfaces a reserved-slug error returned by the API on the slug field", async () => { + mockVerified(); + h.create.mockRejectedValue( + new ApiAdapterError("reserved", { errorCode: "WORKSPACE_SLUG_RESERVED" }), + ); + renderDialog(); + + fireEvent.change(screen.getByLabelText(/team name/i), { + target: { value: "My Workspace" }, + }); + await waitFor(() => expect(screen.getByLabelText(/team url/i)).toHaveValue("my-workspace")); + fireEvent.click(screen.getByRole("button", { name: /create team/i })); + + expect(await screen.findByText(/this url is reserved/i)).toBeInTheDocument(); + }); + + it("does not validate the name field on blur before a submission attempt", async () => { + mockVerified(); + renderDialog(); + + const nameInput = screen.getByLabelText(/team name/i); + fireEvent.focus(nameInput); + fireEvent.blur(nameInput); + + await waitFor(() => { + expect(screen.queryByText("Team name is required")).not.toBeInTheDocument(); + }); + }); + + it("announces verification success so the name form is reachable", async () => { + mockUnverified(); + renderDialog(); + + fireEvent.click(screen.getByRole("button", { name: /send code/i })); + fireEvent.change(await screen.findByLabelText(/verification code/i), { + target: { value: "123456" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Verify" })); + + expect(await screen.findByText(/your email is verified/i)).toBeInTheDocument(); + await waitFor(() => expect(h.confirm).toHaveBeenCalled()); + }); +}); diff --git a/services/backend-api/client/src/features/workspaces/components/CreateWorkspaceDialog/index.tsx b/services/backend-api/client/src/features/workspaces/components/CreateWorkspaceDialog/index.tsx new file mode 100644 index 000000000..008ed5141 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/components/CreateWorkspaceDialog/index.tsx @@ -0,0 +1,212 @@ +import { useEffect, useRef, useState } from "react"; +import { Button, Input, InputGroup, Stack, VisuallyHidden } from "@chakra-ui/react"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { Controller, useForm } from "react-hook-form"; +import { useNavigate } from "react-router-dom"; +import { InferType, object, string } from "yup"; +import { pages } from "@/constants"; +import { InlineErrorAlert } from "@/components/InlineErrorAlert"; +import { PrimaryActionButton } from "@/components/PrimaryActionButton"; +import { useUserMe } from "@/features/discordUser"; +import { isReservedSlug, SLUG_PATTERN, slugifyPreview } from "@/utils/slugify"; +import ApiAdapterError from "@/utils/ApiAdapterError"; +import { ApiErrorCode, getStandardErrorCodeMessage } from "@/utils/getStandardErrorCodeMessage"; +import { + DialogRoot, + DialogContent, + DialogHeader, + DialogTitle, + DialogBody, + DialogFooter, + DialogCloseTrigger, +} from "@/components/ui/dialog"; +import { Field } from "@/components/ui/field"; +import { useCreateWorkspace } from "../../hooks"; +import { VerifyEmailStep } from "../VerifyEmailStep"; + +const formSchema = object({ + name: string().required("Team name is required").max(100, "Team name is too long"), + slug: string() + .required("Team URL is required") + .min(2, "Must be at least 2 characters") + .max(50, "Must be 50 characters or fewer") + .matches(SLUG_PATTERN, "Lowercase letters, numbers, and hyphens only (not at start or end)") + .test("not-reserved", "This URL is reserved. Please choose another.", (value) => + value ? !isReservedSlug(value) : true, + ), +}); + +type FormData = InferType; + +interface Props { + isOpen: boolean; + onClose: () => void; +} + +const FORM_ID = "create-workspace-form"; + +export const CreateWorkspaceDialog = ({ isOpen, onClose }: Props) => { + const navigate = useNavigate(); + const { data: userMe } = useUserMe(); + const verifiedEmail = userMe?.result.verifiedEmail; + const discordEmail = userMe?.result.email; + + const { mutateAsync, error, reset } = useCreateWorkspace(); + const [slugTouched, setSlugTouched] = useState(false); + const { + handleSubmit, + control, + reset: resetForm, + watch, + setValue, + setError, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: yupResolver(formSchema), + mode: "onSubmit", + defaultValues: { name: "", slug: "" }, + }); + + const watchedName = watch("name"); + + const nameInputRef = useRef(null); + const [announcement, setAnnouncement] = useState(""); + + useEffect(() => { + if (isOpen) { + resetForm({ name: "", slug: "" }); + setSlugTouched(false); + reset(); + setAnnouncement(""); + } + }, [isOpen]); + + // Without this, the verify step swapping out on success drops focus to the body. + useEffect(() => { + if (isOpen && verifiedEmail) { + nameInputRef.current?.focus(); + } + }, [isOpen, verifiedEmail]); + + // Auto-fill slug from name while the slug field is pristine. + useEffect(() => { + if (!slugTouched && watchedName) { + setValue("slug", slugifyPreview(watchedName)); + } + }, [watchedName, slugTouched]); + + const onSubmit = async ({ name, slug }: FormData) => { + try { + const { result } = await mutateAsync({ details: { name, slug } }); + onClose(); + navigate(pages.userFeeds({ workspaceSlug: result.slug })); + } catch (err: unknown) { + const apiError = err as ApiAdapterError; + + if (apiError?.errorCode === ApiErrorCode.WORKSPACE_SLUG_TAKEN) { + setError("slug", { message: "This URL is already taken" }); + } else if (apiError?.errorCode === ApiErrorCode.WORKSPACE_SLUG_RESERVED) { + setError("slug", { message: "This URL is reserved. Please choose another." }); + } + // Other errors surfaced via `error` below; keep the dialog open on failure + } + }; + + return ( + !e.open && onClose()}> + + + Create a team + + + + {!verifiedEmail ? ( + + setAnnouncement("Your email is verified. Enter a team name to create your team.") + } + /> + ) : ( +
+ + + ( + { + field.ref(el); + nameInputRef.current = el; + }} + /> + )} + /> + + + + ( + setSlugTouched(true)} + placeholder="my-team" + /> + )} + /> + + + {/* Slug-taken/reserved are already shown inline on the slug field, + so the generic alert covers only the remaining failures, using + the friendly mapped message rather than the raw server string. */} + {error && + error.errorCode !== ApiErrorCode.WORKSPACE_SLUG_TAKEN && + error.errorCode !== ApiErrorCode.WORKSPACE_SLUG_RESERVED && ( + + )} + +
+ )} + {announcement} +
+ + + {verifiedEmail && ( + + Create team + + )} + +
+
+ ); +}; diff --git a/services/backend-api/client/src/features/workspaces/components/InvitePage/InvitePage.test.tsx b/services/backend-api/client/src/features/workspaces/components/InvitePage/InvitePage.test.tsx new file mode 100644 index 000000000..e4728d852 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/components/InvitePage/InvitePage.test.tsx @@ -0,0 +1,273 @@ +import "@testing-library/jest-dom"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { ChakraProvider } from "@chakra-ui/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { system } from "@/utils/theme"; +import { useUserMe } from "@/features/discordUser"; +import { InvitePage } from "./index"; + +const h = vi.hoisted(() => ({ + accept: vi.fn(), + decline: vi.fn(), + sendInviteVerification: vi.fn(), + navigate: vi.fn(), + invalidate: vi.fn(), + invite: { + current: null as null | { + id: string; + emailHint: string; + email?: string; + role: string; + workspaceName: string; + invitedByUserId: string; + createdAt: string; + alreadyMember?: boolean; + }, + }, + inviteStatus: { current: "success" as "loading" | "success" | "error" }, +})); + +vi.mock("@/features/discordUser", () => ({ + useUserMe: vi.fn(), +})); + +vi.mock("@tanstack/react-query", () => ({ + useQueryClient: () => ({ invalidateQueries: h.invalidate }), +})); + +vi.mock("../../hooks", () => ({ + useWorkspaceInvite: () => ({ + invite: h.invite.current, + status: h.inviteStatus.current, + error: null, + refetch: vi.fn(), + }), + useAcceptWorkspaceInvite: () => ({ mutateAsync: h.accept, status: "idle", error: null }), + useDeclineWorkspaceInvite: () => ({ mutateAsync: h.decline, status: "idle", error: null }), + useSendInviteVerification: () => ({ + mutateAsync: h.sendInviteVerification, + status: "idle", + error: null, + reset: vi.fn(), + }), + // VerifyEmailStep (rendered in the mismatch branch) pulls these from the barrel. + useSendEmailVerification: () => ({ + mutateAsync: vi.fn(), + status: "idle", + error: null, + reset: vi.fn(), + }), + useConfirmEmailVerification: () => ({ mutateAsync: vi.fn(), status: "idle", error: null }), +})); + +vi.mock("react-router-dom", () => ({ + useNavigate: () => h.navigate, + useParams: () => ({ inviteId: "invite-1" }), +})); + +// True when `first` appears before `second` in document order. Avoids the bitwise +// compareDocumentPosition mask (no-bitwise) by walking the rendered tree. +const precedesInDom = (first: Element, second: Element): boolean => { + const all = Array.from(document.querySelectorAll("*")); + + return all.indexOf(first) < all.indexOf(second); +}; + +// The GET endpoint returns the full `email` ONLY when the caller's verified email +// matches the invite; otherwise it returns just a redacted `emailHint`. Model both +// cases so the component is tested against the real contract. +const seedInvite = ( + email: string, + { matched, alreadyMember }: { matched: boolean; alreadyMember?: boolean }, +) => { + h.invite.current = { + id: "invite-1", + emailHint: "i***@example.com", + ...(matched ? { email } : {}), + role: "admin", + workspaceName: "Acme Team", + invitedByUserId: "inviter-1", + createdAt: new Date().toISOString(), + alreadyMember, + }; +}; + +const mockUser = (verifiedEmail?: string) => + vi.mocked(useUserMe).mockReturnValue({ + data: { result: { email: "discord@example.com", verifiedEmail } }, + } as never); + +const renderPage = () => + render( + + + , + ); + +describe("InvitePage", () => { + beforeEach(() => { + vi.clearAllMocks(); + h.inviteStatus.current = "success"; + h.invite.current = null; + }); + + it("shows the workspace name and the full invited address when the caller matches", () => { + seedInvite("invitee@example.com", { matched: true }); + mockUser("invitee@example.com"); + + renderPage(); + + expect(screen.getByRole("heading", { name: "Acme Team" })).toBeInTheDocument(); + expect(screen.getByText("invitee@example.com")).toBeInTheDocument(); + }); + + it("offers accept and decline when the verified email matches the invited email", () => { + seedInvite("invitee@example.com", { matched: true }); + mockUser("invitee@example.com"); + + renderPage(); + + expect(screen.getByRole("button", { name: /accept invitation/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /decline/i })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /send code/i })).not.toBeInTheDocument(); + }); + + it("accepts the invitation and navigates into the joined workspace", async () => { + seedInvite("invitee@example.com", { matched: true }); + mockUser("invitee@example.com"); + h.accept.mockResolvedValue({ result: { workspaceSlug: "acme-team" } }); + + renderPage(); + fireEvent.click(screen.getByRole("button", { name: /accept invitation/i })); + + await waitFor(() => expect(h.accept).toHaveBeenCalledWith("invite-1")); + // Drops the invitee into the workspace they just joined, not personal feeds. + expect(h.navigate).toHaveBeenCalledWith("/workspaces/acme-team/feeds"); + }); + + it("shows an accept failure as a friendly alert positioned above the action buttons", async () => { + seedInvite("invitee@example.com", { matched: true }); + mockUser("invitee@example.com"); + h.accept.mockRejectedValue( + Object.assign(new Error("raw server detail"), { + errorCode: "WORKSPACE_INVITE_ALREADY_MEMBER", + }), + ); + + renderPage(); + fireEvent.click(screen.getByRole("button", { name: /accept invitation/i })); + + const alert = await screen.findByText(/failed to accept the invitation/i); + expect(screen.getByText(/you are already a member/i)).toBeInTheDocument(); + expect(screen.queryByText(/raw server detail/i)).not.toBeInTheDocument(); + expect(h.navigate).not.toHaveBeenCalled(); + + // The alert must precede the Accept button in DOM order (errors above actions). + const acceptButton = screen.getByRole("button", { name: /accept invitation/i }); + expect(precedesInDom(alert, acceptButton)).toBe(true); + }); + + it("tells an already-member caller there's nothing to accept, without offering verification or accept", () => { + // The self-accept dead-end: an existing member (e.g. the owner) opens their + // own invite. The page must short-circuit BEFORE the verify step so the + // caller's verified email is never overwritten for an accept the server would + // reject anyway. + seedInvite("invitee@example.com", { matched: false, alreadyMember: true }); + mockUser("someone-else@example.com"); + + renderPage(); + + expect(screen.getByText(/you're already a member/i)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /send code/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /accept invitation/i })).not.toBeInTheDocument(); + }); + + it("guides the user to verify when emails do not match (server withholds the full address)", () => { + // Unmatched caller: the server returns only the hint, so the field is not + // locked — the user types the invited address, which the server gates. + seedInvite("invitee@example.com", { matched: false }); + mockUser("someone-else@example.com"); + + renderPage(); + + expect(screen.getByRole("button", { name: /send code/i })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /accept invitation/i })).not.toBeInTheDocument(); + // The copy acknowledges the address the user has already verified, distinguishing + // this from the "no verified email at all" case. + expect(screen.getByText(/you've verified/i)).toBeInTheDocument(); + expect(screen.getByText("someone-else@example.com")).toBeInTheDocument(); + // The redacted hint is shown for context (never the full invited address). + expect(screen.getAllByText("i***@example.com").length).toBeGreaterThan(0); + }); + + it("guides verification when the user has no verified email at all", () => { + seedInvite("invitee@example.com", { matched: false }); + mockUser(undefined); + + renderPage(); + + expect(screen.getByRole("button", { name: /send code/i })).toBeInTheDocument(); + // With no verified email, the copy does NOT claim the user has verified anything. + expect(screen.queryByText(/you've verified/i)).not.toBeInTheDocument(); + }); + + it("blocks sending a code to an address that can't match the invited hint", async () => { + // Hint is i***@example.com; the user is unmatched so the field is editable. + seedInvite("invitee@example.com", { matched: false }); + mockUser("someone-else@example.com"); + + renderPage(); + + const emailInput = screen.getByLabelText(/email address/i); + // A clearly-unrelated address (wrong first char AND wrong domain). + fireEvent.change(emailInput, { target: { value: "attacker@evil.com" } }); + fireEvent.click(screen.getByRole("button", { name: /send code/i })); + + // The guard fires: no verification send is dispatched, and the user is told + // which address to use (referencing the hint). + await waitFor(() => + expect( + screen.getByText(/enter the address this invitation was sent to/i), + ).toBeInTheDocument(), + ); + expect(h.sendInviteVerification).not.toHaveBeenCalled(); + }); + + it("sends an invite-scoped code when the typed address matches the hint", async () => { + seedInvite("invitee@example.com", { matched: false }); + mockUser("someone-else@example.com"); + h.sendInviteVerification.mockResolvedValue(undefined); + + renderPage(); + + const emailInput = screen.getByLabelText(/email address/i); + // Matches the hint i***@example.com: first char "i" + domain example.com. + fireEvent.change(emailInput, { target: { value: "invitee@example.com" } }); + fireEvent.click(screen.getByRole("button", { name: /send code/i })); + + await waitFor(() => + expect(h.sendInviteVerification).toHaveBeenCalledWith({ + inviteId: "invite-1", + details: { email: "invitee@example.com" }, + }), + ); + }); + + it("labels the loading state for assistive technology", () => { + h.inviteStatus.current = "loading"; + mockUser("invitee@example.com"); + + renderPage(); + + expect(screen.getByText("Loading invitation")).toBeInTheDocument(); + }); + + it("shows an unavailable message when the invitation cannot be loaded", () => { + h.inviteStatus.current = "error"; + mockUser("invitee@example.com"); + + renderPage(); + + expect(screen.getByRole("heading", { name: /invitation unavailable/i })).toBeInTheDocument(); + }); +}); diff --git a/services/backend-api/client/src/features/workspaces/components/InvitePage/index.tsx b/services/backend-api/client/src/features/workspaces/components/InvitePage/index.tsx new file mode 100644 index 000000000..d62a8d468 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/components/InvitePage/index.tsx @@ -0,0 +1,270 @@ +import { useState } from "react"; +import { + Box, + Button, + Heading, + HStack, + Spinner, + Stack, + Text, + VisuallyHidden, +} from "@chakra-ui/react"; +import { useNavigate, useParams } from "react-router-dom"; +import { useQueryClient } from "@tanstack/react-query"; +import { pages } from "@/constants"; +import { InlineErrorAlert } from "@/components/InlineErrorAlert"; +import { PrimaryActionButton } from "@/components/PrimaryActionButton"; +import { useUserMe } from "@/features/discordUser"; +import { getStandardErrorCodeMessage, ApiErrorCode } from "@/utils/getStandardErrorCodeMessage"; +import ApiAdapterError from "@/utils/ApiAdapterError"; +import { notifySuccess } from "@/utils/notifySuccess"; +import { + useAcceptWorkspaceInvite, + useDeclineWorkspaceInvite, + useSendInviteVerification, + useWorkspaceInvite, +} from "../../hooks"; +import { VerifyEmailStep } from "../VerifyEmailStep"; + +// Client-side guardrail for the verify step when the invited address is withheld +// (caller's verified email doesn't match, so we only have the redacted hint like +// `a***@example.com`). Blocks sending a code to an address that can't be the +// invited one, so the invite flow never emails an unrelated inbox. NOT a security +// boundary — the server enforces the real match and the hint is already shown. +const matchesEmailHint = (email: string, hint: string): boolean => { + const at = hint.lastIndexOf("@"); + + if (at <= 0) { + return false; + } + + const hintFirstChar = hint[0]?.toLowerCase(); + const hintDomain = hint.slice(at + 1).toLowerCase(); + const trimmed = email.trim().toLowerCase(); + const emailAt = trimmed.lastIndexOf("@"); + + if (emailAt <= 0) { + return false; + } + + return trimmed[0] === hintFirstChar && trimmed.slice(emailAt + 1) === hintDomain; +}; + +/** + * Invitation landing page (`/invites/:inviteId`). A logged-out user reaching this + * route is sent through Discord OAuth by `RequireAuth`, which preserves the path, + * and returns here. The invited email is resolved from the invitation itself, + * never the URL: if it doesn't match the user's verified email, the page guides + * them to verify the invited address before accepting. + */ +export const InvitePage = () => { + const { inviteId } = useParams<{ inviteId: string }>(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { invite, status, error, refetch } = useWorkspaceInvite({ inviteId }); + const { data: userMe } = useUserMe(); + const verifiedEmail = userMe?.result.verifiedEmail; + const discordEmail = userMe?.result.email; + + const { mutateAsync: accept, status: acceptStatus } = useAcceptWorkspaceInvite(); + const { mutateAsync: decline, status: declineStatus } = useDeclineWorkspaceInvite(); + const { mutateAsync: sendInviteVerification } = useSendInviteVerification(); + // The accept/decline failure is persisted inline (not a transient toast) so the + // reason — e.g. you are already a member of this workspace — stays on screen + // next to the action that produced it. + const [actionError, setActionError] = useState<{ title: string; description: string } | null>( + null, + ); + + if (status === "loading") { + return ( + + + Loading invitation + + + + ); + } + + if (status === "error" || !invite) { + return ( + + + + Invitation unavailable + + + + + + + + ); + } + + // The caller already belongs to this workspace (the case an owner hits opening + // their own invite). Short-circuit BEFORE the verify step: pushing them through + // email verification would overwrite their verified email for an accept the + // server rejects anyway. Leave the invite pending so the intended person can + // still claim it on a different account. + if (invite.alreadyMember) { + return ( + + + + {invite.workspaceName} + + + You're already a member of {invite.workspaceName}, so there's + nothing to accept. This invitation stays open for whoever it was sent to. + + + + + + + ); + } + + // The invited address. The GET endpoint returns the full `email` ONLY when the + // server has confirmed the caller's verified email matches; otherwise we get a + // redacted `emailHint` so a prober cannot harvest the address. So a present + // full email is itself the authoritative match signal. + const invitedEmailDisplay = invite.email ?? invite.emailHint; + const emailMatches = !!invite.email; + // The user has proven ownership of some email, but it isn't the invited one + // (so the server withheld the full address and returned only the hint). + const hasMismatchedVerifiedEmail = !!verifiedEmail && !emailMatches; + const isAccepting = acceptStatus === "loading"; + const isDeclining = declineStatus === "loading"; + + const resolveErrorMessage = (err: unknown): string => { + const code = (err as ApiAdapterError)?.errorCode as ApiErrorCode | undefined; + + return code ? getStandardErrorCodeMessage(code) : (err as Error).message; + }; + + const onAccept = async () => { + setActionError(null); + + try { + const { result } = await accept(invite.id); + notifySuccess(`You've joined ${invite.workspaceName}.`); + // Drop the invitee straight into the workspace they just joined, rather + // than their personal feeds — that's the place they came here to reach. + navigate(pages.userFeeds({ workspaceSlug: result.workspaceSlug })); + } catch (err) { + setActionError({ + title: "Failed to accept the invitation", + description: resolveErrorMessage(err), + }); + } + }; + + const onDecline = async () => { + setActionError(null); + + try { + await decline(invite.id); + notifySuccess("Invitation declined."); + navigate(pages.userFeeds()); + } catch (err) { + setActionError({ + title: "Failed to decline the invitation", + description: resolveErrorMessage(err), + }); + } + }; + + return ( + + + + + You've been invited to join + + + {invite.workspaceName} + + + This invitation was sent to {invitedEmailDisplay}. + + + {emailMatches ? ( + + {actionError && ( + + )} + + + Accept invitation + + + + + ) : ( + + + sendInviteVerification({ inviteId: invite.id, details: { email } }) + } + validateEmail={(email) => + // When the address is withheld (only the hint is known), block a + // send to anything that can't be the invited address before it + // leaves the browser. When invite.email is present the field is + // locked, so this guard never rejects the correct value. + invite.email || matchesEmailHint(email, invite.emailHint) + ? undefined + : `Enter the address this invitation was sent to (${invite.emailHint}).` + } + intro={ + hasMismatchedVerifiedEmail ? ( + <> + You've verified {verifiedEmail}, but this invitation was + sent to {invitedEmailDisplay}. Verify the invited address to + continue. + + ) : ( + <> + To accept this invitation, verify that you own{" "} + {invitedEmailDisplay} — the address it was sent to. We'll + send a one-time code to confirm it. + + ) + } + onVerified={() => { + queryClient.invalidateQueries({ queryKey: ["user-me"] }); + // Re-fetch the invite: now that the invited email is verified, + // the server discloses the full address and the match unlocks + // the accept action (emailMatches derives from invite.email). + refetch(); + }} + /> + + )} + + + ); +}; diff --git a/services/backend-api/client/src/features/workspaces/components/PendingInvitationsList/PendingInvitationsList.test.tsx b/services/backend-api/client/src/features/workspaces/components/PendingInvitationsList/PendingInvitationsList.test.tsx new file mode 100644 index 000000000..370007151 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/components/PendingInvitationsList/PendingInvitationsList.test.tsx @@ -0,0 +1,171 @@ +import "@testing-library/jest-dom"; +import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; +import { ChakraProvider } from "@chakra-ui/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { system } from "@/utils/theme"; +import { PendingInvitationsList } from "./index"; + +const h = vi.hoisted(() => ({ + accept: vi.fn(), + decline: vi.fn(), + invites: { + current: [] as Array<{ + id: string; + email: string; + role: string; + workspaceName: string; + invitedByUserId: string; + createdAt: string; + }>, + }, + status: { current: "success" as "loading" | "success" | "error" }, + acceptStatus: { current: "idle" as "idle" | "loading" | "success" | "error" }, + declineStatus: { current: "idle" as "idle" | "loading" | "success" | "error" }, +})); + +vi.mock("../../hooks", () => ({ + useMyWorkspaceInvites: () => ({ + invites: h.invites.current, + status: h.status.current, + error: null, + refetch: vi.fn(), + }), + useAcceptWorkspaceInvite: () => ({ + mutateAsync: h.accept, + status: h.acceptStatus.current, + error: null, + }), + useDeclineWorkspaceInvite: () => ({ + mutateAsync: h.decline, + status: h.declineStatus.current, + error: null, + }), +})); + +// True when `first` appears before `second` in document order. Avoids the bitwise +// compareDocumentPosition mask (no-bitwise) by walking the rendered tree. +const precedesInDom = (first: Element, second: Element): boolean => { + const all = Array.from(document.querySelectorAll("*")); + + return all.indexOf(first) < all.indexOf(second); +}; + +const invite = (id: string, workspaceName: string) => ({ + id, + email: "me@example.com", + role: "admin", + workspaceName, + invitedByUserId: "inviter", + createdAt: new Date().toISOString(), +}); + +const renderList = () => + render( + + + , + ); + +describe("PendingInvitationsList", () => { + beforeEach(() => { + vi.clearAllMocks(); + h.status.current = "success"; + h.invites.current = []; + h.acceptStatus.current = "idle"; + h.declineStatus.current = "idle"; + }); + + it("renders nothing when there are no pending invitations", () => { + const { container } = renderList(); + expect(container).toBeEmptyDOMElement(); + }); + + it("lists every pending invitation under a labelled region", () => { + h.invites.current = [invite("a", "Workspace A"), invite("b", "Workspace B")]; + + renderList(); + + const region = screen.getByRole("region", { name: "Pending invitations" }); + expect(within(region).getByText("Workspace A")).toBeInTheDocument(); + expect(within(region).getByText("Workspace B")).toBeInTheDocument(); + }); + + it("accepts a specific invitation independently", async () => { + h.invites.current = [invite("a", "Workspace A"), invite("b", "Workspace B")]; + h.accept.mockResolvedValue(undefined); + + renderList(); + + const itemB = screen + .getAllByRole("listitem") + .find((el) => within(el).queryByText("Workspace B")) as HTMLElement; + fireEvent.click(within(itemB).getByRole("button", { name: "Accept" })); + + await waitFor(() => expect(h.accept).toHaveBeenCalledWith("b")); + expect(h.decline).not.toHaveBeenCalled(); + }); + + it("declines a specific invitation independently", async () => { + h.invites.current = [invite("a", "Workspace A"), invite("b", "Workspace B")]; + h.decline.mockResolvedValue(undefined); + + renderList(); + + const itemA = screen + .getAllByRole("listitem") + .find((el) => within(el).queryByText("Workspace A")) as HTMLElement; + fireEvent.click(within(itemA).getByRole("button", { name: "Decline" })); + + await waitFor(() => expect(h.decline).toHaveBeenCalledWith("a")); + expect(h.accept).not.toHaveBeenCalled(); + }); + + it("shows a per-row loading state and disables the other action while accepting", () => { + h.invites.current = [invite("a", "Workspace A")]; + h.acceptStatus.current = "loading"; + + renderList(); + + const item = screen.getByRole("listitem"); + // Accept reflects the in-flight state via its loading text... + expect(within(item).getByText("Accepting...")).toBeInTheDocument(); + // ...and Decline (a plain Button) is disabled so the row can't be double-submitted. + expect(within(item).getByRole("button", { name: /decline/i })).toBeDisabled(); + }); + + it("disables Accept while declining", () => { + h.invites.current = [invite("a", "Workspace A")]; + h.declineStatus.current = "loading"; + + renderList(); + + const item = screen.getByRole("listitem"); + expect(within(item).getByText("Declining...")).toBeInTheDocument(); + expect(within(item).getByRole("button", { name: /accept/i })).toHaveAttribute( + "aria-disabled", + "true", + ); + }); + + it("shows an accept failure as a friendly alert positioned above the action buttons", async () => { + h.invites.current = [invite("a", "Workspace A")]; + h.accept.mockRejectedValue( + Object.assign(new Error("raw server detail"), { + errorCode: "WORKSPACE_INVITE_ALREADY_MEMBER", + }), + ); + + renderList(); + + const item = screen.getByRole("listitem"); + fireEvent.click(within(item).getByRole("button", { name: "Accept" })); + + const alert = await within(item).findByText(/failed to accept the invitation/i); + expect(within(item).getByText(/you are already a member/i)).toBeInTheDocument(); + expect(within(item).queryByText(/raw server detail/i)).not.toBeInTheDocument(); + + // The alert must precede the Accept button in DOM order (errors above actions). + const acceptButton = within(item).getByRole("button", { name: "Accept" }); + expect(precedesInDom(alert, acceptButton)).toBe(true); + }); +}); diff --git a/services/backend-api/client/src/features/workspaces/components/PendingInvitationsList/index.tsx b/services/backend-api/client/src/features/workspaces/components/PendingInvitationsList/index.tsx new file mode 100644 index 000000000..9ec19ecc2 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/components/PendingInvitationsList/index.tsx @@ -0,0 +1,162 @@ +import { useState } from "react"; +import { + Box, + Button, + Heading, + HStack, + Skeleton, + Stack, + Text, + VisuallyHidden, +} from "@chakra-ui/react"; +import { InlineErrorAlert } from "@/components/InlineErrorAlert"; +import { PrimaryActionButton } from "@/components/PrimaryActionButton"; +import { getStandardErrorCodeMessage, ApiErrorCode } from "@/utils/getStandardErrorCodeMessage"; +import ApiAdapterError from "@/utils/ApiAdapterError"; +import { notifySuccess } from "@/utils/notifySuccess"; +import { + useAcceptWorkspaceInvite, + useDeclineWorkspaceInvite, + useMyWorkspaceInvites, +} from "../../hooks"; +import { WorkspaceInvite } from "../../types"; + +const LIVE_STATUS_TEXT: Record = { + loading: "Loading your invitations", + success: "Invitations loaded", +}; + +/** + * A single pending invitation. The accept/decline mutations are instantiated per row + * so each invitation tracks its own in-flight state — accepting one doesn't disable + * the controls on the others, and the row that's mutating can't be double-submitted. + */ +const InviteRow = ({ invite }: { invite: WorkspaceInvite }) => { + const { mutateAsync: accept, status: acceptStatus } = useAcceptWorkspaceInvite(); + const { mutateAsync: decline, status: declineStatus } = useDeclineWorkspaceInvite(); + // Persisted inline (not a toast) so the failure reason stays on the row that + // produced it — e.g. you are already a member of this workspace. + const [actionError, setActionError] = useState<{ title: string; description: string } | null>( + null, + ); + + const isAccepting = acceptStatus === "loading"; + const isDeclining = declineStatus === "loading"; + + const resolveErrorMessage = (err: unknown): string => { + const code = (err as ApiAdapterError)?.errorCode as ApiErrorCode | undefined; + + return code ? getStandardErrorCodeMessage(code) : (err as Error).message; + }; + + const onAccept = async () => { + setActionError(null); + + try { + await accept(invite.id); + notifySuccess(`You've joined ${invite.workspaceName}.`); + } catch (err) { + setActionError({ + title: "Failed to accept the invitation", + description: resolveErrorMessage(err), + }); + } + }; + + const onDecline = async () => { + setActionError(null); + + try { + await decline(invite.id); + notifySuccess("Invitation declined."); + } catch (err) { + setActionError({ + title: "Failed to decline the invitation", + description: resolveErrorMessage(err), + }); + } + }; + + return ( + + + {invite.workspaceName} + + Invited as {invite.role} · {invite.email} + + + {actionError && ( + + )} + + + Accept + + + + + ); +}; + +/** + * The caller's pending workspace invitations (keyed server-side on their verified + * email). A user invited to multiple workspaces under the same email sees them all + * and can accept or decline each independently. Renders nothing when there are no + * pending invitations, so it can sit unobtrusively on the Account Settings page. + */ +export const PendingInvitationsList = ({ enabled }: { enabled?: boolean }) => { + const { invites, status, error, refetch } = useMyWorkspaceInvites({ enabled }); + + if (status === "loading") { + return ( + + {LIVE_STATUS_TEXT[status]} + + + ); + } + + if (status === "error") { + return ( + + + + + ); + } + + if (!invites?.length) { + return null; + } + + return ( + + {LIVE_STATUS_TEXT[status] ?? ""} + + Pending invitations + + + {invites.map((invite) => ( + + ))} + + + ); +}; diff --git a/services/backend-api/client/src/features/workspaces/components/VerifyEmailStep/VerifyEmailStep.test.tsx b/services/backend-api/client/src/features/workspaces/components/VerifyEmailStep/VerifyEmailStep.test.tsx new file mode 100644 index 000000000..3c0107b96 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/components/VerifyEmailStep/VerifyEmailStep.test.tsx @@ -0,0 +1,182 @@ +import "@testing-library/jest-dom"; +import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; +import { ChakraProvider } from "@chakra-ui/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { system } from "@/utils/theme"; +import { VerifyEmailStep } from "./index"; + +const h = vi.hoisted(() => ({ + sendCode: vi.fn(), + confirmCode: vi.fn(), + resetSend: vi.fn(), + // The confirm error IS read off the hook (react-query owns it); the send error + // is now owned by the component and captured from the thrown rejection, so the + // send mock drives failures by rejecting rather than via a hook `error` field. + confirmError: null as { message: string; errorCode?: string } | null, +})); + +vi.mock("../../hooks", () => ({ + useSendEmailVerification: () => ({ + mutateAsync: h.sendCode, + status: "idle", + reset: h.resetSend, + }), + useConfirmEmailVerification: () => ({ + mutateAsync: h.confirmCode, + status: "idle", + error: h.confirmError, + }), +})); + +// Mimics the ApiAdapterError shape the real hooks reject with: an Error carrying +// an `errorCode` the component maps to a friendly message. +const apiError = (errorCode: string, message = "raw server detail") => + Object.assign(new Error(message), { errorCode }); + +// Advances the resend cooldown to zero so the resend button is interactive again. +// The countdown chains one setTimeout per second (each scheduled after the prior +// tick's state update), so the clock is advanced one second at a time to let each +// timer fire and re-render. +const elapseCooldown = async () => { + for (let i = 0; i < 60; i += 1) { + // eslint-disable-next-line no-await-in-loop + await act(async () => { + vi.advanceTimersByTime(1000); + }); + } +}; + +const renderStep = (props: Partial> = {}) => + render( + + + , + ); + +// Drives the component into the "code sent" view so the resend/confirm UI renders. +const reachCodeSentView = async (email = "user@example.com") => { + h.sendCode.mockResolvedValue(undefined); + renderStep({ defaultEmail: email }); + fireEvent.click(screen.getByRole("button", { name: /send code/i })); + await screen.findByRole("button", { name: /resend code/i }); +}; + +describe("VerifyEmailStep", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + h.confirmError = null; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("resends the code once the cooldown elapses, without requiring the verification field", async () => { + await reachCodeSentView(); + h.sendCode.mockClear(); + await elapseCooldown(); + + fireEvent.click(screen.getByRole("button", { name: /resend code/i })); + + await waitFor(() => expect(h.sendCode).toHaveBeenCalledTimes(1)); + // The empty verification-code field must NOT trip the confirm validation. + expect(screen.queryByText(/enter the 6-digit code/i)).not.toBeInTheDocument(); + expect(h.confirmCode).not.toHaveBeenCalled(); + }); + + it("disables resend during the cooldown and shows a countdown, then re-enables it", async () => { + await reachCodeSentView(); + h.sendCode.mockClear(); + + const resend = screen.getByRole("button", { name: /resend code/i }); + // Counts down (visual) and is inert while the server cooldown is in effect. + expect(resend).toHaveTextContent(/resend code \(\d+s\)/i); + expect(resend).toHaveAttribute("aria-disabled", "true"); + + // Clicking mid-cooldown must NOT fire another send. + fireEvent.click(resend); + expect(h.sendCode).not.toHaveBeenCalled(); + + await elapseCooldown(); + + expect(resend).toHaveTextContent(/^resend code$/i); + expect(resend).toHaveAttribute("aria-disabled", "false"); + }); + + it("surfaces a send failure on the address step using the friendly error message", async () => { + // The local send rejects with the server's 429 resend-too-soon code. + h.sendCode.mockRejectedValue(apiError("EMAIL_VERIFICATION_RESEND_TOO_SOON")); + renderStep({ defaultEmail: "user@example.com" }); + + fireEvent.click(screen.getByRole("button", { name: /^send code$/i })); + + expect(await screen.findByText(/failed to send code/i)).toBeInTheDocument(); + expect(screen.getByText(/please wait a moment before requesting/i)).toBeInTheDocument(); + // The raw server string must NOT leak through. + expect(screen.queryByText(/raw server detail/i)).not.toBeInTheDocument(); + // The failed send must not advance to the code-entry view. + expect(screen.queryByLabelText(/verification code/i)).not.toBeInTheDocument(); + }); + + it("surfaces a failure from the injected send path (invite flow), not just the local send", async () => { + // Reproduces the invite-flow bug: sends route through `onSendCode`, whose + // rejection the component must still surface (the local hook's error is blind + // to this path). Mirrors send X -> change email -> send X again hitting the + // server's still-active cooldown. + const onSendCode = vi + .fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(apiError("EMAIL_VERIFICATION_RESEND_TOO_SOON")); + renderStep({ defaultEmail: "invited@example.com", onSendCode }); + + fireEvent.click(screen.getByRole("button", { name: /^send code$/i })); + await screen.findByRole("button", { name: "Resend code" }); + + // Change email clears the client cooldown guard so the second send can fire. + fireEvent.click(screen.getByRole("button", { name: /change email/i })); + fireEvent.click(await screen.findByRole("button", { name: /^send code$/i })); + + expect(await screen.findByText(/failed to send code/i)).toBeInTheDocument(); + expect(screen.getByText(/please wait a moment before requesting/i)).toBeInTheDocument(); + expect(onSendCode).toHaveBeenCalledTimes(2); + }); + + it("surfaces a confirm failure using the friendly error message", async () => { + // The confirm error is owned by react-query and read off the hook, so the + // mocked hook exposes it via `error` (the real mutation populates this on + // rejection). + h.confirmError = { message: "raw server detail", errorCode: "EMAIL_VERIFICATION_INVALID_CODE" }; + await reachCodeSentView(); + + expect(await screen.findByText(/failed to verify/i)).toBeInTheDocument(); + expect(screen.getByText(/invalid or incorrect verification code/i)).toBeInTheDocument(); + expect(screen.queryByText(/raw server detail/i)).not.toBeInTheDocument(); + }); + + it("tells the user how long the verification code is valid", async () => { + await reachCodeSentView(); + + expect(screen.getByText(/the code expires in 10 minutes/i)).toBeInTheDocument(); + }); + + it("does not submit the confirm form when changing the email", async () => { + await reachCodeSentView(); + + fireEvent.click(screen.getByRole("button", { name: /change email/i })); + + expect(h.confirmCode).not.toHaveBeenCalled(); + expect(screen.queryByText(/enter the 6-digit code/i)).not.toBeInTheDocument(); + // Back on the email-entry view. + expect(screen.getByRole("button", { name: /send code/i })).toBeInTheDocument(); + }); + + it("shows the empty-code error only when the user submits the confirm form", async () => { + await reachCodeSentView(); + + fireEvent.click(screen.getByRole("button", { name: /^verify$/i })); + + expect(await screen.findByText(/enter the 6-digit code/i)).toBeInTheDocument(); + expect(h.confirmCode).not.toHaveBeenCalled(); + }); +}); diff --git a/services/backend-api/client/src/features/workspaces/components/VerifyEmailStep/index.tsx b/services/backend-api/client/src/features/workspaces/components/VerifyEmailStep/index.tsx new file mode 100644 index 000000000..6e9165f68 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/components/VerifyEmailStep/index.tsx @@ -0,0 +1,334 @@ +import { useEffect, useRef, useState } from "react"; +import { Box, Button, HStack, Input, Stack, Text, VisuallyHidden } from "@chakra-ui/react"; +import { InlineErrorAlert } from "@/components/InlineErrorAlert"; +import { PrimaryActionButton } from "@/components/PrimaryActionButton"; +import { Field } from "@/components/ui/field"; +import { getStandardErrorCodeMessage, ApiErrorCode } from "@/utils/getStandardErrorCodeMessage"; +import type ApiAdapterError from "@/utils/ApiAdapterError"; +import { useSendEmailVerification, useConfirmEmailVerification } from "../../hooks"; + +interface Props { + defaultEmail?: string; + onVerified: () => void; + /** + * Replaces the default "verify an email to create a team" intro. Used by the + * invitation flow, where the email being verified is the invited address. + */ + intro?: React.ReactNode; + /** + * When the email to verify is fixed (e.g. the invited address), lock the field + * so it can't be changed — verifying a different address wouldn't claim the + * invitation. + */ + lockEmail?: boolean; + /** + * Overrides how the verification code is requested. Defaults to the generic + * `/@me/email-verification` send. The invitation flow passes the invite-scoped + * send so the server only ever emails the invited address. + */ + onSendCode?: (email: string) => Promise; + /** + * Client-side guard run before sending. Return an error message to block the + * send (e.g. the typed address doesn't match the invited one), or undefined to + * allow it. A guardrail only — the real enforcement is server-side. + */ + validateEmail?: (email: string) => string | undefined; +} + +const EMAIL_REGEX = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; +const CODE_REGEX = /^[0-9]{6}$/; +// Mirrors the server's RESEND_COOLDOWN_MS / CODE_TTL_MS. These are UX disclosures, +// not enforcement — the server remains the source of truth for both limits. +const RESEND_COOLDOWN_SECONDS = 60; +const CODE_TTL_MINUTES = 10; + +// Prefer the standardized, friendly message for a known error code (e.g. the 429 +// resend cooldown, an invalid/expired code) over the raw server string, falling +// back to the message when the error carries no code. +const resolveErrorMessage = (err: ApiAdapterError): string => { + const code = err.errorCode as ApiErrorCode | undefined; + + return code ? getStandardErrorCodeMessage(code) : err.message; +}; + +/** + * Passwordless proof-of-ownership of an email: send a 6-digit code to an owned + * address, then confirm it. Pre-fills the Discord + * email for convenience but always requires confirmation — Discord's value is + * a default, not proof. No password anywhere. + */ +export const VerifyEmailStep = ({ + defaultEmail, + onVerified, + intro, + lockEmail, + onSendCode, + validateEmail, +}: Props) => { + const [email, setEmail] = useState(defaultEmail ?? ""); + const [code, setCode] = useState(""); + const [codeSent, setCodeSent] = useState(false); + const [sendAttempted, setSendAttempted] = useState(false); + const [confirmAttempted, setConfirmAttempted] = useState(false); + const [guardError, setGuardError] = useState(undefined); + // Tracks the in-flight send across BOTH paths: the local `sendCode` mutation + // and the injected `onSendCode` (invite flow). `sendStatus` only reflects the + // former, so the button needs its own flag to show a loading state when the + // invite-scoped send is used. + const [isSending, setIsSending] = useState(false); + // Client-side disclosure of the server's resend cooldown. Counts down from + // RESEND_COOLDOWN_SECONDS after each successful send so the user sees why the + // resend is unavailable instead of clicking into a silent 429. + const [cooldownRemaining, setCooldownRemaining] = useState(0); + // Single polite announcement made once per successful send. Carries the resend + // availability to screen-reader users WITHOUT the per-second spam a live + // countdown would cause; the visible "(Ns)" tick stays out of any live region. + const [sendAnnouncement, setSendAnnouncement] = useState(""); + // Owned by the component, NOT read off the local mutation's `error`: the send + // can run through EITHER the local `sendCode` OR the injected `onSendCode` + // (invite flow), and the local hook's error is blind to the latter. Capturing + // the thrown error here surfaces a failure (e.g. the 429 resend cooldown) on + // whichever path actually ran. + const [sendError, setSendError] = useState(undefined); + const sendCountRef = useRef(0); + + const { mutateAsync: sendCode, reset: resetSend } = useSendEmailVerification(); + const { + mutateAsync: confirmCode, + status: confirmStatus, + error: confirmError, + } = useConfirmEmailVerification(); + + const trimmedEmail = email.trim(); + const trimmedCode = code.trim(); + const emailValid = EMAIL_REGEX.test(trimmedEmail); + const codeValid = CODE_REGEX.test(trimmedCode); + const isConfirming = confirmStatus === "loading"; + const inCooldown = cooldownRemaining > 0; + + // Drives the visible countdown. Decrements once a second while active; the tick + // is intentionally NOT an aria-live region (see the resend button below) so a + // screen reader is not spammed every second. + useEffect(() => { + if (cooldownRemaining <= 0) { + return undefined; + } + + const timer = setTimeout(() => setCooldownRemaining((prev) => prev - 1), 1000); + + return () => clearTimeout(timer); + }, [cooldownRemaining]); + + const handleSendCode = async (event: React.SyntheticEvent) => { + event.preventDefault(); + setSendAttempted(true); + + if (!emailValid || isSending || inCooldown) { + return; + } + + const guardMessage = validateEmail?.(trimmedEmail); + + if (guardMessage) { + setGuardError(guardMessage); + + return; + } + + setGuardError(undefined); + setSendError(undefined); + setIsSending(true); + + try { + if (onSendCode) { + await onSendCode(trimmedEmail); + } else { + await sendCode({ details: { email: trimmedEmail } }); + } + + setCodeSent(true); + setSendAttempted(false); + setCooldownRemaining(RESEND_COOLDOWN_SECONDS); + sendCountRef.current += 1; + // First send vs. resend get distinct phrasing; both note the cooldown once. + setSendAnnouncement( + `${sendCountRef.current > 1 ? "New code sent" : "Code sent"} to ${trimmedEmail}. ` + + `You can resend in ${RESEND_COOLDOWN_SECONDS} seconds.`, + ); + } catch (err) { + // Captured from whichever path ran (local send OR injected invite send) and + // rendered in both the address and code-entry views below. + setSendError(err as ApiAdapterError); + } finally { + setIsSending(false); + } + }; + + const onConfirm = async (event: React.SyntheticEvent) => { + event.preventDefault(); + setConfirmAttempted(true); + + if (!codeValid || isConfirming) { + return; + } + + try { + await confirmCode({ + details: { email: trimmedEmail, code: trimmedCode }, + }); + onVerified(); + } catch { + // Surfaced via confirmError below + } + }; + + const onChangeEmail = () => { + setCodeSent(false); + setCode(""); + setSendAttempted(false); + setConfirmAttempted(false); + setGuardError(undefined); + setSendError(undefined); + setCooldownRemaining(0); + resetSend(); + }; + + if (!codeSent) { + return ( +
+ + + {intro ?? ( + <> + To create a team, first verify an email address you own. We'll send a one-time + code to confirm it. + + )} + + + { + setEmail(e.target.value); + if (guardError) setGuardError(undefined); + }} + /> + + {sendError && ( + + )} + + + Send code + + + +
+ ); + } + + return ( +
+ + + We sent a 6-digit code to {trimmedEmail}. Enter it below to verify. + + {sendAnnouncement} + + setCode(e.target.value)} + /> + + {confirmError && ( + + )} + {sendError && ( + + )} + + + {/* aria-disabled (not disabled) keeps the control focusable and + announceable while it's inert during the cooldown/send. The + accessible name stays "Resend code" — the ticking "(Ns)" is + visual-only and deliberately not in a live region. */} + + {!lockEmail && ( + <> + · + + + )} + + + Verify + + + +
+ ); +}; diff --git a/services/backend-api/client/src/features/workspaces/components/WorkspaceMembers/WorkspaceMembers.test.tsx b/services/backend-api/client/src/features/workspaces/components/WorkspaceMembers/WorkspaceMembers.test.tsx new file mode 100644 index 000000000..5d1fa39f1 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/components/WorkspaceMembers/WorkspaceMembers.test.tsx @@ -0,0 +1,406 @@ +import "@testing-library/jest-dom"; +import { render, screen, within, fireEvent, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ChakraProvider } from "@chakra-ui/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { system } from "@/utils/theme"; +import { WorkspaceMembers } from "./index"; + +const h = vi.hoisted(() => ({ + workspace: { + current: { + id: "ws-1", + name: "Acme", + slug: "acme", + myRole: "owner" as "owner" | "admin", + }, + }, + members: { + current: [] as Array<{ userId: string; role: string; discordUserId: string }>, + }, + invites: { + current: [] as Array<{ + id: string; + email: string; + role: string; + invitedByUserId: string; + createdAt: string; + }>, + }, + selfUserId: { current: "self" }, + createInvite: vi.fn(), + // `leave` needs per-test control: the modal-error-reset test sets an error and + // asserts the mutation is reset on close; the success test asserts the toast. + leave: vi.fn(), + leaveReset: vi.fn(), + leaveError: { current: null as null | { message: string; errorCode?: string } }, + removeMember: vi.fn(), + removeReset: vi.fn(), + removeError: { current: null as null | { message: string; errorCode?: string } }, + resend: vi.fn(), + resendReset: vi.fn(), + resendError: { current: null as null | { message: string; errorCode?: string } }, + createSuccessAlert: vi.fn(), + navigate: vi.fn(), +})); + +vi.mock("react-router-dom", async () => { + const actual = await vi.importActual("react-router-dom"); + + return { ...actual, useNavigate: () => h.navigate }; +}); + +vi.mock("../../contexts", () => ({ + useCurrentWorkspace: () => h.workspace.current, +})); + +vi.mock("@/features/discordUser", () => ({ + useUserMe: () => ({ data: { result: { id: h.selfUserId.current } } }), + // Resolve the snowflake to a readable username so rows show a name, not a raw id. + DiscordUsername: ({ userId }: { userId: string }) => {`user-${userId}`}, +})); + +vi.mock("@/contexts/PageAlertContext", () => ({ + usePageAlertContext: () => ({ createSuccessAlert: h.createSuccessAlert }), +})); + +vi.mock("../../hooks", () => ({ + useWorkspaceMembers: () => ({ + members: h.members.current, + status: "success", + error: null, + refetch: vi.fn(), + }), + useWorkspaceInvitesForWorkspace: () => ({ + invites: h.invites.current, + status: "success", + error: null, + refetch: vi.fn(), + }), + useCreateWorkspaceInvite: () => ({ mutateAsync: h.createInvite, error: null }), + useResendWorkspaceInvite: () => ({ + mutateAsync: h.resend, + error: h.resendError.current, + reset: h.resendReset, + }), + useRevokeWorkspaceInvite: () => ({ mutateAsync: vi.fn(), error: null, reset: vi.fn() }), + useRemoveWorkspaceMember: () => ({ + mutateAsync: h.removeMember, + error: h.removeError.current, + reset: h.removeReset, + }), + useLeaveWorkspace: () => ({ + mutateAsync: h.leave, + error: h.leaveError.current, + reset: h.leaveReset, + }), +})); + +const renderView = () => + render( + + + + + , + ); + +describe("WorkspaceMembers", () => { + beforeEach(() => { + vi.clearAllMocks(); + h.workspace.current = { id: "ws-1", name: "Acme", slug: "acme", myRole: "owner" }; + h.selfUserId.current = "self"; + h.members.current = [ + { userId: "self", role: "owner", discordUserId: "1111" }, + { userId: "other", role: "admin", discordUserId: "2222" }, + ]; + h.invites.current = []; + h.leaveError.current = null; + h.removeError.current = null; + h.resendError.current = null; + h.resend.mockResolvedValue(undefined); + h.leave.mockResolvedValue(undefined); + h.removeMember.mockResolvedValue(undefined); + }); + + const pendingInvite = (overrides?: Partial<(typeof h.invites.current)[number]>) => ({ + id: "inv-1", + email: "pending@example.com", + role: "admin", + invitedByUserId: "self", + createdAt: new Date().toISOString(), + ...overrides, + }); + + it("lists current members with their roles", () => { + renderView(); + + const region = screen.getByRole("region", { name: "Members" }); + const owner = within(region) + .getAllByRole("listitem") + .find((el) => within(el).queryByText(/owner/i)) as HTMLElement; + const admin = within(region) + .getAllByRole("listitem") + .find((el) => within(el).queryByText(/admin/i)) as HTMLElement; + + expect(within(owner).getByText(/owner/i)).toBeInTheDocument(); + expect(within(admin).getByText(/admin/i)).toBeInTheDocument(); + }); + + it("renders members by resolved username, not the raw Discord snowflake", () => { + renderView(); + + const region = screen.getByRole("region", { name: "Members" }); + expect(within(region).getByText("user-2222")).toBeInTheDocument(); + expect(within(region).queryByText("2222")).not.toBeInTheDocument(); + }); + + it("lists pending invitations with inviter and creation time", () => { + h.invites.current = [ + { + id: "inv-1", + email: "pending@example.com", + role: "admin", + invitedByUserId: "self", + createdAt: new Date().toISOString(), + }, + ]; + + renderView(); + + const region = screen.getByRole("region", { name: "Pending invitations" }); + const item = within(region).getByRole("listitem"); + expect(within(item).getByText("pending@example.com")).toBeInTheDocument(); + expect(within(item).getByText(/invited by you/i)).toBeInTheDocument(); + expect(within(item).getByText(/ago|few seconds/i)).toBeInTheDocument(); + }); + + it("shows a Remove control on other members for an owner", () => { + renderView(); + + const region = screen.getByRole("region", { name: "Members" }); + const otherRow = within(region) + .getAllByRole("listitem") + .find((el) => within(el).queryByText(/admin/i)) as HTMLElement; + expect(within(otherRow).getByRole("button", { name: /^remove/i })).toBeInTheDocument(); + }); + + it("hides the Remove-other control from an admin but keeps Leave", () => { + h.workspace.current = { id: "ws-1", name: "Acme", slug: "acme", myRole: "admin" }; + + renderView(); + + const region = screen.getByRole("region", { name: "Members" }); + expect(within(region).queryByRole("button", { name: /^remove/i })).not.toBeInTheDocument(); + expect(within(region).getByRole("button", { name: /leave/i })).toBeInTheDocument(); + }); + + it("does not show an invite-email validation error on blur, only after Send invite", async () => { + renderView(); + + const emailInput = screen.getByRole("textbox", { name: /invite by email/i }); + fireEvent.focus(emailInput); + fireEvent.change(emailInput, { target: { value: "not-an-email" } }); + fireEvent.blur(emailInput); + + // The error must NOT surface from typing/blur alone (mode: onSubmit). waitFor + // polls so an async mode:"all" validation appearing mid-window would fail this. + await waitFor(() => { + expect(screen.queryByText(/enter a valid email address/i)).not.toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: /send invite/i })); + + expect(await screen.findByText(/enter a valid email address/i)).toBeInTheDocument(); + expect(h.createInvite).not.toHaveBeenCalled(); + }); + + it("clears a stale leave error by resetting the mutation when the modal is closed", async () => { + const user = userEvent.setup(); + // Self is the only member so the row shows "Leave team". The hook reports a + // prior failure via `error` (react-query's error channel, mirrored here). + h.members.current = [{ userId: "self", role: "owner", discordUserId: "1111" }]; + h.leaveError.current = { message: "Cannot leave" }; + + renderView(); + + await user.click(screen.getByRole("button", { name: /leave team/i })); + const dialog = await screen.findByRole("alertdialog"); + // The stale error from the prior attempt is shown inside the dialog. + expect(within(dialog).getByText(/cannot leave/i)).toBeInTheDocument(); + + // Closing the modal must reset the mutation so the stale error doesn't persist + // into the next open. + await user.click(within(dialog).getByRole("button", { name: /cancel/i })); + + expect(h.leaveReset).toHaveBeenCalled(); + }); + + it("shows the friendly mapped message for a coded leave error, not the raw server string", async () => { + const user = userEvent.setup(); + h.members.current = [{ userId: "self", role: "owner", discordUserId: "1111" }]; + // A coded failure (the last owner can't leave) must render the friendly text. + h.leaveError.current = { + message: "raw server detail", + errorCode: "CANNOT_REMOVE_LAST_OWNER", + }; + + renderView(); + + await user.click(screen.getByRole("button", { name: /leave team/i })); + const dialog = await screen.findByRole("alertdialog"); + + expect(within(dialog).getByText(/a team must have at least one owner/i)).toBeInTheDocument(); + expect(within(dialog).queryByText(/raw server detail/i)).not.toBeInTheDocument(); + }); + + it("labels each pending-invite control with the target email so they are distinguishable", () => { + h.invites.current = [ + pendingInvite({ id: "inv-1", email: "a@example.com" }), + pendingInvite({ id: "inv-2", email: "b@example.com" }), + ]; + + renderView(); + + // Two pending invites means two Resend and two Revoke buttons; without the + // per-row email in the accessible name they would all read identically. + expect( + screen.getByRole("button", { name: "Resend invitation to a@example.com" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Resend invitation to b@example.com" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Revoke invitation to a@example.com" }), + ).toBeInTheDocument(); + }); + + it("resends an invitation and surfaces a success alert on confirm", async () => { + const user = userEvent.setup(); + h.invites.current = [pendingInvite({ email: "pending@example.com" })]; + + renderView(); + + await user.click( + screen.getByRole("button", { name: "Resend invitation to pending@example.com" }), + ); + const dialog = await screen.findByRole("alertdialog"); + await user.click(within(dialog).getByRole("button", { name: /resend invitation/i })); + + await waitFor(() => + expect(h.resend).toHaveBeenCalledWith({ workspaceSlug: "acme", inviteId: "inv-1" }), + ); + expect(h.createSuccessAlert).toHaveBeenCalledWith( + expect.objectContaining({ title: "Invitation resent" }), + ); + }); + + it("shows the friendly cooldown message in the modal and fires no success alert on a 429", async () => { + const user = userEvent.setup(); + h.invites.current = [pendingInvite({ email: "pending@example.com" })]; + // A 429 from the per-invite cooldown rejects the mutation; the hook then exposes + // the coded error (mirrored here via `error`), which the modal must render as the + // friendly mapped message rather than a raw string, and no success alert may fire. + h.resend.mockRejectedValue({ errorCode: "WORKSPACE_INVITE_RESEND_TOO_SOON" }); + h.resendError.current = { + message: "raw 429 detail", + errorCode: "WORKSPACE_INVITE_RESEND_TOO_SOON", + }; + + renderView(); + + await user.click( + screen.getByRole("button", { name: "Resend invitation to pending@example.com" }), + ); + const dialog = await screen.findByRole("alertdialog"); + await user.click(within(dialog).getByRole("button", { name: /resend invitation/i })); + + await waitFor(() => expect(h.resend).toHaveBeenCalled()); + expect(h.createSuccessAlert).not.toHaveBeenCalled(); + // The modal stays open and shows the friendly cooldown copy, not the raw string. + expect( + within(await screen.findByRole("alertdialog")).getByText( + /please wait a moment before resending/i, + ), + ).toBeInTheDocument(); + expect(screen.queryByText(/raw 429 detail/i)).not.toBeInTheDocument(); + }); + + it("confirms a successful leave by navigating to feeds with a persistent alert", async () => { + const user = userEvent.setup(); + // Self is the only member so the row shows "Leave team". Leaving navigates + // away, so the confirmation is carried in navigation state and raised as a + // persistent (dismissable) alert on the feeds page — a page-scoped alert + // raised here would unmount before it could be seen. + h.members.current = [{ userId: "self", role: "owner", discordUserId: "1111" }]; + + renderView(); + + await user.click(screen.getByRole("button", { name: /leave team/i })); + const dialog = await screen.findByRole("alertdialog"); + await user.click(within(dialog).getByRole("button", { name: /leave team/i })); + + await waitFor(() => expect(h.leave).toHaveBeenCalledWith("acme")); + expect(h.navigate).toHaveBeenCalledWith( + "/feeds", + expect.objectContaining({ + state: expect.objectContaining({ + alertTitle: "Left team", + alertDescription: expect.stringContaining("Acme"), + }), + }), + ); + }); + + it("confirms removing another member with a page success alert", async () => { + const user = userEvent.setup(); + + renderView(); + + const region = screen.getByRole("region", { name: "Members" }); + const otherRow = within(region) + .getAllByRole("listitem") + .find((el) => within(el).queryByText(/admin/i)) as HTMLElement; + await user.click(within(otherRow).getByRole("button", { name: /^remove/i })); + const dialog = await screen.findByRole("alertdialog"); + await user.click(within(dialog).getByRole("button", { name: /remove member/i })); + + await waitFor(() => + expect(h.removeMember).toHaveBeenCalledWith({ workspaceSlug: "acme", userId: "other" }), + ); + expect(h.createSuccessAlert).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Member removed", + // The sentence body (not just the title) confirms the outcome and names + // the workspace; it is what a screen reader announces as the message. + description: expect.stringContaining("Acme"), + }), + ); + }); + + it("shows the error in the dialog and fires no success alert when removing a member fails", async () => { + const user = userEvent.setup(); + // The remove mutation rejects; the hook then exposes the error (mirrored here + // via `error`), which the dialog must render, and no success alert may fire. + h.removeMember.mockRejectedValue({ message: "Cannot remove member" }); + h.removeError.current = { message: "Cannot remove member" }; + + renderView(); + + const region = screen.getByRole("region", { name: "Members" }); + const otherRow = within(region) + .getAllByRole("listitem") + .find((el) => within(el).queryByText(/admin/i)) as HTMLElement; + await user.click(within(otherRow).getByRole("button", { name: /^remove/i })); + const dialog = await screen.findByRole("alertdialog"); + await user.click(within(dialog).getByRole("button", { name: /remove member/i })); + + await waitFor(() => expect(h.removeMember).toHaveBeenCalled()); + expect(h.createSuccessAlert).not.toHaveBeenCalled(); + // The modal stays open and shows the failure message. + expect( + within(await screen.findByRole("alertdialog")).getByText(/cannot remove member/i), + ).toBeInTheDocument(); + }); +}); diff --git a/services/backend-api/client/src/features/workspaces/components/WorkspaceMembers/index.tsx b/services/backend-api/client/src/features/workspaces/components/WorkspaceMembers/index.tsx new file mode 100644 index 000000000..44c6aacb7 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/components/WorkspaceMembers/index.tsx @@ -0,0 +1,443 @@ +import { useState } from "react"; +import { + Box, + Button, + Heading, + HStack, + Input, + Skeleton, + Stack, + Text, + VisuallyHidden, +} from "@chakra-ui/react"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { Controller, useForm } from "react-hook-form"; +import { useNavigate } from "react-router-dom"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; +import { InferType, object, string } from "yup"; +import { InlineErrorAlert } from "@/components/InlineErrorAlert"; +import { PrimaryActionButton } from "@/components/PrimaryActionButton"; +import { DestructiveActionButton } from "@/components/DestructiveActionButton"; +import { ConfirmModal } from "@/components"; +import { Field } from "@/components/ui/field"; +import { pages } from "@/constants"; +import { usePageAlertContext } from "@/contexts/PageAlertContext"; +import { DiscordUsername, useUserMe } from "@/features/discordUser"; +import { getStandardErrorCodeMessage, ApiErrorCode } from "@/utils/getStandardErrorCodeMessage"; +import ApiAdapterError from "@/utils/ApiAdapterError"; +import { useCurrentWorkspace } from "../../contexts"; +import { + useCreateWorkspaceInvite, + useLeaveWorkspace, + useRemoveWorkspaceMember, + useResendWorkspaceInvite, + useRevokeWorkspaceInvite, + useWorkspaceInvitesForWorkspace, + useWorkspaceMembers, +} from "../../hooks"; +import { WorkspaceManagedInvite, WorkspaceMember } from "../../types"; + +dayjs.extend(relativeTime); + +// Prefer the standardized, friendly message for a known error code (e.g. +// CANNOT_REMOVE_LAST_OWNER) over the raw server string. Mirrors the InviteForm +// handler so every member-management mutation reports failures consistently. +const resolveErrorMessage = (err?: ApiAdapterError | null): string | undefined => { + if (!err) { + return undefined; + } + + const code = err.errorCode as ApiErrorCode | undefined; + + return code ? getStandardErrorCodeMessage(code) : err.message; +}; + +const inviteFormSchema = object({ + email: string() + .required("Email address is required") + .email("Enter a valid email address") + .max(254, "Email address is too long"), +}); + +type InviteFormData = InferType; + +const LIVE_STATUS_TEXT: Record = { + loading: "Loading members", + success: "Members loaded", +}; + +const InviteForm = ({ workspaceSlug }: { workspaceSlug: string }) => { + const { createSuccessAlert } = usePageAlertContext(); + const { mutateAsync } = useCreateWorkspaceInvite(); + const { + handleSubmit, + control, + reset, + setError, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: yupResolver(inviteFormSchema), + mode: "onSubmit", + defaultValues: { email: "" }, + }); + + const onSubmit = async ({ email }: InviteFormData) => { + try { + await mutateAsync({ workspaceSlug, email }); + reset({ email: "" }); + createSuccessAlert({ + title: "Invitation sent", + description: `An invitation email has been sent to ${email}.`, + }); + } catch (err) { + const apiError = err as ApiAdapterError; + const code = apiError?.errorCode as ApiErrorCode | undefined; + setError("email", { + message: code ? getStandardErrorCodeMessage(code) : (err as Error).message, + }); + } + }; + + return ( +
+ + + + ( + + )} + /> + + Send invite + + + + +
+ ); +}; + +const MemberRow = ({ + member, + isSelf, + canRemoveOthers, + workspaceSlug, +}: { + member: WorkspaceMember; + isSelf: boolean; + canRemoveOthers: boolean; + workspaceSlug: string; +}) => { + const navigate = useNavigate(); + const { createSuccessAlert } = usePageAlertContext(); + const workspace = useCurrentWorkspace(); + const [confirmOpen, setConfirmOpen] = useState(false); + const { + mutateAsync: removeMember, + error: removeError, + reset: resetRemove, + } = useRemoveWorkspaceMember(); + const { mutateAsync: leave, error: leaveError, reset: resetLeave } = useLeaveWorkspace(); + + const onConfirm = async () => { + if (isSelf) { + await leave(workspaceSlug); + // The feeds page reads this on mount and raises a persistent (dismissable) + // alert there; a page-scoped alert raised here would unmount on navigate. + navigate(pages.userFeeds(), { + state: { + alertTitle: "Left team", + alertDescription: workspace?.name + ? `You are no longer a member of ${workspace.name}.` + : undefined, + }, + }); + } else { + await removeMember({ workspaceSlug, userId: member.userId }); + createSuccessAlert({ + title: "Member removed", + description: workspace?.name + ? `This member no longer has access to ${workspace.name} and its feeds.` + : "This member no longer has access to this team and its feeds.", + }); + } + }; + + const error = isSelf ? leaveError : removeError; + const showAction = isSelf || canRemoveOthers; + + const onOpenChange = (open: boolean) => { + setConfirmOpen(open); + + if (!open) { + resetLeave(); + resetRemove(); + } + }; + + return ( + + + + + {isSelf ? " (you)" : ""} + + + {member.role} + + + {showAction && ( + setConfirmOpen(true)}> + {isSelf ? "Leave team" : "Remove"} + + )} + + + ); +}; + +const InviteRow = ({ + invite, + invitedByYou, + workspaceSlug, +}: { + invite: WorkspaceManagedInvite; + invitedByYou: boolean; + workspaceSlug: string; +}) => { + const { createSuccessAlert } = usePageAlertContext(); + const [revokeOpen, setRevokeOpen] = useState(false); + const [resendOpen, setResendOpen] = useState(false); + const { + mutateAsync: revoke, + error: revokeError, + reset: resetRevoke, + } = useRevokeWorkspaceInvite(); + const { + mutateAsync: resend, + error: resendError, + reset: resetResend, + } = useResendWorkspaceInvite(); + + const onRevokeOpenChange = (open: boolean) => { + setRevokeOpen(open); + + if (!open) { + resetRevoke(); + } + }; + + const onResendOpenChange = (open: boolean) => { + setResendOpen(open); + + if (!open) { + resetResend(); + } + }; + + return ( + + + {invite.email} + + Invited {invitedByYou ? "by you " : ""} + {dayjs(invite.createdAt).fromNow()} · {invite.role} + + + + + setRevokeOpen(true)} + > + Revoke + + + { + await resend({ workspaceSlug, inviteId: invite.id }); + createSuccessAlert({ + title: "Invitation resent", + description: `Another invitation email has been sent to ${invite.email}.`, + }); + }} + /> + { + await revoke({ workspaceSlug, inviteId: invite.id }); + }} + /> + + ); +}; + +/** + * The owner/admin member-management view. Lists current members with roles and + * outstanding pending invitations, and provides invite/revoke/remove/leave + * controls. Remove-other is owner-only (gated on the caller's role from the + * workspace detail); every member can leave. + */ +export const WorkspaceMembers = () => { + const workspace = useCurrentWorkspace(); + const { data: userMe } = useUserMe({ enabled: true }); + const { + members, + status: membersStatus, + error: membersError, + refetch: refetchMembers, + } = useWorkspaceMembers({ workspaceSlug: workspace?.slug }); + const { + invites, + status: invitesStatus, + error: invitesError, + refetch: refetchInvites, + } = useWorkspaceInvitesForWorkspace({ workspaceSlug: workspace?.slug }); + + if (!workspace) { + return null; + } + + const selfUserId = userMe?.result.id; + const canRemoveOthers = workspace.myRole === "owner"; + + return ( + + + {LIVE_STATUS_TEXT[membersStatus] ?? ""} + + Members + + + {membersStatus === "loading" && ( + + + + + )} + {membersStatus === "error" && ( + + + + + )} + {membersStatus === "success" && ( + + {members?.map((member) => ( + + ))} + + )} + + + + {invitesStatus === "loading" ? "Loading invitations" : ""} + + + Pending invitations + + {invitesStatus === "loading" && ( + + + + )} + {invitesStatus === "error" && ( + + + + + )} + {invitesStatus === "success" && + (invites?.length ? ( + + {invites.map((invite) => ( + + ))} + + ) : ( + There are no pending invitations. + ))} + + + ); +}; diff --git a/services/backend-api/client/src/features/workspaces/components/WorkspaceScopeLayout/WorkspaceScopeLayout.test.tsx b/services/backend-api/client/src/features/workspaces/components/WorkspaceScopeLayout/WorkspaceScopeLayout.test.tsx new file mode 100644 index 000000000..17f7ad82a --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/components/WorkspaceScopeLayout/WorkspaceScopeLayout.test.tsx @@ -0,0 +1,109 @@ +import "@testing-library/jest-dom"; +import { render, screen } from "@testing-library/react"; +import { ChakraProvider } from "@chakra-ui/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { system } from "@/utils/theme"; +import { WorkspaceScopeLayout } from "./index"; +import { useIsWorkspacesEnabled, useWorkspace } from "../../hooks"; + +vi.mock("../../hooks", () => ({ + useIsWorkspacesEnabled: vi.fn(), + useWorkspace: vi.fn(), +})); + +// Provide the current workspace without a real query; pass children through. +vi.mock("../../contexts", () => ({ + CurrentWorkspaceProvider: ({ children }: { children: React.ReactNode }) => children, +})); + +const renderLayout = () => + render( + + + + }> + SCOPED CONTENT} /> + + NOT FOUND PAGE} /> + + + , + ); + +describe("WorkspaceScopeLayout", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders the scoped page for a member of an enabled workspace", async () => { + vi.mocked(useIsWorkspacesEnabled).mockReturnValue({ + enabled: true, + status: "success", + } as never); + vi.mocked(useWorkspace).mockReturnValue({ + status: "success", + workspace: { + id: "workspace-1", + name: "Acme", + slug: "acme-marketing", + role: "admin", + }, + error: null, + } as never); + + renderLayout(); + + expect(await screen.findByText("SCOPED CONTENT")).toBeInTheDocument(); + }); + + it("redirects to not-found when the workspace is inaccessible (404)", () => { + vi.mocked(useIsWorkspacesEnabled).mockReturnValue({ + enabled: true, + status: "success", + } as never); + vi.mocked(useWorkspace).mockReturnValue({ + status: "error", + workspace: undefined, + error: { message: "Workspace not found" }, + } as never); + + renderLayout(); + + expect(screen.getByText("NOT FOUND PAGE")).toBeInTheDocument(); + expect(screen.queryByText("SCOPED CONTENT")).not.toBeInTheDocument(); + }); + + it("redirects to not-found when the workspaces feature is disabled", () => { + vi.mocked(useIsWorkspacesEnabled).mockReturnValue({ + enabled: false, + status: "success", + } as never); + vi.mocked(useWorkspace).mockReturnValue({ + status: "loading", + workspace: undefined, + error: null, + } as never); + + renderLayout(); + + expect(screen.getByText("NOT FOUND PAGE")).toBeInTheDocument(); + }); + + it("shows neither content nor not-found while the gate is resolving", () => { + vi.mocked(useIsWorkspacesEnabled).mockReturnValue({ + enabled: false, + status: "loading", + } as never); + vi.mocked(useWorkspace).mockReturnValue({ + status: "loading", + workspace: undefined, + error: null, + } as never); + + renderLayout(); + + expect(screen.queryByText("SCOPED CONTENT")).not.toBeInTheDocument(); + expect(screen.queryByText("NOT FOUND PAGE")).not.toBeInTheDocument(); + }); +}); diff --git a/services/backend-api/client/src/features/workspaces/components/WorkspaceScopeLayout/index.tsx b/services/backend-api/client/src/features/workspaces/components/WorkspaceScopeLayout/index.tsx new file mode 100644 index 000000000..8ee8b6ccd --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/components/WorkspaceScopeLayout/index.tsx @@ -0,0 +1,55 @@ +import { Suspense } from "react"; +import { Navigate, Outlet, useParams } from "react-router-dom"; +import { Spinner } from "@chakra-ui/react"; +import { pages } from "@/constants"; +import { FeedScopeProvider } from "@/features/feed"; +import RouteParams from "@/types/RouteParams"; +import { CurrentWorkspaceProvider } from "../../contexts"; +import { useIsWorkspacesEnabled, useWorkspace } from "../../hooks"; + +/** + * The `/workspaces/:workspaceSlug` layout route. Gates on the workspaces feature flag, + * validates `:workspaceSlug` via the authoritative per-workspace endpoint + * (`GET /workspaces/:workspaceSlug` returns 404 for a non-member or unknown slug), + * provides `CurrentWorkspaceContext`, and renders the scoped page via ``. + * Feature-disabled, error, or missing workspace all resolve to the not-found page. + * + * Validation uses the per-workspace query rather than the `useWorkspaces()` list because + * the list is cached with `keepPreviousData` and would briefly hold a stale + * (pre-creation) value right after creating a workspace — the fresh per-slug query has + * no such race. + */ +export const WorkspaceScopeLayout = () => { + const { workspaceSlug } = useParams(); + const { enabled, status: flagStatus } = useIsWorkspacesEnabled(); + const { workspace, status: workspaceStatus, error } = useWorkspace({ workspaceSlug: enabled ? workspaceSlug : undefined }); + + if (flagStatus === "loading") { + return ; + } + + if (!enabled) { + return ; + } + + if (workspaceStatus === "loading") { + return ; + } + + if (error || !workspace) { + return ; + } + + return ( + + {/* All feed queries, mutations, and links under a workspace route are + workspace-scoped via this provider, so the personal feeds UI is reused + verbatim. */} + + }> + + + + + ); +}; diff --git a/services/backend-api/client/src/features/workspaces/components/WorkspaceSettings/WorkspaceSettings.test.tsx b/services/backend-api/client/src/features/workspaces/components/WorkspaceSettings/WorkspaceSettings.test.tsx new file mode 100644 index 000000000..d4af416db --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/components/WorkspaceSettings/WorkspaceSettings.test.tsx @@ -0,0 +1,251 @@ +import "@testing-library/jest-dom"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ChakraProvider } from "@chakra-ui/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { system } from "@/utils/theme"; +import { WorkspaceSettings } from "./index"; +import { useCurrentWorkspace } from "../../contexts"; + +const h = vi.hoisted(() => ({ + update: vi.fn(), + updateError: { current: null as null | { message: string; errorCode?: string } }, + successAlert: vi.fn(), + navigate: vi.fn(), +})); + +vi.mock("../../contexts", () => ({ + useCurrentWorkspace: vi.fn(), +})); + +vi.mock("../../hooks", () => ({ + useUpdateWorkspace: () => ({ + mutateAsync: h.update, + status: "idle", + error: h.updateError.current, + reset: vi.fn(), + }), +})); + +vi.mock("@/contexts/PageAlertContext", () => ({ + usePageAlertContext: () => ({ createSuccessAlert: h.successAlert }), +})); + +vi.mock("react-router-dom", async (importOriginal) => { + const actual = await importOriginal(); + + return { ...actual, useNavigate: () => h.navigate }; +}); + +const asAdmin = () => + vi.mocked(useCurrentWorkspace).mockReturnValue({ + id: "workspace-1", + name: "Acme Marketing", + slug: "acme-marketing", + myRole: "admin", + } as never); + +const asOwner = () => + vi.mocked(useCurrentWorkspace).mockReturnValue({ + id: "workspace-1", + name: "Acme Marketing", + slug: "acme-marketing", + myRole: "owner", + } as never); + +const renderSettings = () => + render( + + + , + ); + +describe("WorkspaceSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + h.updateError.current = null; + }); + + it("lets an admin edit the workspace name and slug", () => { + asAdmin(); + + renderSettings(); + + const nameInput = screen.getByRole("textbox", { name: /team name/i }); + expect(nameInput).toHaveValue("Acme Marketing"); + expect(nameInput).not.toHaveAttribute("readonly"); + + const slugInput = screen.getByRole("textbox", { name: /team url/i }); + expect(slugInput).toHaveValue("acme-marketing"); + expect(slugInput).not.toHaveAttribute("readonly"); + + expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument(); + }); + + it("lets an owner edit the workspace name and slug", () => { + asOwner(); + + renderSettings(); + + const nameInput = screen.getByRole("textbox", { name: /team name/i }); + expect(nameInput).not.toHaveAttribute("readonly"); + + const slugInput = screen.getByRole("textbox", { name: /team url/i }); + expect(slugInput).not.toHaveAttribute("readonly"); + + expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument(); + }); + + it("disables Save until a field is changed", async () => { + asAdmin(); + + renderSettings(); + + expect(screen.getByRole("button", { name: "Save" })).toHaveAttribute("aria-disabled", "true"); + + fireEvent.change(screen.getByRole("textbox", { name: /team name/i }), { + target: { value: "Acme Renamed" }, + }); + + await waitFor(() => + expect(screen.getByRole("button", { name: "Save" })).not.toHaveAttribute("aria-disabled"), + ); + }); + + it("saves the new name and raises a success alert", async () => { + asAdmin(); + h.update.mockResolvedValue({ + result: { id: "workspace-1", name: "Acme Renamed", slug: "acme-marketing" }, + }); + + renderSettings(); + + fireEvent.change(screen.getByRole("textbox", { name: /team name/i }), { + target: { value: "Acme Renamed" }, + }); + const save = screen.getByRole("button", { name: "Save" }); + await waitFor(() => expect(save).not.toHaveAttribute("aria-disabled")); + await userEvent.click(save); + + await waitFor(() => + expect(h.update).toHaveBeenCalledWith({ + workspaceSlug: "acme-marketing", + details: { name: "Acme Renamed" }, + }), + ); + expect(h.successAlert).toHaveBeenCalled(); + expect(h.navigate).not.toHaveBeenCalled(); + }); + + it("shows a confirmation dialog when the slug is changed", async () => { + asAdmin(); + + renderSettings(); + + fireEvent.change(screen.getByRole("textbox", { name: /team url/i }), { + target: { value: "acme-new-slug" }, + }); + const save = screen.getByRole("button", { name: "Save" }); + await waitFor(() => expect(save).toBeEnabled()); + fireEvent.click(save); + + expect(await screen.findByRole("alertdialog")).toBeInTheDocument(); + expect(screen.getByText(/changing your team url/i)).toBeInTheDocument(); + }); + + it("navigates to the new slug after confirming a slug change", async () => { + asAdmin(); + h.update.mockResolvedValue({ + result: { id: "workspace-1", name: "Acme Marketing", slug: "acme-new-slug" }, + }); + + renderSettings(); + + fireEvent.change(screen.getByRole("textbox", { name: /team url/i }), { + target: { value: "acme-new-slug" }, + }); + const save = screen.getByRole("button", { name: "Save" }); + await waitFor(() => expect(save).toBeEnabled()); + fireEvent.click(save); + + const confirmBtn = await screen.findByRole("button", { + name: /yes, change url/i, + }); + fireEvent.click(confirmBtn); + + await waitFor(() => + expect(h.update).toHaveBeenCalledWith({ + workspaceSlug: "acme-marketing", + details: { slug: "acme-new-slug" }, + }), + ); + expect(h.navigate).toHaveBeenCalledWith("/workspaces/acme-new-slug/settings", { + replace: true, + }); + }); + + it("surfaces a save error in an inline alert", () => { + asAdmin(); + h.updateError.current = { message: "Name already taken" }; + + renderSettings(); + + expect(screen.getByText("Failed to save")).toBeInTheDocument(); + expect(screen.getByText("Name already taken")).toBeInTheDocument(); + }); + + it("renders the friendly mapped message for a coded save error, not the raw string", () => { + asAdmin(); + h.updateError.current = { + message: "raw server detail", + errorCode: "WORKSPACE_INSUFFICIENT_ROLE", + }; + + renderSettings(); + + expect(screen.getByText("Failed to save")).toBeInTheDocument(); + expect(screen.getByText(/you do not have permission/i)).toBeInTheDocument(); + expect(screen.queryByText(/raw server detail/i)).not.toBeInTheDocument(); + }); + + it("does not duplicate a slug-taken failure as a generic alert (shown on the field instead)", () => { + asAdmin(); + h.updateError.current = { message: "slug taken", errorCode: "WORKSPACE_SLUG_TAKEN" }; + + renderSettings(); + + expect(screen.queryByText("Failed to save")).not.toBeInTheDocument(); + }); + + it("shows the URL preview below the slug field for an admin", () => { + asAdmin(); + + renderSettings(); + + expect(screen.getByText(/url preview: \/workspaces\/acme-marketing/i)).toBeInTheDocument(); + }); + + it("does not show validation errors on edit/blur, only after Save is clicked", async () => { + asAdmin(); + + renderSettings(); + + const nameInput = screen.getByRole("textbox", { name: /team name/i }); + // Clearing a required field and blurring must NOT surface an error (mode: + // onSubmit). waitFor polls, so an async mode:"all" error would fail this. + fireEvent.change(nameInput, { target: { value: "" } }); + fireEvent.blur(nameInput); + + await waitFor(() => { + expect(screen.queryByText(/team name is required/i)).not.toBeInTheDocument(); + }); + + // The error only appears once the user submits. + const save = screen.getByRole("button", { name: "Save" }); + await waitFor(() => expect(save).not.toHaveAttribute("aria-disabled")); + await userEvent.click(save); + + expect(await screen.findByText(/team name is required/i)).toBeInTheDocument(); + expect(h.update).not.toHaveBeenCalled(); + }); +}); diff --git a/services/backend-api/client/src/features/workspaces/components/WorkspaceSettings/index.tsx b/services/backend-api/client/src/features/workspaces/components/WorkspaceSettings/index.tsx new file mode 100644 index 000000000..06d5b89fd --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/components/WorkspaceSettings/index.tsx @@ -0,0 +1,194 @@ +import { useEffect, useState } from "react"; +import { Box, Heading, Input, InputGroup, Stack } from "@chakra-ui/react"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { Controller, useForm } from "react-hook-form"; +import { useNavigate } from "react-router-dom"; +import { InferType, object, string } from "yup"; +import { InlineErrorAlert } from "@/components/InlineErrorAlert"; +import { PrimaryActionButton } from "@/components/PrimaryActionButton"; +import { ConfirmModal } from "@/components"; +import { usePageAlertContext } from "@/contexts/PageAlertContext"; +import { pages } from "@/constants"; +import { isReservedSlug, SLUG_PATTERN } from "@/utils/slugify"; +import ApiAdapterError from "@/utils/ApiAdapterError"; +import { ApiErrorCode, getStandardErrorCodeMessage } from "@/utils/getStandardErrorCodeMessage"; +import { Field } from "@/components/ui/field"; +import { useCurrentWorkspace } from "../../contexts"; +import { useUpdateWorkspace } from "../../hooks"; + +const formSchema = object({ + name: string().required("Team name is required").max(100, "Team name is too long"), + slug: string() + .required("Team URL is required") + .min(2, "Must be at least 2 characters") + .max(50, "Must be 50 characters or fewer") + .matches(SLUG_PATTERN, "Lowercase letters, numbers, and hyphens only (not at start or end)") + .test("not-reserved", "This URL is reserved. Please choose another.", (value) => + value ? !isReservedSlug(value) : true, + ), +}); + +type FormData = InferType; + +export const WorkspaceSettings = () => { + const workspace = useCurrentWorkspace(); + const navigate = useNavigate(); + const { createSuccessAlert } = usePageAlertContext(); + const { mutateAsync, error } = useUpdateWorkspace(); + const [confirmOpen, setConfirmOpen] = useState(false); + const [pendingData, setPendingData] = useState(null); + + const { + handleSubmit, + control, + reset, + watch, + setError, + formState: { errors, isSubmitting, isDirty }, + } = useForm({ + resolver: yupResolver(formSchema), + mode: "onSubmit", + defaultValues: { name: workspace?.name ?? "", slug: workspace?.slug ?? "" }, + }); + + const watchedSlug = watch("slug"); + + // Keyed on id as well as name/slug: resets baseline when switching workspaces. + useEffect(() => { + reset({ name: workspace?.name ?? "", slug: workspace?.slug ?? "" }); + }, [workspace?.id, workspace?.name, workspace?.slug]); + + if (!workspace) { + return null; + } + + const executeUpdate = async (data: FormData) => { + const details: { name?: string; slug?: string } = {}; + + if (data.name !== workspace.name) { + details.name = data.name; + } + + if (data.slug !== workspace.slug) { + details.slug = data.slug; + } + + if (!Object.keys(details).length) { + return; + } + + try { + const result = await mutateAsync({ workspaceSlug: workspace.slug, details }); + const newSlug = result.result.slug; + reset({ name: result.result.name, slug: newSlug }); + createSuccessAlert({ + title: "Team updated", + description: "Your changes have been saved.", + }); + + if (data.slug !== workspace.slug) { + navigate(pages.workspaceSettings(newSlug), { replace: true }); + } + } catch (err: unknown) { + const apiError = err as ApiAdapterError; + + if (apiError?.errorCode === ApiErrorCode.WORKSPACE_SLUG_TAKEN) { + setError("slug", { message: "This URL is already taken" }); + } else if (apiError?.errorCode === ApiErrorCode.WORKSPACE_SLUG_RESERVED) { + setError("slug", { message: "This URL is reserved. Please choose another." }); + } + // Other errors surfaced via `error` below + } + }; + + const onSubmit = (data: FormData) => { + if (!isDirty) { + return; + } + + if (data.slug !== workspace.slug) { + setPendingData(data); + setConfirmOpen(true); + } else { + executeUpdate(data); + } + }; + + return ( + + + Team settings + +
+ + + } + /> + + + + } + /> + + + {/* Slug-taken/reserved are already shown inline on the slug field, so + the generic alert covers only the remaining failures, using the + friendly mapped message rather than the raw server string. */} + {error && + error.errorCode !== ApiErrorCode.WORKSPACE_SLUG_TAKEN && + error.errorCode !== ApiErrorCode.WORKSPACE_SLUG_RESERVED && ( + + )} + + + Save + + + +
+ { + if (pendingData) { + await executeUpdate(pendingData); + setPendingData(null); + } + }} + /> +
+ ); +}; diff --git a/services/backend-api/client/src/features/workspaces/components/WorkspaceSwitcher/WorkspaceSwitcher.test.tsx b/services/backend-api/client/src/features/workspaces/components/WorkspaceSwitcher/WorkspaceSwitcher.test.tsx new file mode 100644 index 000000000..a8d3b8963 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/components/WorkspaceSwitcher/WorkspaceSwitcher.test.tsx @@ -0,0 +1,180 @@ +import "@testing-library/jest-dom"; +import { render, screen, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ChakraProvider } from "@chakra-ui/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { system } from "@/utils/theme"; +import { WorkspaceSwitcher } from "./index"; +import { useWorkspaces } from "../../hooks"; + +const h = vi.hoisted(() => ({ + navigate: vi.fn(), + params: { current: {} as { workspaceSlug?: string } }, +})); + +vi.mock("../../hooks", () => ({ + useWorkspaces: vi.fn(), +})); + +vi.mock("react-router-dom", async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + useNavigate: () => h.navigate, + useParams: () => h.params.current, + }; +}); + +const mockWorkspaces = (overrides: Record) => + vi.mocked(useWorkspaces).mockReturnValue({ + status: "success", + workspaces: [], + refetch: vi.fn(), + ...overrides, + } as never); + +const renderSwitcher = (onCreateWorkspace = vi.fn()) => { + render( + + + , + ); + + return onCreateWorkspace; +}; + +describe("WorkspaceSwitcher", () => { + beforeEach(() => { + vi.clearAllMocks(); + h.params.current = {}; + }); + + it("labels the trigger with Personal in personal scope", () => { + mockWorkspaces({ + workspaces: [{ id: "t1", name: "Acme", slug: "acme-marketing", role: "admin" }], + }); + + renderSwitcher(); + + expect( + screen.getByRole("button", { + name: "Switch team, current: Personal", + }), + ).toBeInTheDocument(); + }); + + it("labels the trigger with the active workspace name in workspace scope", () => { + h.params.current = { workspaceSlug: "acme-marketing" }; + mockWorkspaces({ + workspaces: [{ id: "t1", name: "Acme", slug: "acme-marketing", role: "admin" }], + }); + + renderSwitcher(); + + expect(screen.getByRole("button", { name: "Switch team, current: Acme" })).toBeInTheDocument(); + }); + + it("lists Personal plus each workspace, with the active one checked", async () => { + h.params.current = { workspaceSlug: "acme-marketing" }; + mockWorkspaces({ + workspaces: [ + { id: "t1", name: "Acme", slug: "acme-marketing", role: "admin" }, + { id: "t2", name: "Bookclub", slug: "bookclub", role: "owner" }, + ], + }); + + renderSwitcher(); + fireEvent.click(screen.getByRole("button", { name: /switch team/i })); + + expect(await screen.findByRole("menuitemradio", { name: "Personal" })).toHaveAttribute( + "aria-checked", + "false", + ); + expect(screen.getByRole("menuitemradio", { name: "Acme" })).toHaveAttribute( + "aria-checked", + "true", + ); + }); + + it("navigates to personal feeds when Personal is chosen", async () => { + h.params.current = { workspaceSlug: "acme-marketing" }; + mockWorkspaces({ + workspaces: [{ id: "t1", name: "Acme", slug: "acme-marketing", role: "admin" }], + }); + + renderSwitcher(); + await userEvent.click(screen.getByRole("button", { name: /switch team/i })); + await userEvent.click(await screen.findByRole("menuitemradio", { name: "Personal" })); + + expect(h.navigate).toHaveBeenCalledWith("/feeds"); + }); + + it("navigates to a workspace's feeds when that workspace is chosen", async () => { + mockWorkspaces({ + workspaces: [{ id: "t1", name: "Acme", slug: "acme-marketing", role: "admin" }], + }); + + renderSwitcher(); + await userEvent.click(screen.getByRole("button", { name: /switch team/i })); + await userEvent.click(await screen.findByRole("menuitemradio", { name: "Acme" })); + + expect(h.navigate).toHaveBeenCalledWith("/workspaces/acme-marketing/feeds"); + }); + + it("hides the workspace-settings item in personal scope", async () => { + mockWorkspaces({ + workspaces: [{ id: "t1", name: "Acme", slug: "acme-marketing", role: "admin" }], + }); + + renderSwitcher(); + fireEvent.click(screen.getByRole("button", { name: /switch team/i })); + + expect(await screen.findByRole("menuitem", { name: /create team/i })).toBeInTheDocument(); + expect(screen.queryByRole("menuitem", { name: /settings/i })).not.toBeInTheDocument(); + }); + + it("shows the workspace-settings item in workspace scope", async () => { + h.params.current = { workspaceSlug: "acme-marketing" }; + mockWorkspaces({ + workspaces: [{ id: "t1", name: "Acme", slug: "acme-marketing", role: "admin" }], + }); + + renderSwitcher(); + fireEvent.click(screen.getByRole("button", { name: /switch team/i })); + + expect(await screen.findByRole("menuitem", { name: /Acme settings/i })).toBeInTheDocument(); + }); + + it("opens the create-workspace dialog from the footer action", async () => { + mockWorkspaces({ + workspaces: [{ id: "t1", name: "Acme", slug: "acme-marketing", role: "admin" }], + }); + + const onCreateWorkspace = renderSwitcher(); + fireEvent.click(screen.getByRole("button", { name: /switch team/i })); + const items = await screen.findAllByRole("menuitem", { + name: /create team/i, + }); + fireEvent.click(items[items.length - 1]); + + expect(onCreateWorkspace).toHaveBeenCalled(); + }); + + it("surfaces a retryable error when the workspaces query fails", async () => { + const refetch = vi.fn(); + mockWorkspaces({ + status: "error", + workspaces: undefined, + error: { message: "Boom" }, + refetch, + }); + + renderSwitcher(); + fireEvent.click(screen.getByRole("button", { name: /switch team/i })); + + expect(await screen.findByRole("alert")).toHaveTextContent("Couldn't load teams"); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(refetch).toHaveBeenCalled(); + }); +}); diff --git a/services/backend-api/client/src/features/workspaces/components/WorkspaceSwitcher/index.tsx b/services/backend-api/client/src/features/workspaces/components/WorkspaceSwitcher/index.tsx new file mode 100644 index 000000000..5577d22b8 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/components/WorkspaceSwitcher/index.tsx @@ -0,0 +1,157 @@ +import { useMemo, useState } from "react"; +import { Box, Button, Input, Skeleton, Stack, Text, VisuallyHidden } from "@chakra-ui/react"; +import { FaChevronDown, FaGear, FaPlus } from "react-icons/fa6"; +import { useNavigate, useParams } from "react-router-dom"; +import { pages } from "@/constants"; +import RouteParams from "@/types/RouteParams"; +import { InlineErrorAlert } from "@/components/InlineErrorAlert"; +import { + MenuRoot, + MenuTrigger, + MenuContent, + MenuRadioItemGroup, + MenuRadioItem, + MenuSeparator, + MenuItem, +} from "@/components/ui/menu"; +import { useWorkspaces } from "../../hooks"; + +const PERSONAL_VALUE = "personal"; +const FILTER_THRESHOLD = 7; + +const LIVE_STATUS_TEXT: Record = { + loading: "Loading teams", + success: "Teams loaded", +}; + +/** + * Header workspace switcher. + * + * Active scope is derived from the route (`useParams().workspaceSlug`) + the workspaces + * list, NOT from `CurrentWorkspaceContext`: the header renders as a sibling of + * `WorkspaceScopeLayout`, so the context provider is not an ancestor here. + * + * Menu item values are workspace slugs (the URL segment), not workspace ids. + * + * The feature gate and the "render only when the user has >=1 workspace" count-gate + * (A2) are applied by `AppHeader`, the single decision point; this component + * still degrades safely if mounted in an empty/loading state. + */ +export const WorkspaceSwitcher = ({ onCreateWorkspace }: { onCreateWorkspace: () => void }) => { + const navigate = useNavigate(); + const { workspaceSlug } = useParams(); + const { workspaces, status, error, refetch } = useWorkspaces(); + const [filter, setFilter] = useState(""); + + const activeValue = workspaceSlug ?? PERSONAL_VALUE; + const activeName = useMemo(() => { + if (!workspaceSlug) { + return "Personal"; + } + + return workspaces?.find((t) => t.slug === workspaceSlug)?.name ?? "Team"; + }, [workspaceSlug, workspaces]); + + const visibleWorkspaces = useMemo(() => { + if (!workspaces || workspaces.length <= FILTER_THRESHOLD || !filter.trim()) { + return workspaces ?? []; + } + + const q = filter.trim().toLowerCase(); + + return workspaces.filter((t) => t.name.toLowerCase().includes(q)); + }, [workspaces, filter]); + + const showFilter = (workspaces?.length ?? 0) > FILTER_THRESHOLD; + + const handleSelect = (value: string) => { + if (value === PERSONAL_VALUE) { + navigate(pages.userFeeds()); + } else { + navigate(pages.userFeeds({ workspaceSlug: value })); + } + }; + + return ( + + + + + + {LIVE_STATUS_TEXT[status] ?? ""} + {showFilter && ( + + setFilter(e.target.value)} + onKeyDown={(e) => e.stopPropagation()} + /> + + )} + {status === "loading" && ( + + + + + )} + {status === "error" && ( + + + + + )} + {status === "success" && ( + handleSelect(e.value)} + > + Personal + {visibleWorkspaces.map((workspace) => ( + + + {workspace.name} + + + ))} + {showFilter && visibleWorkspaces.length === 0 && ( + + No matching teams. + + )} + + )} + + {workspaceSlug && ( + navigate(pages.workspaceSettings(workspaceSlug))} + > + + {activeName} settings + + )} + + + Create team + + + + ); +}; diff --git a/services/backend-api/client/src/features/workspaces/components/WorkspacesSettingsSection/WorkspacesSettingsSection.test.tsx b/services/backend-api/client/src/features/workspaces/components/WorkspacesSettingsSection/WorkspacesSettingsSection.test.tsx new file mode 100644 index 000000000..a6fc41938 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/components/WorkspacesSettingsSection/WorkspacesSettingsSection.test.tsx @@ -0,0 +1,129 @@ +import "@testing-library/jest-dom"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { ChakraProvider } from "@chakra-ui/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { system } from "@/utils/theme"; +import { WorkspacesSettingsSection } from "./index"; +import { useIsWorkspacesEnabled, useWorkspaces } from "../../hooks"; + +vi.mock("../../hooks", () => ({ + useIsWorkspacesEnabled: vi.fn(), + useWorkspaces: vi.fn(), +})); + +// The dialog has its own hook dependencies; stub it for these tests. +vi.mock("../CreateWorkspaceDialog", () => ({ + CreateWorkspaceDialog: ({ isOpen }: { isOpen: boolean }) => + isOpen ?
Create workspace dialog
: null, +})); + +// The pending-invitations list has its own hook dependencies and is covered by +// its own test; stub it so this test stays focused on the "Your teams" section. +vi.mock("../PendingInvitationsList", () => ({ + PendingInvitationsList: () => null, +})); + +const mockEnabled = (enabled: boolean) => + vi.mocked(useIsWorkspacesEnabled).mockReturnValue({ enabled } as never); + +const mockWorkspaces = (overrides: Record) => + vi.mocked(useWorkspaces).mockReturnValue({ + status: "success", + workspaces: [], + refetch: vi.fn(), + ...overrides, + } as never); + +const renderSection = () => + render( + + + + + , + ); + +describe("WorkspacesSettingsSection", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders nothing when the workspaces feature is disabled", () => { + mockEnabled(false); + mockWorkspaces({}); + + renderSection(); + + expect(screen.queryByRole("heading", { name: "Your teams" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /create team/i })).not.toBeInTheDocument(); + }); + + it("shows an empty state when the user is in no workspaces", () => { + mockEnabled(true); + mockWorkspaces({ workspaces: [] }); + + renderSection(); + + expect(screen.getByRole("heading", { name: "Your teams" })).toBeInTheDocument(); + expect(screen.getByText(/not in any teams yet/i)).toBeInTheDocument(); + }); + + it("lists each workspace with Open and Settings links using slug, and shows the role", () => { + mockEnabled(true); + mockWorkspaces({ + workspaces: [ + { id: "t1", name: "Acme", slug: "acme-marketing", role: "admin" }, + { id: "t2", name: "Bookclub", slug: "bookclub", role: "owner" }, + ], + }); + + renderSection(); + + const openLinks = screen.getAllByRole("link", { name: "Open" }); + expect(openLinks).toHaveLength(2); + expect(openLinks[0]).toHaveAttribute("href", "/workspaces/acme-marketing/feeds"); + + expect(screen.getByRole("link", { name: "Acme settings" })).toHaveAttribute( + "href", + "/workspaces/acme-marketing/settings", + ); + expect(screen.getByRole("link", { name: "Bookclub settings" })).toHaveAttribute( + "href", + "/workspaces/bookclub/settings", + ); + + expect(screen.getByText("admin")).toBeInTheDocument(); + expect(screen.getByText("owner")).toBeInTheDocument(); + + // No dead "Leave" action (no endpoint yet). + expect(screen.queryByRole("button", { name: /leave/i })).not.toBeInTheDocument(); + }); + + it("opens the create-workspace dialog from the section action", () => { + mockEnabled(true); + mockWorkspaces({ workspaces: [] }); + + renderSection(); + fireEvent.click(screen.getByRole("button", { name: /create team/i })); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + + it("surfaces a retryable error when the workspaces query fails", () => { + mockEnabled(true); + const refetch = vi.fn(); + mockWorkspaces({ + status: "error", + workspaces: undefined, + error: { message: "Boom" }, + refetch, + }); + + renderSection(); + + expect(screen.getByRole("alert")).toHaveTextContent("Failed to load your teams"); + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + expect(refetch).toHaveBeenCalled(); + }); +}); diff --git a/services/backend-api/client/src/features/workspaces/components/WorkspacesSettingsSection/index.tsx b/services/backend-api/client/src/features/workspaces/components/WorkspacesSettingsSection/index.tsx new file mode 100644 index 000000000..7ac931c59 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/components/WorkspacesSettingsSection/index.tsx @@ -0,0 +1,135 @@ +import { + Badge, + Box, + Button, + Flex, + Heading, + HStack, + Link as ChakraLink, + Separator, + Skeleton, + Stack, + Text, + useDisclosure, + VisuallyHidden, +} from "@chakra-ui/react"; +import { FaGear, FaPlus } from "react-icons/fa6"; +import { Link as RouterLink } from "react-router-dom"; +import { pages } from "@/constants"; +import { InlineErrorAlert } from "@/components/InlineErrorAlert"; +import { PrimaryActionButton } from "@/components/PrimaryActionButton"; +import { useIsWorkspacesEnabled, useWorkspaces } from "../../hooks"; +import { CreateWorkspaceDialog } from "../CreateWorkspaceDialog"; +import { PendingInvitationsList } from "../PendingInvitationsList"; + +const LIVE_STATUS_TEXT: Record = { + loading: "Loading your teams", + success: "Teams loaded", +}; + +/** + * "Your workspaces" section for the Account Settings page. Renders only when the + * workspaces feature is enabled. It is an overview + entry point, not a management + * surface — per-workspace management lives on `/workspaces/:workspaceSlug/settings`. No "leave" + * action is shown: no leave endpoint exists yet, and dead/disabled UI that + * implies an action works is avoided. + */ +export const WorkspacesSettingsSection = () => { + const { enabled } = useIsWorkspacesEnabled(); + const { workspaces, status, error, refetch } = useWorkspaces({ enabled }); + const createDisclosure = useDisclosure(); + + if (!enabled) { + return null; + } + + return ( + <> + + + + + Your teams + + + + Create team + + + + Teams let you collaborate on feeds with others. Open a team to work in it, or change its + settings. + + {LIVE_STATUS_TEXT[status] ?? ""} + {status === "loading" && ( + + + + + )} + {status === "error" && ( + + + + + )} + {status === "success" && workspaces?.length === 0 && ( + You're not in any teams yet. Create one to get started. + )} + {status === "success" && !!workspaces?.length && ( + + {workspaces.map((workspace) => ( + + + {workspace.name} + + {workspace.role} + + + + + + + Settings + + + + + ))} + + )} + + + + + ); +}; diff --git a/services/backend-api/client/src/features/workspaces/components/index.ts b/services/backend-api/client/src/features/workspaces/components/index.ts new file mode 100644 index 000000000..dc8dd3e5a --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/components/index.ts @@ -0,0 +1,9 @@ +export * from "./WorkspaceSwitcher"; +export * from "./WorkspacesSettingsSection"; +export * from "./VerifyEmailStep"; +export * from "./CreateWorkspaceDialog"; +export * from "./WorkspaceSettings"; +export * from "./WorkspaceMembers"; +export * from "./WorkspaceScopeLayout"; +export * from "./InvitePage"; +export * from "./PendingInvitationsList"; diff --git a/services/backend-api/client/src/features/workspaces/contexts/CurrentWorkspaceContext.tsx b/services/backend-api/client/src/features/workspaces/contexts/CurrentWorkspaceContext.tsx new file mode 100644 index 000000000..7cae61b1e --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/contexts/CurrentWorkspaceContext.tsx @@ -0,0 +1,61 @@ +import { ReactElement, ReactNode, createContext, useContext, useMemo } from "react"; +import { Spinner } from "@chakra-ui/react"; +import { ErrorAlert } from "@/components/ErrorAlert"; +import { useWorkspace } from "../hooks"; +import { WorkspaceRole } from "../types"; + +export interface CurrentWorkspace { + id: string; + name: string; + slug: string; + myRole: WorkspaceRole; + // The workspace's feed limit, used to render the feed-limit bar. + maxFeeds?: number; +} + +/** + * `null` is the personal scope — no workspace is current. Mirrors `UserFeedContext` + * but, unlike it, `useCurrentWorkspace()` does not throw outside a provider: + * personal-scope pages legitimately render with no current workspace. + */ +const CurrentWorkspaceContext = createContext(null); + +export const CurrentWorkspaceProvider = ({ + workspaceSlug, + children, + loadingComponent, + errorComponent, +}: { + workspaceSlug?: string; + children: ReactNode; + loadingComponent?: ReactElement; + errorComponent?: ReactElement; +}) => { + const { workspace, status, error } = useWorkspace({ workspaceSlug }); + + const value = useMemo( + () => + workspace + ? { + id: workspace.id, + name: workspace.name, + slug: workspace.slug, + myRole: workspace.role, + maxFeeds: workspace.maxFeeds, + } + : null, + [workspace], + ); + + if (error) { + return errorComponent || ; + } + + if (status === "loading" || !workspace) { + return loadingComponent || ; + } + + return {children}; +}; + +export const useCurrentWorkspace = () => useContext(CurrentWorkspaceContext); diff --git a/services/backend-api/client/src/features/workspaces/contexts/index.ts b/services/backend-api/client/src/features/workspaces/contexts/index.ts new file mode 100644 index 000000000..8a46f09ef --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/contexts/index.ts @@ -0,0 +1 @@ +export * from "./CurrentWorkspaceContext"; diff --git a/services/backend-api/client/src/features/workspaces/hooks/index.ts b/services/backend-api/client/src/features/workspaces/hooks/index.ts new file mode 100644 index 000000000..760d3188d --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/hooks/index.ts @@ -0,0 +1,19 @@ +export * from "./useWorkspaces"; +export * from "./useWorkspace"; +export * from "./useCreateWorkspace"; +export * from "./useUpdateWorkspace"; +export * from "./useSendEmailVerification"; +export * from "./useSendInviteVerification"; +export * from "./useConfirmEmailVerification"; +export * from "./useIsWorkspacesEnabled"; +export * from "./useWorkspaceInvite"; +export * from "./useMyWorkspaceInvites"; +export * from "./useAcceptWorkspaceInvite"; +export * from "./useDeclineWorkspaceInvite"; +export * from "./useWorkspaceMembers"; +export * from "./useWorkspaceInvitesForWorkspace"; +export * from "./useCreateWorkspaceInvite"; +export * from "./useResendWorkspaceInvite"; +export * from "./useRevokeWorkspaceInvite"; +export * from "./useRemoveWorkspaceMember"; +export * from "./useLeaveWorkspace"; diff --git a/services/backend-api/client/src/features/workspaces/hooks/useAcceptWorkspaceInvite.tsx b/services/backend-api/client/src/features/workspaces/hooks/useAcceptWorkspaceInvite.tsx new file mode 100644 index 000000000..52a51ebca --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/hooks/useAcceptWorkspaceInvite.tsx @@ -0,0 +1,29 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import ApiAdapterError from "@/utils/ApiAdapterError"; +import { acceptWorkspaceInvite, AcceptWorkspaceInviteOutput } from "../api"; + +export const useAcceptWorkspaceInvite = () => { + const queryClient = useQueryClient(); + + const { mutateAsync, status, error, reset } = useMutation< + AcceptWorkspaceInviteOutput, + ApiAdapterError, + string + >( + (inviteId) => acceptWorkspaceInvite(inviteId), + { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["workspaces"], exact: false }); + queryClient.invalidateQueries({ queryKey: ["workspace-invites"], exact: false }); + queryClient.invalidateQueries({ queryKey: ["workspace-invite"], exact: false }); + }, + }, + ); + + return { + mutateAsync, + status, + error, + reset, + }; +}; diff --git a/services/backend-api/client/src/features/workspaces/hooks/useConfirmEmailVerification.tsx b/services/backend-api/client/src/features/workspaces/hooks/useConfirmEmailVerification.tsx new file mode 100644 index 000000000..3b372dd39 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/hooks/useConfirmEmailVerification.tsx @@ -0,0 +1,24 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import ApiAdapterError from "@/utils/ApiAdapterError"; +import { confirmEmailVerification, ConfirmEmailVerificationInput } from "../api"; + +export const useConfirmEmailVerification = () => { + const queryClient = useQueryClient(); + + const { mutateAsync, status, error, reset } = useMutation< + void, + ApiAdapterError, + ConfirmEmailVerificationInput + >((input) => confirmEmailVerification(input), { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["user-me"] }); + }, + }); + + return { + mutateAsync, + status, + error, + reset, + }; +}; diff --git a/services/backend-api/client/src/features/workspaces/hooks/useCreateWorkspace.tsx b/services/backend-api/client/src/features/workspaces/hooks/useCreateWorkspace.tsx new file mode 100644 index 000000000..fea31ea4a --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/hooks/useCreateWorkspace.tsx @@ -0,0 +1,27 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import ApiAdapterError from "@/utils/ApiAdapterError"; +import { createWorkspace, CreateWorkspaceInput, CreateWorkspaceOutput } from "../api"; + +export const useCreateWorkspace = () => { + const queryClient = useQueryClient(); + + const { mutateAsync, status, error, reset } = useMutation< + CreateWorkspaceOutput, + ApiAdapterError, + CreateWorkspaceInput + >((details) => createWorkspace(details), { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["workspaces"], + exact: false, + }); + }, + }); + + return { + mutateAsync, + status, + error, + reset, + }; +}; diff --git a/services/backend-api/client/src/features/workspaces/hooks/useCreateWorkspaceInvite.tsx b/services/backend-api/client/src/features/workspaces/hooks/useCreateWorkspaceInvite.tsx new file mode 100644 index 000000000..7e4ae430e --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/hooks/useCreateWorkspaceInvite.tsx @@ -0,0 +1,28 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import ApiAdapterError from "@/utils/ApiAdapterError"; +import { + createWorkspaceInvite, + CreateWorkspaceInviteInput, + CreateWorkspaceInviteOutput, +} from "../api"; + +export const useCreateWorkspaceInvite = () => { + const queryClient = useQueryClient(); + + const { mutateAsync, status, error, reset } = useMutation< + CreateWorkspaceInviteOutput, + ApiAdapterError, + CreateWorkspaceInviteInput + >((input) => createWorkspaceInvite(input), { + onSuccess: (_data, { workspaceSlug }) => { + queryClient.invalidateQueries({ queryKey: ["workspace-invites-list", { workspaceSlug }] }); + }, + }); + + return { + mutateAsync, + status, + error, + reset, + }; +}; diff --git a/services/backend-api/client/src/features/workspaces/hooks/useDeclineWorkspaceInvite.tsx b/services/backend-api/client/src/features/workspaces/hooks/useDeclineWorkspaceInvite.tsx new file mode 100644 index 000000000..7f65e36b8 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/hooks/useDeclineWorkspaceInvite.tsx @@ -0,0 +1,24 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import ApiAdapterError from "@/utils/ApiAdapterError"; +import { declineWorkspaceInvite } from "../api"; + +export const useDeclineWorkspaceInvite = () => { + const queryClient = useQueryClient(); + + const { mutateAsync, status, error, reset } = useMutation( + (inviteId) => declineWorkspaceInvite(inviteId), + { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["workspace-invites"], exact: false }); + queryClient.invalidateQueries({ queryKey: ["workspace-invite"], exact: false }); + }, + }, + ); + + return { + mutateAsync, + status, + error, + reset, + }; +}; diff --git a/services/backend-api/client/src/features/workspaces/hooks/useIsWorkspacesEnabled.test.tsx b/services/backend-api/client/src/features/workspaces/hooks/useIsWorkspacesEnabled.test.tsx new file mode 100644 index 000000000..6c61cc597 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/hooks/useIsWorkspacesEnabled.test.tsx @@ -0,0 +1,58 @@ +import { renderHook } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { useIsWorkspacesEnabled } from "./useIsWorkspacesEnabled"; +import { useUserMe } from "@/features/discordUser"; + +vi.mock("@/features/discordUser", () => ({ + useUserMe: vi.fn(), +})); + +const mockUserMe = (capabilities: boolean | undefined, featureFlag: boolean | undefined) => + vi.mocked(useUserMe).mockReturnValue({ + data: { + result: { + capabilities: capabilities === undefined ? undefined : { workspaces: capabilities }, + featureFlags: featureFlag === undefined ? undefined : { workspaces: featureFlag }, + }, + }, + status: "success", + fetchStatus: "idle", + } as never); + +describe("useIsWorkspacesEnabled", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("is enabled only when both the capability and the per-user flag are true", () => { + mockUserMe(true, true); + expect(renderHook(() => useIsWorkspacesEnabled()).result.current.enabled).toBe(true); + }); + + it("is disabled when the deployment capability is off", () => { + mockUserMe(false, true); + expect(renderHook(() => useIsWorkspacesEnabled()).result.current.enabled).toBe(false); + }); + + it("is disabled when the per-user flag is off", () => { + mockUserMe(true, false); + expect(renderHook(() => useIsWorkspacesEnabled()).result.current.enabled).toBe(false); + }); + + it("is disabled when both are off", () => { + mockUserMe(false, false); + expect(renderHook(() => useIsWorkspacesEnabled()).result.current.enabled).toBe(false); + }); + + it("is disabled while the user is still loading", () => { + vi.mocked(useUserMe).mockReturnValue({ + data: undefined, + status: "loading", + fetchStatus: "fetching", + } as never); + + const { result } = renderHook(() => useIsWorkspacesEnabled()); + expect(result.current.enabled).toBe(false); + expect(result.current.status).toBe("loading"); + }); +}); diff --git a/services/backend-api/client/src/features/workspaces/hooks/useIsWorkspacesEnabled.tsx b/services/backend-api/client/src/features/workspaces/hooks/useIsWorkspacesEnabled.tsx new file mode 100644 index 000000000..406a4fd28 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/hooks/useIsWorkspacesEnabled.tsx @@ -0,0 +1,15 @@ +import { useUserMe } from "@/features/discordUser"; + +// Both layers must be true: the deployment capability and the per-user rollout +// flag. UX gate only — the backend re-enforces both. +export const useIsWorkspacesEnabled = () => { + const { data, status, fetchStatus } = useUserMe(); + + const enabled = !!(data?.result.capabilities?.workspaces && data?.result.featureFlags?.workspaces); + + return { + enabled, + status, + fetchStatus, + }; +}; diff --git a/services/backend-api/client/src/features/workspaces/hooks/useLeaveWorkspace.tsx b/services/backend-api/client/src/features/workspaces/hooks/useLeaveWorkspace.tsx new file mode 100644 index 000000000..e01c25e0e --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/hooks/useLeaveWorkspace.tsx @@ -0,0 +1,24 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import ApiAdapterError from "@/utils/ApiAdapterError"; +import { leaveWorkspace } from "../api"; + +export const useLeaveWorkspace = () => { + const queryClient = useQueryClient(); + + const { mutateAsync, status, error, reset } = useMutation( + (workspaceSlug) => leaveWorkspace(workspaceSlug), + { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["workspaces"], exact: false }); + queryClient.invalidateQueries({ queryKey: ["workspace-members"], exact: false }); + }, + }, + ); + + return { + mutateAsync, + status, + error, + reset, + }; +}; diff --git a/services/backend-api/client/src/features/workspaces/hooks/useMyWorkspaceInvites.tsx b/services/backend-api/client/src/features/workspaces/hooks/useMyWorkspaceInvites.tsx new file mode 100644 index 000000000..0a298bad1 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/hooks/useMyWorkspaceInvites.tsx @@ -0,0 +1,24 @@ +import { useQuery } from "@tanstack/react-query"; +import ApiAdapterError from "@/utils/ApiAdapterError"; +import { getMyWorkspaceInvites, GetMyWorkspaceInvitesOutput } from "../api"; + +interface Props { + enabled?: boolean; +} + +export const useMyWorkspaceInvites = (props?: Props) => { + const { data, status, error, refetch } = useQuery( + ["workspace-invites", "@me"], + async () => getMyWorkspaceInvites(), + { + enabled: props?.enabled, + }, + ); + + return { + invites: data?.result, + status, + error, + refetch, + }; +}; diff --git a/services/backend-api/client/src/features/workspaces/hooks/useRemoveWorkspaceMember.tsx b/services/backend-api/client/src/features/workspaces/hooks/useRemoveWorkspaceMember.tsx new file mode 100644 index 000000000..5f73257c2 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/hooks/useRemoveWorkspaceMember.tsx @@ -0,0 +1,24 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import ApiAdapterError from "@/utils/ApiAdapterError"; +import { removeWorkspaceMember, RemoveWorkspaceMemberInput } from "../api"; + +export const useRemoveWorkspaceMember = () => { + const queryClient = useQueryClient(); + + const { mutateAsync, status, error, reset } = useMutation< + void, + ApiAdapterError, + RemoveWorkspaceMemberInput + >((input) => removeWorkspaceMember(input), { + onSuccess: (_data, { workspaceSlug }) => { + queryClient.invalidateQueries({ queryKey: ["workspace-members", { workspaceSlug }] }); + }, + }); + + return { + mutateAsync, + status, + error, + reset, + }; +}; diff --git a/services/backend-api/client/src/features/workspaces/hooks/useResendWorkspaceInvite.tsx b/services/backend-api/client/src/features/workspaces/hooks/useResendWorkspaceInvite.tsx new file mode 100644 index 000000000..4f069e322 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/hooks/useResendWorkspaceInvite.tsx @@ -0,0 +1,18 @@ +import { useMutation } from "@tanstack/react-query"; +import ApiAdapterError from "@/utils/ApiAdapterError"; +import { resendWorkspaceInvite, ResendWorkspaceInviteInput } from "../api"; + +export const useResendWorkspaceInvite = () => { + const { mutateAsync, status, error, reset } = useMutation< + void, + ApiAdapterError, + ResendWorkspaceInviteInput + >((input) => resendWorkspaceInvite(input)); + + return { + mutateAsync, + status, + error, + reset, + }; +}; diff --git a/services/backend-api/client/src/features/workspaces/hooks/useRevokeWorkspaceInvite.tsx b/services/backend-api/client/src/features/workspaces/hooks/useRevokeWorkspaceInvite.tsx new file mode 100644 index 000000000..9fb251f6e --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/hooks/useRevokeWorkspaceInvite.tsx @@ -0,0 +1,24 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import ApiAdapterError from "@/utils/ApiAdapterError"; +import { revokeWorkspaceInvite, RevokeWorkspaceInviteInput } from "../api"; + +export const useRevokeWorkspaceInvite = () => { + const queryClient = useQueryClient(); + + const { mutateAsync, status, error, reset } = useMutation< + void, + ApiAdapterError, + RevokeWorkspaceInviteInput + >((input) => revokeWorkspaceInvite(input), { + onSuccess: (_data, { workspaceSlug }) => { + queryClient.invalidateQueries({ queryKey: ["workspace-invites-list", { workspaceSlug }] }); + }, + }); + + return { + mutateAsync, + status, + error, + reset, + }; +}; diff --git a/services/backend-api/client/src/features/workspaces/hooks/useSendEmailVerification.tsx b/services/backend-api/client/src/features/workspaces/hooks/useSendEmailVerification.tsx new file mode 100644 index 000000000..685ecef7e --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/hooks/useSendEmailVerification.tsx @@ -0,0 +1,18 @@ +import { useMutation } from "@tanstack/react-query"; +import ApiAdapterError from "@/utils/ApiAdapterError"; +import { sendEmailVerification, SendEmailVerificationInput } from "../api"; + +export const useSendEmailVerification = () => { + const { mutateAsync, status, error, reset } = useMutation< + void, + ApiAdapterError, + SendEmailVerificationInput + >((input) => sendEmailVerification(input)); + + return { + mutateAsync, + status, + error, + reset, + }; +}; diff --git a/services/backend-api/client/src/features/workspaces/hooks/useSendInviteVerification.tsx b/services/backend-api/client/src/features/workspaces/hooks/useSendInviteVerification.tsx new file mode 100644 index 000000000..a0d6eccce --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/hooks/useSendInviteVerification.tsx @@ -0,0 +1,18 @@ +import { useMutation } from "@tanstack/react-query"; +import ApiAdapterError from "@/utils/ApiAdapterError"; +import { sendInviteVerification, SendInviteVerificationInput } from "../api"; + +export const useSendInviteVerification = () => { + const { mutateAsync, status, error, reset } = useMutation< + void, + ApiAdapterError, + SendInviteVerificationInput + >((input) => sendInviteVerification(input)); + + return { + mutateAsync, + status, + error, + reset, + }; +}; diff --git a/services/backend-api/client/src/features/workspaces/hooks/useUpdateWorkspace.tsx b/services/backend-api/client/src/features/workspaces/hooks/useUpdateWorkspace.tsx new file mode 100644 index 000000000..b0061724e --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/hooks/useUpdateWorkspace.tsx @@ -0,0 +1,25 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import ApiAdapterError from "@/utils/ApiAdapterError"; +import { updateWorkspace, UpdateWorkspaceInput, UpdateWorkspaceOutput } from "../api"; + +export const useUpdateWorkspace = () => { + const queryClient = useQueryClient(); + + const { mutateAsync, status, error, reset } = useMutation< + UpdateWorkspaceOutput, + ApiAdapterError, + UpdateWorkspaceInput + >((input) => updateWorkspace(input), { + onSuccess: (_data, { workspaceSlug }) => { + queryClient.invalidateQueries({ queryKey: ["workspaces"], exact: false }); + queryClient.invalidateQueries({ queryKey: ["workspace", { workspaceSlug }] }); + }, + }); + + return { + mutateAsync, + status, + error, + reset, + }; +}; diff --git a/services/backend-api/client/src/features/workspaces/hooks/useWorkspace.tsx b/services/backend-api/client/src/features/workspaces/hooks/useWorkspace.tsx new file mode 100644 index 000000000..3cc0131c2 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/hooks/useWorkspace.tsx @@ -0,0 +1,34 @@ +import { useQuery } from "@tanstack/react-query"; +import ApiAdapterError from "@/utils/ApiAdapterError"; +import { getWorkspace, GetWorkspaceOutput } from "../api"; + +interface Props { + workspaceSlug?: string; +} + +export const useWorkspace = ({ workspaceSlug }: Props) => { + const { data, status, error, fetchStatus, refetch } = useQuery< + GetWorkspaceOutput, + ApiAdapterError | Error + >( + ["workspace", { workspaceSlug }], + async () => { + if (!workspaceSlug) { + throw new Error("Missing workspace selection"); + } + + return getWorkspace({ workspaceSlug }); + }, + { + enabled: !!workspaceSlug, + }, + ); + + return { + workspace: data?.result, + status, + error, + fetchStatus, + refetch, + }; +}; diff --git a/services/backend-api/client/src/features/workspaces/hooks/useWorkspaceInvite.tsx b/services/backend-api/client/src/features/workspaces/hooks/useWorkspaceInvite.tsx new file mode 100644 index 000000000..824e95ae1 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/hooks/useWorkspaceInvite.tsx @@ -0,0 +1,25 @@ +import { useQuery } from "@tanstack/react-query"; +import ApiAdapterError from "@/utils/ApiAdapterError"; +import { getWorkspaceInvite, GetWorkspaceInviteOutput } from "../api"; + +interface Props { + inviteId?: string; + enabled?: boolean; +} + +export const useWorkspaceInvite = ({ inviteId, enabled }: Props) => { + const { data, status, error, refetch } = useQuery( + ["workspace-invite", inviteId], + async () => getWorkspaceInvite(inviteId as string), + { + enabled: enabled !== false && !!inviteId, + }, + ); + + return { + invite: data?.result, + status, + error, + refetch, + }; +}; diff --git a/services/backend-api/client/src/features/workspaces/hooks/useWorkspaceInvitesForWorkspace.tsx b/services/backend-api/client/src/features/workspaces/hooks/useWorkspaceInvitesForWorkspace.tsx new file mode 100644 index 000000000..a8eef33b4 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/hooks/useWorkspaceInvitesForWorkspace.tsx @@ -0,0 +1,31 @@ +import { useQuery } from "@tanstack/react-query"; +import ApiAdapterError from "@/utils/ApiAdapterError"; +import { getWorkspaceInvites, GetWorkspaceInvitesOutput } from "../api"; + +interface Props { + workspaceSlug?: string; + enabled?: boolean; +} + +export const useWorkspaceInvitesForWorkspace = ({ workspaceSlug, enabled }: Props) => { + const { data, status, error, refetch } = useQuery( + ["workspace-invites-list", { workspaceSlug }], + async () => { + if (!workspaceSlug) { + throw new Error("Missing workspace selection"); + } + + return getWorkspaceInvites(workspaceSlug); + }, + { + enabled: enabled !== false && !!workspaceSlug, + }, + ); + + return { + invites: data?.result, + status, + error, + refetch, + }; +}; diff --git a/services/backend-api/client/src/features/workspaces/hooks/useWorkspaceMembers.tsx b/services/backend-api/client/src/features/workspaces/hooks/useWorkspaceMembers.tsx new file mode 100644 index 000000000..b2b123fc2 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/hooks/useWorkspaceMembers.tsx @@ -0,0 +1,31 @@ +import { useQuery } from "@tanstack/react-query"; +import ApiAdapterError from "@/utils/ApiAdapterError"; +import { getWorkspaceMembers, GetWorkspaceMembersOutput } from "../api"; + +interface Props { + workspaceSlug?: string; + enabled?: boolean; +} + +export const useWorkspaceMembers = ({ workspaceSlug, enabled }: Props) => { + const { data, status, error, refetch } = useQuery( + ["workspace-members", { workspaceSlug }], + async () => { + if (!workspaceSlug) { + throw new Error("Missing workspace selection"); + } + + return getWorkspaceMembers(workspaceSlug); + }, + { + enabled: enabled !== false && !!workspaceSlug, + }, + ); + + return { + members: data?.result, + status, + error, + refetch, + }; +}; diff --git a/services/backend-api/client/src/features/workspaces/hooks/useWorkspaces.tsx b/services/backend-api/client/src/features/workspaces/hooks/useWorkspaces.tsx new file mode 100644 index 000000000..138f7e5f0 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/hooks/useWorkspaces.tsx @@ -0,0 +1,26 @@ +import { useQuery } from "@tanstack/react-query"; +import ApiAdapterError from "@/utils/ApiAdapterError"; +import { getWorkspaces, GetWorkspacesOutput } from "../api"; + +interface Props { + enabled?: boolean; +} + +export const useWorkspaces = (props?: Props) => { + const { data, status, error, fetchStatus, refetch } = useQuery( + ["workspaces"], + async () => getWorkspaces(), + { + enabled: props?.enabled, + keepPreviousData: true, + }, + ); + + return { + workspaces: data?.result, + status, + error, + fetchStatus, + refetch, + }; +}; diff --git a/services/backend-api/client/src/features/workspaces/index.ts b/services/backend-api/client/src/features/workspaces/index.ts new file mode 100644 index 000000000..2e00f7911 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/index.ts @@ -0,0 +1,5 @@ +export * from "./api"; +export * from "./types"; +export * from "./hooks"; +export * from "./contexts"; +export * from "./components"; diff --git a/services/backend-api/client/src/features/workspaces/types/index.ts b/services/backend-api/client/src/features/workspaces/types/index.ts new file mode 100644 index 000000000..3b81767f0 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/types/index.ts @@ -0,0 +1,122 @@ +import { boolean, InferType, mixed, number, object, string } from "yup"; + +export const WORKSPACE_ROLES = ["owner", "admin"] as const; + +export type WorkspaceRole = (typeof WORKSPACE_ROLES)[number]; + +/** + * A workspace as seen by a member: the workspace's identity plus the caller's role in it. + * Returned by both the list (`GET /workspaces`) and detail (`GET /workspaces/:workspaceId`) + * endpoints. + */ +export const WorkspaceSchema = object({ + id: string().required(), + name: string().required(), + slug: string().required(), + role: mixed() + .oneOf([...WORKSPACE_ROLES]) + .required(), + // The workspace's feed limit. Present on the detail endpoint; the list endpoint + // omits it, so it is optional. + maxFeeds: number().optional(), +}).required(); + +export type Workspace = InferType; + +/** + * The fuller workspace document returned by create/update (`POST`/`PATCH /workspaces`), + * which has no caller role attached. + */ +export const WorkspaceDetailsSchema = object({ + id: string().required(), + name: string().required(), + slug: string().required(), + createdByUserId: string().required(), + createdAt: string().required(), + updatedAt: string().required(), +}).required(); + +export type WorkspaceDetails = InferType; + +/** + * The invitee-facing view of a pending workspace invitation. Returned by both the + * single-invitation landing endpoint (`GET /workspace-invites/:inviteId`) and the + * caller's pending-invitations list (`GET /workspace-invites/@me`). The invited + * `email` is resolved server-side from the stored invitation, never from the URL. + */ +export const WorkspaceInviteSchema = object({ + id: string().required(), + email: string().required(), + role: mixed() + .oneOf([...WORKSPACE_ROLES]) + .required(), + workspaceName: string().required(), + invitedByUserId: string().required(), + createdAt: string().required(), +}).required(); + +export type WorkspaceInvite = InferType; + +/** + * Minimal context for the invitation landing page (`GET /workspace-invites/:inviteId`), + * reachable by any authenticated user who has the invite id. The invited address is + * returned only as a redacted hint (e.g. `a***@example.com`) so a prober cannot harvest + * the full address; the full email surfaces only in the caller's own `@me` list once + * their verified email matches. + */ +export const WorkspaceInviteContextSchema = object({ + id: string().required(), + // Always present: a redacted hint (e.g. `a***@example.com`). + emailHint: string().required(), + // Present only when the caller's verified email already matches the invite, so + // the verify-and-accept UX can pre-fill/lock the field for the real invitee + // while a prober only ever sees the hint. + email: string().optional(), + role: mixed() + .oneOf([...WORKSPACE_ROLES]) + .required(), + workspaceName: string().required(), + invitedByUserId: string().required(), + createdAt: string().required(), + // True when the caller is already a member of the workspace (resolved + // server-side, independent of their verified email). The landing page uses it + // to show an "already a member" state instead of offering the verify step, + // which would otherwise overwrite the caller's verified email for an accept + // that the server would reject anyway. + alreadyMember: boolean().optional(), +}).required(); + +export type WorkspaceInviteContext = InferType; + +/** + * A pending invitation as seen by an owner/admin managing a workspace. Returned by + * the workspace-scoped invites list (`GET /workspaces/:workspaceSlug/invites`). The + * inviter is identified by user id; creation time drives the "invited X ago" display. + */ +export const WorkspaceManagedInviteSchema = object({ + id: string().required(), + email: string().required(), + role: mixed() + .oneOf([...WORKSPACE_ROLES]) + .required(), + invitedByUserId: string().required(), + createdAt: string().required(), +}).required(); + +export type WorkspaceManagedInvite = InferType; + +/** + * A current member of a workspace as seen by an owner/admin. Returned by + * `GET /workspaces/:workspaceSlug/members`. Identity is kept Discord-agnostic at the + * membership level (`userId`); `discordUserId` is surfaced so the client can both + * render the member and identify which row is the caller (leave vs remove). + */ +export const WorkspaceMemberSchema = object({ + userId: string().required(), + role: mixed() + .oneOf([...WORKSPACE_ROLES]) + .required(), + discordUserId: string().required(), +}).required(); + +export type WorkspaceMember = InferType; diff --git a/services/backend-api/client/src/mocks/data/userMe.ts b/services/backend-api/client/src/mocks/data/userMe.ts index 013b0e24e..a8964951d 100644 --- a/services/backend-api/client/src/mocks/data/userMe.ts +++ b/services/backend-api/client/src/mocks/data/userMe.ts @@ -4,6 +4,8 @@ import { ProductKey } from "../../constants"; const mockUserMe: UserMe = { id: "1", email: "email@email.com", + // Set to undefined to inspect the verify-email step in the create-workspace flow. + verifiedEmail: "email@email.com", preferences: { alertOnDisabledFeeds: true, }, @@ -28,6 +30,10 @@ const mockUserMe: UserMe = { enableBilling: true, featureFlags: { externalProperties: true, + workspaces: true, + }, + capabilities: { + workspaces: true, }, supporterFeatures: { exrternalProperties: { diff --git a/services/backend-api/client/src/mocks/data/workspaces.ts b/services/backend-api/client/src/mocks/data/workspaces.ts new file mode 100644 index 000000000..9589ec073 --- /dev/null +++ b/services/backend-api/client/src/mocks/data/workspaces.ts @@ -0,0 +1,8 @@ +import { Workspace } from "@/features/workspaces"; + +const mockWorkspaces: Workspace[] = [ + { id: "workspace-1", name: "Acme Marketing", slug: "acme-marketing", role: "owner" }, + { id: "workspace-2", name: "Open Source Crew", slug: "open-source-crew", role: "admin" }, +]; + +export default mockWorkspaces; diff --git a/services/backend-api/client/src/mocks/handlers.ts b/services/backend-api/client/src/mocks/handlers.ts index e008b78b7..e1e90abca 100644 --- a/services/backend-api/client/src/mocks/handlers.ts +++ b/services/backend-api/client/src/mocks/handlers.ts @@ -9,6 +9,7 @@ import { UpdateUserMeOutput, } from "@/features/discordUser"; import { GetServersOutput } from "../features/discordServers/api/getServer"; +import { isReservedSlug } from "@/utils/slugify"; import { CreateUserFeedCloneOutput, CreateUserFeedDatePreviewOutput, @@ -76,7 +77,7 @@ import { CreateUserFeedUrlValidationInput, CreateUserFeedUrlValidationOutput, } from "../features/feed/api/createUserFeedUrlValidation"; -import { ApiErrorCode } from "../utils/getStandardErrorCodeMessage copy"; +import { ApiErrorCode } from "../utils/getStandardErrorCodeMessage"; import { CreateUserFeedDeduplicatedUrlsInput, CreateUserFeedDeduplicatedUrlsOutput, @@ -84,10 +85,21 @@ import { import { UserFeedUrlRequestStatus } from "../features/feed/types/UserFeedUrlRequestStatus"; import curatedFeedsMock from "./data/curatedFeedsMock.json"; import { GetCuratedFeedsOutput } from "../features/feed/api/getCuratedFeeds"; +import { + CreateWorkspaceOutput, + GetWorkspaceOutput, + GetWorkspacesOutput, + Workspace, + UpdateWorkspaceOutput, +} from "@/features/workspaces"; +import mockWorkspaces from "./data/workspaces"; const CURATED_FEEDS_MAX_LIMIT = 25; const CURATED_FEEDS_MIN_SEARCH_LENGTH = 3; +// In-memory workspaces store so the mock create flow reflects in the chooser/list. +const workspacesStore: Workspace[] = [...mockWorkspaces]; + function escapeRegex(input: string): string { return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } @@ -198,6 +210,137 @@ const handlers = [ return HttpResponse.json({ result: mockUserMe }); }), + http.post("/api/v1/users/@me/email-verification", async () => { + await delay(500); + + return HttpResponse.json({ result: { ok: true } }); + }), + http.post("/api/v1/users/@me/email-verification/confirm", async () => { + await delay(500); + + return HttpResponse.json({ result: { ok: true } }); + }), + http.get("/api/v1/workspaces", async () => { + await delay(500); + + return HttpResponse.json({ result: workspacesStore }); + }), + http.post("/api/v1/workspaces", async ({ request }) => { + await delay(500); + const body = (await request.json()) as { name: string; slug: string }; + + if (isReservedSlug(body.slug)) { + return HttpResponse.json( + generateMockApiErrorResponse({ + code: "WORKSPACE_SLUG_RESERVED", + message: "This URL slug is reserved and cannot be used", + }), + { status: 409 }, + ); + } + + if (workspacesStore.some((t) => t.slug === body.slug)) { + return HttpResponse.json( + generateMockApiErrorResponse({ + code: "WORKSPACE_SLUG_TAKEN", + message: "This URL slug is already taken by another workspace", + }), + { status: 409 }, + ); + } + + const id = `workspace-${workspacesStore.length + 1}`; + workspacesStore.push({ id, name: body.name, slug: body.slug, role: "owner" }); + + return HttpResponse.json({ + result: { + id, + name: body.name, + slug: body.slug, + createdByUserId: "1", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }); + }), + http.get("/api/v1/workspaces/:workspaceSlug", async ({ params }) => { + await delay(500); + const workspaceSlug = params.workspaceSlug as string; + const workspace = workspacesStore.find((t) => t.slug === workspaceSlug); + + if (!workspace) { + return HttpResponse.json( + generateMockApiErrorResponse({ + code: "WORKSPACE_NOT_FOUND", + message: "Workspace not found", + }), + { status: 404 }, + ); + } + + return HttpResponse.json({ + result: { + id: workspace.id, + name: workspace.name, + slug: workspace.slug, + role: workspace.role, + }, + }); + }), + http.patch("/api/v1/workspaces/:workspaceSlug", async ({ request, params }) => { + await delay(500); + const workspaceSlug = params.workspaceSlug as string; + const body = (await request.json()) as { name?: string; slug?: string }; + const workspace = workspacesStore.find((t) => t.slug === workspaceSlug); + + if (!workspace) { + return HttpResponse.json( + generateMockApiErrorResponse({ + code: "WORKSPACE_NOT_FOUND", + message: "Workspace not found", + }), + { status: 404 }, + ); + } + + if (body.slug && body.slug !== workspace.slug && isReservedSlug(body.slug)) { + return HttpResponse.json( + generateMockApiErrorResponse({ + code: "WORKSPACE_SLUG_RESERVED", + message: "This URL slug is reserved and cannot be used", + }), + { status: 409 }, + ); + } + + if ( + body.slug && + body.slug !== workspace.slug && + workspacesStore.some((t) => t.slug === body.slug) + ) { + return HttpResponse.json( + generateMockApiErrorResponse({ + code: "WORKSPACE_SLUG_TAKEN", + message: "This URL slug is already taken by another workspace", + }), + { status: 409 }, + ); + } + + if (body.name) workspace.name = body.name; + if (body.slug) workspace.slug = body.slug; + + return HttpResponse.json({ + result: { + id: workspace.id, + name: workspace.name, + slug: workspace.slug, + createdByUserId: "1", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }); + }), http.get("/api/v1/discord-users/bot", async () => HttpResponse.json({ result: mockDiscordBot, diff --git a/services/backend-api/client/src/pages/AddUserFeeds.tsx b/services/backend-api/client/src/pages/AddUserFeeds.tsx index 38ffc8f4c..ff1cd779e 100644 --- a/services/backend-api/client/src/pages/AddUserFeeds.tsx +++ b/services/backend-api/client/src/pages/AddUserFeeds.tsx @@ -39,7 +39,7 @@ import { Panel } from "@/components/Panel"; import { PrimaryActionButton } from "@/components/PrimaryActionButton"; import { AutoResizeTextarea } from "../components/AutoResizeTextarea"; import { pages, ProductKey } from "../constants"; -import { ensureUrlScheme, useCreateUserFeed, useUserFeeds } from "../features/feed"; +import { ensureUrlScheme, useCreateUserFeed, useUserFeeds, useFeedScope } from "../features/feed"; import { useCreateUserFeedUrlValidation } from "../features/feed/hooks/useCreateUserFeedUrlValidation"; import { useDiscordUserMe, useUserMe } from "../features/discordUser"; import { PricingDialogContext } from "@/features/subscriptionProducts"; @@ -224,6 +224,8 @@ const UploadProgressView = ({ const { mutateAsync: createUserFeed } = useCreateUserFeed(); const { mutateAsync: createUrlValidation } = useCreateUserFeedUrlValidation(); const navigate = useNavigate(); + const { workspaceSlug } = useFeedScope(); + const scope = workspaceSlug ? { workspaceSlug } : undefined; const total = allResults.length; const percentSucceeded = ((totalSucceeded / total) * 100).toFixed(2); @@ -275,7 +277,7 @@ const UploadProgressView = ({ rowData.title = title; rowData.status = "success"; - rowData.controlPaneLink = pages.userFeed(id); + rowData.controlPaneLink = pages.userFeed(id, { scope }); } } catch (err) { rowData.status = "failed"; @@ -292,7 +294,7 @@ const UploadProgressView = ({ }), ); }, - [createUrlValidation, createUserFeed, sourceFeed?.id], + [createUrlValidation, createUserFeed, sourceFeed?.id, scope], ); useEffect(() => { @@ -456,7 +458,7 @@ const UploadProgressView = ({ return; } - navigate(pages.userFeeds()); + navigate(pages.userFeeds(scope)); }} > Close @@ -484,6 +486,8 @@ const AddFormView = ({ onSubmitted }: { onSubmitted: (urls: string[]) => void }) status: deduplicateStatus, } = useCreateUserFeedDeduplicatedUrls(); const navigate = useNavigate(); + const { workspaceSlug } = useFeedScope(); + const scope = workspaceSlug ? { workspaceSlug } : undefined; const remainingFeedsAllowed = discordUserMe && userFeedsResults @@ -539,7 +543,7 @@ const AddFormView = ({ onSubmitted }: { onSubmitted: (urls: string[]) => void }) - Feeds + Feeds @@ -795,7 +799,7 @@ const AddFormView = ({ onSubmitted }: { onSubmitted: (urls: string[]) => void }) )} - =1 workspace. At 0 workspaces the header is + * unchanged except for a "Create a team" entry in the account menu. */ export const AppHeader = ({ invertBackground }: Props) => { const { data: discordBotData, status, error } = useDiscordBot(); const { data: discordUserMe } = useDiscordUserMe(); const { t } = useTranslation(); + const { enabled: workspacesEnabled } = useIsWorkspacesEnabled(); + const { workspaces } = useWorkspaces({ enabled: workspacesEnabled }); + const hasWorkspaces = (workspaces?.length ?? 0) > 0; + const createWorkspaceDisclosure = useDisclosure(); + return ( - } - logoutSlot={ - - - {t("components.pageContentV2.logout")} + <> + + ) : undefined + } + searchSlot={} + accountMenuSlot={ + workspacesEnabled && !hasWorkspaces ? ( + + + Create a team - } + ) : undefined + } + logoutSlot={ + + + {t("components.pageContentV2.logout")} + + } + /> + } + /> + {workspacesEnabled && ( + - } - /> + )} + ); }; diff --git a/services/backend-api/client/src/pages/UserFeeds.tsx b/services/backend-api/client/src/pages/UserFeeds.tsx index aef7ab855..2075bf0ff 100644 --- a/services/backend-api/client/src/pages/UserFeeds.tsx +++ b/services/backend-api/client/src/pages/UserFeeds.tsx @@ -38,11 +38,12 @@ import { UserFeedsTable, useUserFeedManagementInvitesCount, useUserFeeds, + useFeedScope, } from "../features/feed"; import type { FeedActionState } from "../features/feed"; import type { CuratedFeed } from "../features/feed/types"; import { useDeleteUserFeed } from "../features/feed/hooks/useDeleteUserFeed"; -import { ApiErrorCode } from "../utils/getStandardErrorCodeMessage copy"; +import { ApiErrorCode } from "../utils/getStandardErrorCodeMessage"; import ApiAdapterError from "../utils/ApiAdapterError"; import { pages } from "../constants"; import { BoxConstrained, ConfirmModal, Panel } from "../components"; @@ -105,6 +106,8 @@ const UserFeedsInner: React.FC = () => { const { t } = useTranslation(); const { state } = useLocation(); const [searchParams, setSearchParams] = useSearchParams(); + const { workspaceSlug } = useFeedScope(); + const scope = useMemo(() => (workspaceSlug ? { workspaceSlug } : undefined), [workspaceSlug]); const { data: userMeData } = useUserMe(); const { data: userFeedsRequireAttentionResults } = useUserFeeds({ limit: 1, @@ -172,14 +175,16 @@ const UserFeedsInner: React.FC = () => { const showSetupChecklist = (feedsWithoutConnections > 0 && unconfiguredFeedsLoaded) || hasCompletedSetup; const navigatedAlertTitle = state?.alertTitle; + const navigatedAlertDescription = state?.alertDescription; useEffect(() => { if (navigatedAlertTitle) { createSuccessAlert({ title: navigatedAlertTitle, + description: navigatedAlertDescription, }); } - }, [navigatedAlertTitle]); + }, [navigatedAlertTitle, navigatedAlertDescription]); useEffect(() => { const addFeedQuery = searchParams.get("addFeed"); @@ -240,7 +245,7 @@ const UserFeedsInner: React.FC = () => { ...prev, [feed.id]: { status: "added", - settingsUrl: pages.userFeed(result.id), + settingsUrl: pages.userFeed(result.id, { scope }), feedId: result.id, }, })); @@ -272,7 +277,7 @@ const UserFeedsInner: React.FC = () => { } } }, - [createUserFeed, createInfoAlert, discordUserMe?.maxUserFeeds], + [createUserFeed, createInfoAlert, discordUserMe?.maxUserFeeds, scope], ); const handleCuratedFeedRemove = useCallback( @@ -314,13 +319,20 @@ const UserFeedsInner: React.FC = () => { [feedActionStates, deleteUserFeed], ); - const handleUrlFeedAdded = useCallback((_feedId: string, feedUrl: string) => { - setFeedActionStates((prev) => ({ - ...prev, - [feedUrl]: { status: "added", settingsUrl: pages.userFeed(_feedId), feedId: _feedId }, - })); - setModalSessionAddCount((prev) => prev + 1); - }, []); + const handleUrlFeedAdded = useCallback( + (_feedId: string, feedUrl: string) => { + setFeedActionStates((prev) => ({ + ...prev, + [feedUrl]: { + status: "added", + settingsUrl: pages.userFeed(_feedId, { scope }), + feedId: _feedId, + }, + })); + setModalSessionAddCount((prev) => prev + 1); + }, + [scope], + ); const handleUrlFeedRemoved = useCallback((feedUrl: string) => { setFeedActionStates((prev) => { @@ -661,7 +673,7 @@ const UserFeedsInner: React.FC = () => { - + Add multiple feeds diff --git a/services/backend-api/client/src/pages/UserSettings.tsx b/services/backend-api/client/src/pages/UserSettings.tsx index 2033372b5..8285a0ab7 100644 --- a/services/backend-api/client/src/pages/UserSettings.tsx +++ b/services/backend-api/client/src/pages/UserSettings.tsx @@ -40,6 +40,7 @@ import { DatePreferencesForm } from "@/features/feed"; import { useRemoveRedditLogin } from "../features/feed/hooks/useRemoveRedditLogin"; import { RedditLoginButton } from "@/features/discordUser"; +import { WorkspacesSettingsSection } from "@/features/workspaces"; import { PageAlertContextOutlet, PageAlertProvider, @@ -664,6 +665,7 @@ const UserSettingsInner = () => {
+
diff --git a/services/backend-api/client/src/pages/WorkspaceSettings.tsx b/services/backend-api/client/src/pages/WorkspaceSettings.tsx new file mode 100644 index 000000000..d197ef4f1 --- /dev/null +++ b/services/backend-api/client/src/pages/WorkspaceSettings.tsx @@ -0,0 +1,18 @@ +import { Stack } from "@chakra-ui/react"; +import { BoxConstrained } from "@/components"; +import { PageAlertContextOutlet, PageAlertProvider } from "@/contexts/PageAlertContext"; +import { WorkspaceMembers, WorkspaceSettings } from "@/features/workspaces"; + +export const WorkspaceSettingsPage = () => ( + + + + + + + + + + + +); diff --git a/services/backend-api/client/src/pages/index.tsx b/services/backend-api/client/src/pages/index.tsx index ecfae0072..5597e9a83 100644 --- a/services/backend-api/client/src/pages/index.tsx +++ b/services/backend-api/client/src/pages/index.tsx @@ -9,6 +9,7 @@ import { pages } from "../constants"; import { FeedConnectionType } from "../types"; import { Loading } from "../components"; import { UserFeedStatusFilterProvider, MultiSelectUserFeedProvider } from "@/features/feed"; +import { WorkspaceScopeLayout, InvitePage } from "@/features/workspaces"; import { NotFound } from "./NotFound"; import { SuspenseErrorBoundary } from "../components/SuspenseErrorBoundary"; @@ -42,6 +43,10 @@ const Checkout = lazyWithRetries(() => import("./Checkout").then(({ Checkout: c }) => ({ default: c })), ); +const WorkspaceSettingsPage = lazyWithRetries(() => + import("./WorkspaceSettings").then(({ WorkspaceSettingsPage: c }) => ({ default: c })), +); + const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes); const Pages: React.FC = () => ( @@ -153,6 +158,106 @@ const Pages: React.FC = () => ( } /> + {/* Workspace-scoped routes reuse the same page components as personal scope. + WorkspaceScopeLayout provides the workspace + feed scope so feed queries, + mutations, and links stay workspace-scoped. Each child renders its own header + (mirroring the personal routes) so the message-builder route can be + full-screen with no header, exactly like personal scope. */} + + + + } + > + } /> + + + }> + + + + + + + + } + /> + + + }> + + + + } + /> + }> + }> + + + + } + /> + }> + }> + + + + } + /> + + + + Loading Message Builder... + + } + > + + + + } + /> + + + + + } + /> + + {/* Invitation landing page. RequireAuth bootstraps a logged-out invitee + through Discord OAuth and returns them here (the path is preserved via + the OAuth state), so the link works whether or not they're signed in. */} + + }> + + + + } + /> } /> ); diff --git a/services/backend-api/client/src/types/RouteParams.ts b/services/backend-api/client/src/types/RouteParams.ts index 9c9a42ee2..feb829b75 100644 --- a/services/backend-api/client/src/types/RouteParams.ts +++ b/services/backend-api/client/src/types/RouteParams.ts @@ -1,3 +1,3 @@ -type RouteParams = "serverId" | "feedId" | "connectionId"; +type RouteParams = "serverId" | "feedId" | "connectionId" | "workspaceSlug"; export default RouteParams; diff --git a/services/backend-api/client/src/utils/fetchRest.ts b/services/backend-api/client/src/utils/fetchRest.ts index f5e76f829..9fea84604 100644 --- a/services/backend-api/client/src/utils/fetchRest.ts +++ b/services/backend-api/client/src/utils/fetchRest.ts @@ -1,7 +1,7 @@ /* eslint-disable no-console */ import { Schema, ValidationError } from "yup"; import ApiAdapterError from "./ApiAdapterError"; -import { getStandardErrorCodeMessage } from "./getStandardErrorCodeMessage copy"; +import { getStandardErrorCodeMessage } from "./getStandardErrorCodeMessage"; import getStatusCodeErrorMessage from "./getStatusCodeErrorMessage"; interface StandardApiError { diff --git a/services/backend-api/client/src/utils/getStandardErrorCodeMessage copy.ts b/services/backend-api/client/src/utils/getStandardErrorCodeMessage.ts similarity index 67% rename from services/backend-api/client/src/utils/getStandardErrorCodeMessage copy.ts rename to services/backend-api/client/src/utils/getStandardErrorCodeMessage.ts index 2d2cf21e4..27d125f5c 100644 --- a/services/backend-api/client/src/utils/getStandardErrorCodeMessage copy.ts +++ b/services/backend-api/client/src/utils/getStandardErrorCodeMessage.ts @@ -50,6 +50,29 @@ export enum ApiErrorCode { SUBSCRIPTION_ABOUT_TO_RENEW = "SUBSCRIPTION_ABOUT_TO_RENEW", USER_REFRESH_RATE_NOT_ALLOWED = "USER_REFRESH_RATE_NOT_ALLOWED", ADDRESS_LOCATION_NOT_ALLOWED = "ADDRESS_LOCATION_NOT_ALLOWED", + EMAIL_VERIFICATION_INVALID_CODE = "EMAIL_VERIFICATION_INVALID_CODE", + EMAIL_VERIFICATION_EXPIRED = "EMAIL_VERIFICATION_EXPIRED", + EMAIL_VERIFICATION_TOO_MANY_ATTEMPTS = "EMAIL_VERIFICATION_TOO_MANY_ATTEMPTS", + EMAIL_VERIFICATION_RESEND_TOO_SOON = "EMAIL_VERIFICATION_RESEND_TOO_SOON", + EMAIL_VERIFICATION_TOO_MANY_TARGETS = "EMAIL_VERIFICATION_TOO_MANY_TARGETS", + EMAIL_VERIFICATION_UNAVAILABLE = "EMAIL_VERIFICATION_UNAVAILABLE", + TOO_MANY_REQUESTS = "TOO_MANY_REQUESTS", + EMAIL_ALREADY_IN_USE = "EMAIL_ALREADY_IN_USE", + EMAIL_NOT_VERIFIED = "EMAIL_NOT_VERIFIED", + WORKSPACE_NOT_FOUND = "WORKSPACE_NOT_FOUND", + WORKSPACE_INSUFFICIENT_ROLE = "WORKSPACE_INSUFFICIENT_ROLE", + WORKSPACE_SLUG_TAKEN = "WORKSPACE_SLUG_TAKEN", + WORKSPACE_SLUG_RESERVED = "WORKSPACE_SLUG_RESERVED", + WORKSPACE_INVITE_NOT_FOUND = "WORKSPACE_INVITE_NOT_FOUND", + WORKSPACE_INVITE_EMAIL_UNVERIFIED = "WORKSPACE_INVITE_EMAIL_UNVERIFIED", + WORKSPACE_INVITE_EMAIL_MISMATCH = "WORKSPACE_INVITE_EMAIL_MISMATCH", + WORKSPACE_INVITE_ALREADY_MEMBER = "WORKSPACE_INVITE_ALREADY_MEMBER", + WORKSPACE_MEMBER_ALREADY_EXISTS = "WORKSPACE_MEMBER_ALREADY_EXISTS", + WORKSPACE_ALREADY_INVITED = "WORKSPACE_ALREADY_INVITED", + WORKSPACE_INVITE_EMAIL_UNAVAILABLE = "WORKSPACE_INVITE_EMAIL_UNAVAILABLE", + WORKSPACE_INVITE_RESEND_TOO_SOON = "WORKSPACE_INVITE_RESEND_TOO_SOON", + WORKSPACE_INVITE_LIMIT_REACHED = "WORKSPACE_INVITE_LIMIT_REACHED", + CANNOT_REMOVE_LAST_OWNER = "CANNOT_REMOVE_LAST_OWNER", SUBSCRIPTION_ALREADY_CANCELLED = "SUBSCRIPTION_ALREADY_CANCELLED", } @@ -115,6 +138,37 @@ const ERROR_CODE_MESSAGES: Record = { USER_REFRESH_RATE_NOT_ALLOWED: "Refresh rate is not allowed.", ADDRESS_LOCATION_NOT_ALLOWED: "Your location is not supported for billing. This may be due to regional restrictions. If you believe this is an error, please contact support@monitorss.xyz.", + EMAIL_VERIFICATION_INVALID_CODE: "Invalid or incorrect verification code. Please try again.", + EMAIL_VERIFICATION_EXPIRED: "This verification code has expired. Please request a new one.", + EMAIL_VERIFICATION_TOO_MANY_ATTEMPTS: + "Too many incorrect attempts. Please request a new verification code.", + EMAIL_VERIFICATION_RESEND_TOO_SOON: + "Please wait a moment before requesting another verification code.", + EMAIL_VERIFICATION_TOO_MANY_TARGETS: + "Too many different email addresses have been tried recently. Please wait before trying another address.", + EMAIL_VERIFICATION_UNAVAILABLE: + "Email verification is currently unavailable. Please try again later.", + TOO_MANY_REQUESTS: "Too many requests. Please wait a moment and try again.", + EMAIL_ALREADY_IN_USE: "This email is already in use by another account.", + EMAIL_NOT_VERIFIED: "A verified email is required to perform this action.", + WORKSPACE_NOT_FOUND: "This team no longer exists, or you do not have access to it.", + WORKSPACE_INSUFFICIENT_ROLE: "You do not have permission to do this.", + WORKSPACE_SLUG_TAKEN: "This URL is already taken by another team.", + WORKSPACE_SLUG_RESERVED: "This URL is reserved. Please choose another.", + WORKSPACE_INVITE_NOT_FOUND: + "This invitation no longer exists. It may have already been accepted, declined, or revoked.", + WORKSPACE_INVITE_EMAIL_UNVERIFIED: "Verify the invited email address to accept this invitation.", + WORKSPACE_INVITE_EMAIL_MISMATCH: "Verify the invited email address to accept this invitation.", + WORKSPACE_MEMBER_ALREADY_EXISTS: "This email already belongs to a member of this team.", + WORKSPACE_INVITE_ALREADY_MEMBER: "You are already a member of this team.", + WORKSPACE_ALREADY_INVITED: "This email already has a pending invitation to this team.", + WORKSPACE_INVITE_EMAIL_UNAVAILABLE: + "The invitation email could not be sent because email delivery is currently unavailable. Please try again later.", + WORKSPACE_INVITE_RESEND_TOO_SOON: "Please wait a moment before resending this invitation.", + WORKSPACE_INVITE_LIMIT_REACHED: + "This team has reached its limit of pending invitations. Revoke a pending invitation before sending another.", + CANNOT_REMOVE_LAST_OWNER: + "A team must have at least one owner. Transfer ownership before removing this member.", SUBSCRIPTION_ALREADY_CANCELLED: "This subscription has already been cancelled. Try refreshing the page to see your current plan.", }; diff --git a/services/backend-api/client/src/utils/slugify.ts b/services/backend-api/client/src/utils/slugify.ts new file mode 100644 index 000000000..748cbe3cc --- /dev/null +++ b/services/backend-api/client/src/utils/slugify.ts @@ -0,0 +1,43 @@ +// Mirrors the backend SLUG_PATTERN: lowercase alphanumerics and single hyphens, +// no leading, trailing, or consecutive hyphens. +export const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +// Mirrors the backend RESERVED_SLUGS set so obviously-reserved slugs are caught +// client-side before submission. The backend remains the authority. +export const RESERVED_SLUGS: ReadonlySet = new Set([ + "new", + "create", + "edit", + "settings", + "admin", + "api", + "me", + "account", + "login", + "logout", + "workspaces", + "workspace", + "feeds", + "feed", + "null", + "undefined", +]); + +export function isReservedSlug(slug: string): boolean { + return RESERVED_SLUGS.has(slug); +} + +/** + * Derives a slug preview from a workspace name as the user types. + * Used to pre-fill the slug field in CreateWorkspaceDialog — the user must still + * confirm or edit it before submitting. + */ +export function slugifyPreview(name: string): string { + return name + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 50) + .replace(/-+$/g, ""); +} diff --git a/services/backend-api/docs/adr/002-workspace-membership-and-ownership-model.md b/services/backend-api/docs/adr/002-workspace-membership-and-ownership-model.md new file mode 100644 index 000000000..555d23f35 --- /dev/null +++ b/services/backend-api/docs/adr/002-workspace-membership-and-ownership-model.md @@ -0,0 +1,181 @@ +# ADR-002 — Workspace membership & ownership model: provider-agnostic `workspaces` + `workspace_memberships`, native over better-auth + +**Status:** Accepted +**Date:** 2026-05-29 (feed↔workspace association and slugs added 2026-05-31) +**Scope:** `services/backend-api/src/` (data model, routes, auth gate). The frontend consumes this contract; its UI decisions live in client ADR-008. + +## Context + +Client ADR-005 ("Workspace scoping") put the forward-compatibility seams in place — the `/workspaces/:workspaceSlug` route shape, an optional `workspaceId` on `getUserFeeds()`, and a nullable `workspaceId` on `UserFeedSchema` — and deferred the membership/ownership model to "a backend ADR." This is that ADR. + +Functional requirements: + +1. A user can belong to multiple workspaces; a workspace has multiple users. +2. Member management (invite/add/remove) is **out of scope** this round. +3. Creating a workspace requires a **verified email** and a workspace **name**. "Verified" means an email the user owns and *we* verified — not the email Discord hands us, which is a mutable OAuth claim that only proves Discord's belief, not mailbox control (§4). +4. Two roles — **owner** and **admin**. Owner-only actions (delete, transfer ownership) are gated; access is otherwise identical and every member can edit (§3). +5. Workspaces should be able to own user feeds (built this round — §7). +6. **Self-host, MIT-licensing, no forced pay at small scale** are hard constraints (§8). + +Ground truth from discovery: + +- **Identity is Discord-coupled today.** `requireAuthHook` sets `request.discordUserId`; feed ownership keys on `user.discordUserId`. The `User` doc has a Mongo `_id` (exposed as `IUser.id`) plus `discordUserId`, `email`, `featureFlags`. +- **No email verification exists** — `email` is a bare Discord-OAuth string with no verified flag or flow. A genuine gap for req #3. +- **A per-user feature-flag mechanism already exists** (`User.featureFlags` → `GET /users/@me`; `externalProperties` is the precedent). +- **Persistence (backend ADR-001):** raw Mongoose repository classes, no new interface files, vertical slices under `src/features//`. + +Two maintainer constraints override the "follow the nearest precedent" default: + +- **Nothing Discord-related may couple to the workspace data model.** (Rules out mirroring the feed-sharing `invites[]` precedent, which keys on `discordUserId`.) +- **Evaluate an open-source library (better-auth) before building natively.** + +A note on the name: "workspace" is the *outer* billing + membership + resource unit, matching better-auth's `organization` plugin (§5, the documented escalation path) and every reference platform that exposes an "organization"/"workspace". "Team" is intentionally left free for a future *inner* grouping (a workspace containing teams) if one is ever needed. + +## Decision + +### 1. Workspace↔user edges reference the internal user ID, never `discordUserId` + +Membership references `IUser.id` (the `User` document's Mongo `_id`). The workspace domain has **zero** references to `discordUserId`, Discord guilds, or any Discord concept. This is the load-bearing decision: it aligns with the destination-extensibility roadmap (client ADR-004), survives a future migration off Discord-as-sole-identity, and makes the workspace model a clean shape to map onto better-auth later (§5). Feed *ownership* stays on `discordUserId` for now (legacy, untouched); the workspace↔user edge is provider-agnostic from day one. + +### 2. Two collections: `workspaces` and `workspace_memberships` (not an embedded array) + +`workspaces` holds `{ name, createdByUserId (audit only), timestamps }`; `workspace_memberships` holds `{ workspaceId, userId (refs `users._id`, NOT `discordUserId`), role, timestamps }` with a unique index on `{ userId, workspaceId }`. That one index enforces one-membership-per-(workspace,user) and its `userId` prefix serves the hot "workspaces I'm in" lookup, so no separate index is needed. Nothing queries by `workspaceId` alone yet (the "members of a workspace" listing ships with member management, §10). + +A separate collection rather than an embedded `members[]` because: the `invites[]` precedent keys on `discordUserId` (violates the decoupling constraint, so it doesn't apply); "workspaces I'm in" is the hottest query and is a clean indexed lookup here vs. a scan against `workspaces`; it maps 1:1 onto better-auth's `member` table (§5); and it avoids unbounded array growth. + +One `WorkspaceMongooseRepository` owns both collections (ADR-001: no interface file). Slice at `src/features/workspaces/`. + +**At least one owner always exists.** The membership collection makes orphan-prevention a property of the data, not a cleanup job: `WorkspaceMongooseRepository.countOwners` is the check that leave/remove/demote operations (member management, §10) run before mutating, rejecting with `CANNOT_REMOVE_LAST_OWNER` if the action would drop the last owner. The bad state is forbidden at the operation boundary rather than swept up after the fact; no soft-`inactive` flag is added. + +### 3. Role is an open-ended string validated by a Zod enum + +`role` is stored as a string, validated by `WorkspaceRole = z.enum(['owner','admin'])`. Adding a role later is "extend the enum + add the check" — no migration. The creator gets a single `owner` membership. Every member can manage the workspace and its feeds; the only role gate is **owner-only** actions — `deleteWorkspace` and `transferOwnership` — so there is no read-only tier. Authorization is a `can(action, role)` function in `workspaces.service.ts` (today: owner-only actions ⇒ `owner`, else any member) — handlers call `can()`, never compare role strings inline. This is the seam any future permission model extends; the `WORKSPACE_INSUFFICIENT_ROLE` error surfaces a failed check. + +### 4. Verified email: an owned email we verify ourselves (passwordless), never Discord's claim + +Add to the `User` schema `verifiedEmail` + `verifiedEmailVerifiedAt` (both optional). These are **separate** from the Discord-sourced `email` (which stays untrusted for this purpose) and are never the primary key — the identifier is `User._id` (§1). `verifiedEmail` is **unique across users** so it can later become a login anchor (§9). + +**Flow (passwordless):** the user enters an email they own (pre-filled with the Discord email for convenience, but confirmation is always required) → we email a one-time code → on confirm we set the two fields. `POST /workspaces` returns `403` unless `verifiedEmail` is set. + +**Why passwordless:** with a strong federated login already in place, a separate password is the worst option on both axes — it adds hash custody, reset flows, and credential-stuffing surface (security) and forces a second secret (usability), for no gain. Proving mailbox ownership once is sufficient. + +Key sub-decisions: + +- **A typed code (OTP), not a magic link.** The user is already authenticated mid-flow, so a short code works cross-device, dodges the email/AV-scanner link-prefetch bug (scanners GET links and silently consume single-use tokens), and is easy to rate-limit. Magic link is a near-equivalent alternative; code is chosen. The code's small space is *not* the security boundary — an attempt cap + short expiry + send rate-limit are; the code is hashed at rest as defense-in-depth. +- **Endpoints live in the `users` slice, not `workspaces`** (verified email is a reusable user attribute): a send and a confirm endpoint under `/users/@me/...`. The workspaces handler only reads `User.verifiedEmail`. +- **Mail transport** goes through generic SMTP (nodemailer, MIT) behind a small swappable mailer — never a proprietary email API (req #6). SMTP is optional: these endpoints are gated per-user by the workspaces feature flag (§8), and when SMTP is unconfigured a send returns `EMAIL_VERIFICATION_UNAVAILABLE`. + +(Storage shape, code length/expiry/attempt constants, and normalization rules live in the implementing code, not here — they are tuning, not architecture.) + +### 5. Build natively now; better-auth's organization plugin is the documented escalation path + +better-auth ships an `organization` plugin (orgs/members/roles/invitations/RBAC) with a MongoDB adapter that maps almost 1:1 onto this feature and brings email verification for free. We evaluated adopting it now and **defer it** for this round. + +The real coupling is a single foreign key: **`member.userId` references better-auth's own `user` table** — the org plugin is bolted to better-auth core, not standalone. So every workspace member must exist in a better-auth-visible `user` table, forcing either (a) pointing better-auth's adapter at our `User` collection and ceding ownership of it (plus running better-auth's `session`/`account`/`verification` collections we don't use), or (b) running a separate better-auth `user` table and syncing into it. Either way we'd run better-auth's runtime + ~6 mostly-unused tables to use ~10% of the plugin — its headline features (invitations, sub-teams, fine-grained permissions) are all req #2 ("out of scope"). The native cost is small by comparison: two collections, one repo, a role enum, a `can()` function, one verified-email field. + +To keep a future migration *mechanical*, the native schema deliberately mirrors better-auth's: `workspaces` ≈ `organization`, `workspace_memberships` ≈ `member`. **Trigger conditions** that flip the recommendation: invitations become a priority, a second identity provider or email/password login is added, or fine-grained permissions are needed — at which point we'd want better-auth's *core* anyway, so lending it the `user` table stops being a tax. This ADR should be superseded then. + +better-auth is MIT-licensed and runs as a library against our own Mongo, so adopting it later stays self-hostable and MIT-compatible (req #6) — and this is why proprietary hosted-auth (Auth0/Clerk/WorkOS) is ruled out: they gate orgs/SSO behind paid tiers. Adopting better-auth as the *auth core* (the multi-provider trigger) is a platform-wide, major-version change and **must not be bundled** with the workspaces toggle: workspaces is a toggleable module; the auth core is a separate platform decision. + +### 6. API surface + +All routes register under the `workspaces` slice, require auth, and are gated **server-side** by the §8 per-user flag (not just in the UI): a caller lacking the `featureFlags.workspaces` flag ⇒ `404`/`403`. + +- `POST /api/v1/workspaces` — create `{ name, slug }`; `403` if email unverified; creator gets an owner membership. +- `GET /api/v1/workspaces` — list workspaces I'm a member of. +- `GET /api/v1/workspaces/:workspaceSlug` — detail; `403`/`404` if not a member. +- `PATCH /api/v1/workspaces/:workspaceSlug` — update `{ name, slug }`. + +URLs are **slug-based** (`:workspaceSlug`, not `:workspaceId`). Every `Workspace` has a required, unique, lowercase `slug` validated against the shared `SLUG_PATTERN`/`SLUG_MAX` (50) in `src/shared/utils/slugify.ts` (the single source of truth `workspaces.schemas.ts` also consumes); the validator additionally rejects reserved words and consecutive hyphens. The user supplies the slug (the client previews a derived one live, but the backend does not auto-derive); a taken slug surfaces `WORKSPACE_SLUG_TAKEN`. No backfill — slugs ship with workspaces. (Client ADR-005 had deferred slugs as "backend cost"; that reversed because shareable readable URLs like `/workspaces/acme-marketing/feeds` are materially better and the uniqueness cost proved small with no backfill.) + +Member-management endpoints (and the owner-only `deleteWorkspace`/`transferOwnership`) are not built (req #2); the `can()` seam and the membership collection make them additive (§10). + +### 7. Feeds are owned by workspaces + +`UserFeed` carries a real, indexed `workspaceId` (`ObjectId`). Feeds with `workspaceId` set belong to a workspace; `workspaceId: null` is personal. + +- **Authorization is membership-based.** Feed and connection handlers authorize workspace-feed operations by checking the caller is a member of the feed's workspace (via `WorkspaceMongooseRepository.listWorkspaceIdsForUser`). The §2 collection is the authz input, exactly as designed. +- **Workspace feeds have their own quota**, enforced via `SupportersService.getWorkspaceBenefits(workspaceId)` (today returns `defaultMaxWorkspaceFeeds` from `BACKEND_API_DEFAULT_MAX_WORKSPACE_FEEDS`, default 140). Counted via `countByWorkspace(workspaceId)`, never against the creator's personal quota. The seam takes a future workspace-level subscription lookup without changing the call shape. +- **Insulated from personal supporter perks.** Every enforcement query keyed on personal supporter benefits (refresh rate, daily-article limits, personal feed count) excludes workspace feeds with an explicit `{ workspaceId: null }` filter. Workspace feeds are governed only by `getWorkspaceBenefits`. +- **Scope isolation.** `getUserFeeds()` with a `workspaceId` returns only that workspace's feeds; without one, only personal feeds (`workspaceId: null`). A workspace's feeds never appear on the personal dashboard or vice versa. + +### 8. Self-host opt-in/out, licensing, and cost (req #6) + +**Single per-user gate:** + +| Layer | Mechanism | Audience | Default | +|---|---|---|---| +| **Per-user feature flag** | `User.featureFlags.workspaces` → `/users/@me` | self-hoster / hosted gradual rollout | off | + +`User.featureFlags.workspaces` is the sole gate. The workspace and email-verification routes always register; `requireWorkspacesFeatureHook` returns 404 for any user without the flag, so the feature is inert for everyone until the flag is set on a user. (An earlier design also had a deployment-level `BACKEND_API_WORKSPACES_FEATURE_ENABLED` env toggle; it was removed in favor of relying solely on the per-user flag, which already hides the feature and requires no env/compose change to enable per user.) SMTP stays optional — without it, email verification returns `EMAIL_VERIFICATION_UNAVAILABLE`. This clean opt-out is exactly what the no-Discord-coupling vertical slice (§1) buys. + +**Licensing/cost:** every dependency the feature adds is permissive (the native model is first-party; mail is nodemailer/MIT over operator SMTP; better-auth, if later adopted, is MIT). No proprietary email API, no managed auth, no metered/per-seat tier. A self-hosted instance needs only Mongo (already required) plus its own SMTP — both free. + +**Operational consequence — replica-set Mongo.** Two operations are transactional and so require a replica set: workspace *creation* writes the workspace + owner membership in one transaction (atomicity over a compensating-delete alternative), and workspace *deletion* (when built, §10) must resolve its feeds in the same transaction (default policy: block while feeds exist) so no `workspaceId` ever dangles. Mongo transactions require a replica set, so any deployment that *enables* workspaces must run Mongo as a replica set. Dev/test composes already do; `docker-compose.base.yml` runs standalone `mongod` and must migrate before workspaces is enabled there — a compose change deferred to a major release. Workspaces-disabled deployments (the default) are unaffected. + +### 9. Forward compatibility: other auth methods (seams now, no build) + +Keeping `discordUserId` out of the workspace model is what lets other logins be added later. Following the "design the seam, don't build the feature" stance: + +**Add now (cheap):** +- **Resolve identity to an internal `request.userId` at the auth boundary** (ideally stored in the session at login). New code consumes `request.userId`, not `request.discordUserId`; a future auth method becomes "a new way to populate `request.userId`" with no internal change. +- **`verifiedEmail` is unique** (§4) — the OTP send/confirm we build is most of an email/OTP login already; the only difference is that confirm would issue a session instead of setting a flag. + +**Deferred — ONE slot, filled ONE of two ways (not both):** an account/identities model mapping internal `userId` ← `(provider, providerAccountId)`, with Discord as the first record. When another method becomes real, pick either a native `identities` collection (lighter; right when existing users just link a second provider) **or** better-auth's `account` table (heavier; right for first-class non-Discord accounts, many providers, MFA — but takes ownership of `user`/`session`, §5). The seams above are identical for both, so deferring costs nothing. **The one path to avoid is building native and *then* migrating to better-auth** — let the leaning be set by an honest read of the trajectory. + +**Honest boundary:** the seams fully cover *linking a second login to an existing user* (everyone still has a `discordUserId`, so the legacy app is untouched). *First-class Discord-less accounts* additionally need the legacy delivery/supporter code decoupled from `discordUserId`, which stays deferred until a Discord-less user is a real requirement. + +### 10. Member invitations — tokenless, OTP-gated acceptance model + +**Status update (workspace invitations, 2026-06-07):** the invitation lifecycle described below supersedes the earlier draft in this section, which specified an accept-link token that would set `verifiedEmail`. That model is deliberately reversed because it would re-open the Discord-email-spoofing hole this ADR's §4 closes (see security invariant below). + +Two guardrails keep invitations consistent with the rest of the model: +- **`workspace_memberships` is `userId`-keyed** (§2) — a membership only ever exists for a real account. +- **Invitations are a separate, *email*-keyed `WorkspaceInvite` collection** — because at invite time the invitee may not have an account. You invite a person by email, not a Discord user. + +**Security invariant (pinned by regression test):** `verifiedEmail` is written **exclusively** by `EmailVerificationService.confirm` — the one-time-code flow (§4). The Discord OAuth sign-in path (`initDiscordUser`) writes only the `email` field, never `verifiedEmail`. This invariant holds for the entire invitation lifecycle: the invitation notification email contains a deep link keyed by invitation id; the link is a notification only and confers no authority on its own. **There is no accept-link token.** + +**Lifecycle when accepting:** accept and decline are both gated server-side on `user.verifiedEmail === invite.email`. The three cases: + +| `verifiedEmail` state | Result | +|---|---| +| unset | email-unverified error (carry invited email in payload) | +| set to a different address | email-mismatch error (carry both addresses in payload) | +| set to the invited email | proceed | + +Acceptance is transactional: delete the `WorkspaceInvite` row and insert the `WorkspaceMembership` row in one transaction (mirroring `createWorkspaceWithOwner`). The accepted role is stamped on the invitation and copied to the membership. + +**Why not an accept-link token that sets `verifiedEmail`:** an attacker can set their Discord account's email to any victim address. If clicking a forwarded or intercepted link could set `verifiedEmail`, the spoofing hole §4 closes would be re-opened. Proof of email control must always come from the OTP flow — which delivers a code to the inbox, not to the HTTP client. + +The `can(action, role)` seam (§3) covers the invitations authorization actions: `manageMembers` (invite / revoke) for owner and admin; `removeMember` (owner only) and `leaveWorkspace` (owner or admin) for membership mutations. The §2 `countOwners` check guards leave and remove to enforce the "at least one owner" invariant. + +Member removal and ownership transfer: removal runs inside a transaction with the owner-count re-check, rejecting with `CANNOT_REMOVE_LAST_OWNER` if the action would leave the workspace ownerless. Ownership transfer is deferred (v1 owners cannot leave a populated workspace). + +## Consequences + +**Easier:** +- "Workspaces I'm in" is one indexed query; the chooser is cheap. +- Adding a role is enum + `can()` clause — no migration. +- The workspace model has no Discord knowledge, so a provider swap doesn't touch it (ADR-004 alignment). +- A future better-auth migration is a table re-map, not a redesign. +- Invitations and other logins are additive (§9/§10), not rewrites. + +**Harder:** +- Two collections to keep consistent (create-workspace writes both in one transaction; reads treat an orphan workspace as inaccessible). +- A second identity concept (`userId` for workspaces vs `discordUserId` for the legacy app) lives in the codebase until the legacy app is decoupled (deferred, §9). Bridging handlers resolve `discordUserId → IUser.id` via the existing `findIdByDiscordId` (or the `request.userId` seam). + +**Lost:** +- The "everything keyed on `discordUserId`" simplicity — given up deliberately for provider-agnosticism. +- The out-of-the-box invitation/permission machinery better-auth would have provided — not a real loss at this scope (§10), and better-auth remains the escalation path (§5/§9). + +## Alternatives considered + +- **A flat `admin`/`member` role model with a read-only member tier.** Rejected — collaboration on feeds means every member needs to edit, so a read-only tier is friction without benefit. The real distinction is owner-only destructive actions (delete, transfer), which `owner`/`admin` captures (§3). The enum stays open-ended, so a finer model remains additive. +- **Embedded `members[]` on the workspace doc.** Rejected — the precedent keys on `discordUserId` (violates decoupling) and makes "my workspaces" a scan. +- **Key membership on `discordUserId` for feed consistency.** Rejected by maintainer constraint — couples workspaces to Discord, contradicts the destination roadmap. +- **Seed `emailVerified` from Discord's OAuth `verified` flag.** Rejected (§4) — a mutable OAuth claim proves Discord's belief, not mailbox control; can't anchor the gate. +- **Separate email + password to create a workspace.** Rejected (§4) — adds hash custody, reset flows, and credential-stuffing surface plus a second secret, for no gain over proving ownership once. +- **Magic link instead of OTP.** Rejected (§4) — links break cross-device and get consumed by scanners that prefetch URLs. Near-identical storage, so reversible; code is the default. +- **Hosted auth/SaaS (Auth0/Clerk/WorkOS) or a proprietary email API (SendGrid/Postmark), incl. better-auth's *managed* email service.** Rejected — proprietary and/or paid; gate orgs/SSO or email behind tiers, violating req #6. (Flagged because better-auth's managed email "is right there" if the library is adopted — it stays off; mail always goes through operator SMTP.) +- **Adopt better-auth's organization plugin now / build the identities model now.** Deferred (§5/§9) — speculative with only Discord; the cheap seams keep both additive. Re-evaluate at the trigger conditions. +- **Couple the workspaces toggle to a better-auth auth-core adoption.** Rejected — swapping the login system is platform-wide and major-version; the workspaces opt-in stays a small isolated module toggle. diff --git a/services/backend-api/package.json b/services/backend-api/package.json index 4856a547e..bc0d5539c 100644 --- a/services/backend-api/package.json +++ b/services/backend-api/package.json @@ -18,6 +18,7 @@ "@monitorss/contracts": "^0.1.0", "@monitorss/logger": "^1.1.2", "@sinclair/typebox": "^0.34.48", + "ajv-formats": "^3.0.1", "commander": "^12.0.0", "dayjs": "^1.11.19", "dotenv": "^17.2.3", diff --git a/services/backend-api/src/app.ts b/services/backend-api/src/app.ts index 5a410b36f..1f1e65349 100644 --- a/services/backend-api/src/app.ts +++ b/services/backend-api/src/app.ts @@ -14,6 +14,8 @@ import { BadRequestError, ApiErrorCode, } from "./infra/error-handler"; +import addFormats from "ajv-formats"; +import type Ajv from "ajv"; import { timezoneKeywordPlugin, dateLocaleKeywordPlugin, @@ -28,6 +30,9 @@ import { userFeedsRoutes } from "./features/user-feeds/user-feeds.routes"; import { supporterSubscriptionsRoutes } from "./features/supporter-subscriptions/supporter-subscriptions.routes"; import { userFeedManagementInvitesRoutes } from "./features/user-feed-management-invites/user-feed-management-invites.routes"; import { usersRoutes } from "./features/users/users.routes"; +import { emailVerificationRoutes } from "./features/users/email-verification.routes"; +import { workspacesRoutes } from "./features/workspaces/workspaces.routes"; +import { workspaceInvitesRoutes } from "./features/workspace-invites/workspace-invites.routes"; import { redditAuthRoutes } from "./features/reddit-auth/reddit-auth.routes"; import { errorReportsRoutes } from "./features/error-reports/error-reports.routes"; import { curatedFeedsRoutes } from "./features/curated-feeds/curated-feeds.routes"; @@ -38,6 +43,7 @@ declare module "fastify" { container: Container; accessToken: SessionAccessToken; discordUserId: string; + userId?: string; } } @@ -53,6 +59,10 @@ export async function createApp( removeAdditional: true, }, plugins: [ + // Passed by reference (its function name is "formatsPlugin") so + // @fastify/ajv-compiler detects it and skips its own duplicate + // ajv-formats registration. This enables `format: "email"` validation. + addFormats as unknown as (ajv: Ajv) => Ajv, timezoneKeywordPlugin, dateLocaleKeywordPlugin, hasAtLeastOneVisibleColumnPlugin, @@ -224,6 +234,15 @@ export async function createApp( // Users routes await instance.register(usersRoutes, { prefix: "/users" }); + // Workspaces. Access is gated per-user by the workspaces feature flag + // (requireWorkspacesFeatureHook), so the routes always register and a + // user without the flag gets a 404. + await instance.register(emailVerificationRoutes, { prefix: "/users" }); + await instance.register(workspacesRoutes, { prefix: "/workspaces" }); + await instance.register(workspaceInvitesRoutes, { + prefix: "/workspace-invites", + }); + // Reddit auth routes await instance.register(redditAuthRoutes, { prefix: "/reddit" }); diff --git a/services/backend-api/src/config.ts b/services/backend-api/src/config.ts index 5edd39d29..2c343f411 100644 --- a/services/backend-api/src/config.ts +++ b/services/backend-api/src/config.ts @@ -52,6 +52,10 @@ const configSchema = z.object({ BACKEND_API_DEFAULT_REFRESH_RATE_MINUTES: z.coerce.number().default(10), BACKEND_API_DEFAULT_MAX_FEEDS: z.coerce.number().default(5), BACKEND_API_DEFAULT_MAX_USER_FEEDS: z.coerce.number().default(5), + // Hardcoded workspace feed limit. Forward-compatible: a future + // workspace-level Paddle subscription will resolve this dynamically per + // workspace. + BACKEND_API_DEFAULT_MAX_WORKSPACE_FEEDS: z.coerce.number().default(140), BACKEND_API_DEFAULT_DATE_FORMAT: z .string() .default("ddd, D MMMM YYYY, h:mm A z"), @@ -86,6 +90,14 @@ const configSchema = z.object({ BACKEND_API_SMTP_USERNAME: z.string().optional(), BACKEND_API_SMTP_PASSWORD: z.string().optional(), BACKEND_API_SMTP_FROM: z.string().optional(), + BACKEND_API_SMTP_FROM_DOMAIN: z.string().optional(), + // Defaults target production SMTPS (implicit TLS on 465). Overridable so a + // local/test mailer can run plain SMTP on another port. + BACKEND_API_SMTP_PORT: z.coerce.number().optional(), + BACKEND_API_SMTP_SECURE: z + .string() + .transform((val) => val !== "false") + .default("true"), // Paddle BACKEND_API_PADDLE_KEY: z.string().optional(), @@ -129,7 +141,27 @@ const configSchema = z.object({ : [], ) .default(""), -}); +}) + .refine( + (cfg) => { + const smtpConfigured = Boolean( + cfg.BACKEND_API_SMTP_HOST && + cfg.BACKEND_API_SMTP_USERNAME && + cfg.BACKEND_API_SMTP_PASSWORD, + ); + if (!smtpConfigured) { + return true; + } + return Boolean( + cfg.BACKEND_API_SMTP_FROM || cfg.BACKEND_API_SMTP_FROM_DOMAIN, + ); + }, + { + message: + "When SMTP is configured, either BACKEND_API_SMTP_FROM or BACKEND_API_SMTP_FROM_DOMAIN must be set", + path: ["BACKEND_API_SMTP_FROM_DOMAIN"], + }, + ); export type Config = z.infer; diff --git a/services/backend-api/src/container.ts b/services/backend-api/src/container.ts index 4b8f2ab4f..65222c8b7 100644 --- a/services/backend-api/src/container.ts +++ b/services/backend-api/src/container.ts @@ -12,7 +12,6 @@ import type { IUserFeedLimitOverrideRepository } from "./repositories/interfaces import type { IPatronRepository } from "./repositories/interfaces/patron.types"; import type { INotificationDeliveryAttemptRepository } from "./repositories/interfaces/notification-delivery-attempt.types"; import type { IFeedSubscriberRepository } from "./repositories/interfaces/feed-subscriber.types"; -import type { IUserRepository } from "./repositories/interfaces/user.types"; import type { ICustomerRepository } from "./repositories/interfaces/customer.types"; import type { IFeedRepository } from "./repositories/interfaces/feed.types"; import type { IFeedFilteredFormatRepository } from "./repositories/interfaces/feed-filtered-format.types"; @@ -31,6 +30,8 @@ import { PatronMongooseRepository } from "./repositories/mongoose/patron.mongoos import { NotificationDeliveryAttemptMongooseRepository } from "./repositories/mongoose/notification-delivery-attempt.mongoose.repository"; import { FeedSubscriberMongooseRepository } from "./repositories/mongoose/feed-subscriber.mongoose.repository"; import { UserMongooseRepository } from "./repositories/mongoose/user.mongoose.repository"; +import { WorkspaceMongooseRepository } from "./repositories/mongoose/workspace.mongoose.repository"; +import { EmailVerificationMongooseRepository } from "./repositories/mongoose/email-verification.mongoose.repository"; import { CustomerMongooseRepository } from "./repositories/mongoose/customer.mongoose.repository"; import { FeedMongooseRepository } from "./repositories/mongoose/feed.mongoose.repository"; import { FeedFilteredFormatMongooseRepository } from "./repositories/mongoose/feed-filtered-format.mongoose.repository"; @@ -56,6 +57,8 @@ import { DiscordUsersService } from "./services/discord-users/discord-users.serv import { FeedSchedulingService } from "./services/feed-scheduling/feed-scheduling.service"; import { FeedsService } from "./services/feeds/feeds.service"; import { NotificationsService } from "./services/notifications/notifications.service"; +import { EmailVerificationService } from "./features/users/email-verification.service"; +import { WorkspacesService } from "./features/workspaces/workspaces.service"; import { DiscordServersService } from "./services/discord-servers/discord-servers.service"; import { UserFeedConnectionEventsService } from "./services/user-feed-connection-events/user-feed-connection-events.service"; import { MongoMigrationsService } from "./services/mongo-migrations/mongo-migrations.service"; @@ -91,7 +94,9 @@ export interface Container { patronRepository: IPatronRepository; notificationDeliveryAttemptRepository: INotificationDeliveryAttemptRepository; feedSubscriberRepository: IFeedSubscriberRepository; - userRepository: IUserRepository; + userRepository: UserMongooseRepository; + workspaceRepository: WorkspaceMongooseRepository; + emailVerificationRepository: EmailVerificationMongooseRepository; customerRepository: ICustomerRepository; feedRepository: IFeedRepository; feedFilteredFormatRepository: IFeedFilteredFormatRepository; @@ -122,6 +127,8 @@ export interface Container { feedSchedulingService: FeedSchedulingService; feedsService: FeedsService; notificationsService: NotificationsService; + emailVerificationService: EmailVerificationService; + workspacesService: WorkspacesService; discordServersService: DiscordServersService; userFeedConnectionEventsService: UserFeedConnectionEventsService; mongoMigrationsService: MongoMigrationsService; @@ -166,6 +173,12 @@ export function createContainer(deps: { deps.mongoConnection, ); const userRepository = new UserMongooseRepository(deps.mongoConnection); + const workspaceRepository = new WorkspaceMongooseRepository( + deps.mongoConnection, + ); + const emailVerificationRepository = new EmailVerificationMongooseRepository( + deps.mongoConnection, + ); const customerRepository = new CustomerMongooseRepository( deps.mongoConnection, ); @@ -254,6 +267,21 @@ export function createContainer(deps: { const smtpTransport = createSmtpTransport(deps.config); + const emailVerificationService = new EmailVerificationService({ + config: deps.config, + smtpTransport, + emailVerificationRepository, + userRepository, + }); + + const workspacesService = new WorkspacesService({ + config: deps.config, + smtpTransport, + workspaceRepository, + userRepository, + emailVerificationService, + }); + const notificationsService = new NotificationsService({ config: deps.config, smtpTransport, @@ -300,6 +328,7 @@ export function createContainer(deps: { userRepository, feedsService, supportersService, + workspacesService, feedFetcherApiService, feedFetcherService, feedHandlerService, @@ -382,6 +411,8 @@ export function createContainer(deps: { notificationDeliveryAttemptRepository, feedSubscriberRepository, userRepository, + workspaceRepository, + emailVerificationRepository, customerRepository, feedRepository, feedFilteredFormatRepository, @@ -412,6 +443,8 @@ export function createContainer(deps: { feedSchedulingService, feedsService, notificationsService, + emailVerificationService, + workspacesService, discordServersService, userFeedConnectionEventsService, mongoMigrationsService, diff --git a/services/backend-api/src/features/feed-connections/feed-connections.handlers.ts b/services/backend-api/src/features/feed-connections/feed-connections.handlers.ts index 8ebdf4985..8052cff02 100644 --- a/services/backend-api/src/features/feed-connections/feed-connections.handlers.ts +++ b/services/backend-api/src/features/feed-connections/feed-connections.handlers.ts @@ -29,6 +29,10 @@ import type { CreateTemplatePreviewBody, UpdateDiscordChannelConnectionBody, } from "./feed-connections.schemas"; +import { + resolveFeedForRequester, + canAccessConnection, +} from "../../shared/utils/feed-access"; export function formatDiscordChannelConnectionResponse( con: IDiscordChannelConnection, @@ -85,46 +89,14 @@ export async function deleteDiscordChannelConnectionHandler( }>, reply: FastifyReply, ): Promise { - const { - userFeedRepository, - feedConnectionsDiscordChannelsService, - usersService, - config, - } = request.container; + const { feedConnectionsDiscordChannelsService } = request.container; const { discordUserId } = request; const { feedId, connectionId } = request.params; - if (!userFeedRepository.areAllValidIds([feedId])) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } - - const user = await usersService.getOrCreateUserByDiscordId(discordUserId); - const isAdmin = config.BACKEND_API_ADMIN_USER_IDS.includes(user.id); + const { feed, isAdmin } = await resolveFeedForRequester(request, feedId); - const feed = isAdmin - ? await userFeedRepository.findById(feedId) - : await userFeedRepository.findByIdAndOwnership(feedId, discordUserId); - - if (!feed) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } - - const isOwner = feed.user.discordUserId === discordUserId; - if (!isAdmin && !isOwner) { - const invite = feed.shareManageOptions?.invites.find( - (i) => i.discordUserId === discordUserId, - ); - const allowedConnectionIds = invite?.connections?.map( - (c) => c.connectionId, - ); - - if ( - allowedConnectionIds && - allowedConnectionIds.length > 0 && - !allowedConnectionIds.includes(connectionId) - ) { - throw new NotFoundError(ApiErrorCode.FEED_CONNECTION_NOT_FOUND); - } + if (!canAccessConnection(feed, discordUserId, isAdmin, connectionId)) { + throw new NotFoundError(ApiErrorCode.FEED_CONNECTION_NOT_FOUND); } const connection = feed.connections.discordChannels.find( @@ -153,27 +125,12 @@ export async function createDiscordChannelConnectionHandler( const { userFeedRepository, feedConnectionsDiscordChannelsService, - usersService, - config, messageBrokerEventsService, } = request.container; const { discordUserId, accessToken } = request; const { feedId } = request.params; - if (!userFeedRepository.areAllValidIds([feedId])) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } - - const user = await usersService.getOrCreateUserByDiscordId(discordUserId); - const isAdmin = config.BACKEND_API_ADMIN_USER_IDS.includes(user.id); - - const feed = isAdmin - ? await userFeedRepository.findById(feedId) - : await userFeedRepository.findByIdAndOwnership(feedId, discordUserId); - - if (!feed) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } + const { feed } = await resolveFeedForRequester(request, feedId); const { name, @@ -236,46 +193,14 @@ export async function sendTestArticleHandler( }>, reply: FastifyReply, ): Promise { - const { - userFeedRepository, - feedConnectionsDiscordChannelsService, - usersService, - config, - } = request.container; + const { feedConnectionsDiscordChannelsService } = request.container; const { discordUserId } = request; const { feedId, connectionId } = request.params; - if (!userFeedRepository.areAllValidIds([feedId])) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } - - const user = await usersService.getOrCreateUserByDiscordId(discordUserId); - const isAdmin = config.BACKEND_API_ADMIN_USER_IDS.includes(user.id); + const { feed, isAdmin } = await resolveFeedForRequester(request, feedId); - const feed = isAdmin - ? await userFeedRepository.findById(feedId) - : await userFeedRepository.findByIdAndOwnership(feedId, discordUserId); - - if (!feed) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } - - const isOwner = feed.user.discordUserId === discordUserId; - if (!isAdmin && !isOwner) { - const invite = feed.shareManageOptions?.invites.find( - (i) => i.discordUserId === discordUserId, - ); - const allowedConnectionIds = invite?.connections?.map( - (c) => c.connectionId, - ); - - if ( - allowedConnectionIds && - allowedConnectionIds.length > 0 && - !allowedConnectionIds.includes(connectionId) - ) { - throw new NotFoundError(ApiErrorCode.FEED_CONNECTION_NOT_FOUND); - } + if (!canAccessConnection(feed, discordUserId, isAdmin, connectionId)) { + throw new NotFoundError(ApiErrorCode.FEED_CONNECTION_NOT_FOUND); } const connection = feed.connections.discordChannels.find( @@ -328,46 +253,14 @@ export async function copyConnectionSettingsHandler( }>, reply: FastifyReply, ): Promise { - const { - userFeedRepository, - feedConnectionsDiscordChannelsService, - usersService, - config, - } = request.container; + const { feedConnectionsDiscordChannelsService } = request.container; const { discordUserId, accessToken } = request; const { feedId, connectionId } = request.params; - if (!userFeedRepository.areAllValidIds([feedId])) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } - - const user = await usersService.getOrCreateUserByDiscordId(discordUserId); - const isAdmin = config.BACKEND_API_ADMIN_USER_IDS.includes(user.id); + const { feed, isAdmin } = await resolveFeedForRequester(request, feedId); - const feed = isAdmin - ? await userFeedRepository.findById(feedId) - : await userFeedRepository.findByIdAndOwnership(feedId, discordUserId); - - if (!feed) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } - - const isOwner = feed.user.discordUserId === discordUserId; - if (!isAdmin && !isOwner) { - const invite = feed.shareManageOptions?.invites.find( - (i) => i.discordUserId === discordUserId, - ); - const allowedConnectionIds = invite?.connections?.map( - (c) => c.connectionId, - ); - - if ( - allowedConnectionIds && - allowedConnectionIds.length > 0 && - !allowedConnectionIds.includes(connectionId) - ) { - throw new NotFoundError(ApiErrorCode.FEED_CONNECTION_NOT_FOUND); - } + if (!canAccessConnection(feed, discordUserId, isAdmin, connectionId)) { + throw new NotFoundError(ApiErrorCode.FEED_CONNECTION_NOT_FOUND); } const connection = feed.connections.discordChannels.find( @@ -396,46 +289,14 @@ export async function cloneConnectionHandler( }>, reply: FastifyReply, ): Promise { - const { - userFeedRepository, - feedConnectionsDiscordChannelsService, - usersService, - config, - } = request.container; + const { feedConnectionsDiscordChannelsService } = request.container; const { discordUserId, accessToken } = request; const { feedId, connectionId } = request.params; - if (!userFeedRepository.areAllValidIds([feedId])) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } - - const user = await usersService.getOrCreateUserByDiscordId(discordUserId); - const isAdmin = config.BACKEND_API_ADMIN_USER_IDS.includes(user.id); - - const feed = isAdmin - ? await userFeedRepository.findById(feedId) - : await userFeedRepository.findByIdAndOwnership(feedId, discordUserId); - - if (!feed) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } - - const isOwner = feed.user.discordUserId === discordUserId; - if (!isAdmin && !isOwner) { - const invite = feed.shareManageOptions?.invites.find( - (i) => i.discordUserId === discordUserId, - ); - const allowedConnectionIds = invite?.connections?.map( - (c) => c.connectionId, - ); + const { feed, isAdmin } = await resolveFeedForRequester(request, feedId); - if ( - allowedConnectionIds && - allowedConnectionIds.length > 0 && - !allowedConnectionIds.includes(connectionId) - ) { - throw new NotFoundError(ApiErrorCode.FEED_CONNECTION_NOT_FOUND); - } + if (!canAccessConnection(feed, discordUserId, isAdmin, connectionId)) { + throw new NotFoundError(ApiErrorCode.FEED_CONNECTION_NOT_FOUND); } const connection = feed.connections.discordChannels.find( @@ -482,46 +343,14 @@ export async function createPreviewHandler( }>, reply: FastifyReply, ): Promise { - const { - userFeedRepository, - feedConnectionsDiscordChannelsService, - usersService, - config, - } = request.container; + const { feedConnectionsDiscordChannelsService } = request.container; const { discordUserId } = request; const { feedId, connectionId } = request.params; - if (!userFeedRepository.areAllValidIds([feedId])) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } + const { feed, isAdmin } = await resolveFeedForRequester(request, feedId); - const user = await usersService.getOrCreateUserByDiscordId(discordUserId); - const isAdmin = config.BACKEND_API_ADMIN_USER_IDS.includes(user.id); - - const feed = isAdmin - ? await userFeedRepository.findById(feedId) - : await userFeedRepository.findByIdAndOwnership(feedId, discordUserId); - - if (!feed) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } - - const isOwner = feed.user.discordUserId === discordUserId; - if (!isAdmin && !isOwner) { - const invite = feed.shareManageOptions?.invites.find( - (i) => i.discordUserId === discordUserId, - ); - const allowedConnectionIds = invite?.connections?.map( - (c) => c.connectionId, - ); - - if ( - allowedConnectionIds && - allowedConnectionIds.length > 0 && - !allowedConnectionIds.includes(connectionId) - ) { - throw new NotFoundError(ApiErrorCode.FEED_CONNECTION_NOT_FOUND); - } + if (!canAccessConnection(feed, discordUserId, isAdmin, connectionId)) { + throw new NotFoundError(ApiErrorCode.FEED_CONNECTION_NOT_FOUND); } const connection = feed.connections.discordChannels.find( @@ -571,29 +400,10 @@ export async function createTemplatePreviewHandler( }>, reply: FastifyReply, ): Promise { - const { - userFeedRepository, - feedConnectionsDiscordChannelsService, - usersService, - config, - } = request.container; - const { discordUserId } = request; + const { feedConnectionsDiscordChannelsService } = request.container; const { feedId } = request.params; - if (!userFeedRepository.areAllValidIds([feedId])) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } - - const user = await usersService.getOrCreateUserByDiscordId(discordUserId); - const isAdmin = config.BACKEND_API_ADMIN_USER_IDS.includes(user.id); - - const feed = isAdmin - ? await userFeedRepository.findById(feedId) - : await userFeedRepository.findByIdAndOwnership(feedId, discordUserId); - - if (!feed) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } + const { feed } = await resolveFeedForRequester(request, feedId); const body = request.body; @@ -623,46 +433,14 @@ export async function updateDiscordChannelConnectionHandler( }>, reply: FastifyReply, ): Promise { - const { - userFeedRepository, - feedConnectionsDiscordChannelsService, - usersService, - config, - } = request.container; + const { feedConnectionsDiscordChannelsService } = request.container; const { discordUserId, accessToken } = request; const { feedId, connectionId } = request.params; - if (!userFeedRepository.areAllValidIds([feedId])) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } - - const user = await usersService.getOrCreateUserByDiscordId(discordUserId); - const isAdmin = config.BACKEND_API_ADMIN_USER_IDS.includes(user.id); - - const feed = isAdmin - ? await userFeedRepository.findById(feedId) - : await userFeedRepository.findByIdAndOwnership(feedId, discordUserId); - - if (!feed) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } + const { feed, isAdmin } = await resolveFeedForRequester(request, feedId); - const isOwner = feed.user.discordUserId === discordUserId; - if (!isAdmin && !isOwner) { - const invite = feed.shareManageOptions?.invites.find( - (i) => i.discordUserId === discordUserId, - ); - const allowedConnectionIds = invite?.connections?.map( - (c) => c.connectionId, - ); - - if ( - allowedConnectionIds && - allowedConnectionIds.length > 0 && - !allowedConnectionIds.includes(connectionId) - ) { - throw new NotFoundError(ApiErrorCode.FEED_CONNECTION_NOT_FOUND); - } + if (!canAccessConnection(feed, discordUserId, isAdmin, connectionId)) { + throw new NotFoundError(ApiErrorCode.FEED_CONNECTION_NOT_FOUND); } const connection = feed.connections.discordChannels.find( diff --git a/services/backend-api/src/features/user-feed-management-invites/user-feed-management-invites.exception-codes.ts b/services/backend-api/src/features/user-feed-management-invites/user-feed-management-invites.exception-codes.ts index 26f95fe42..bb1477ef3 100644 --- a/services/backend-api/src/features/user-feed-management-invites/user-feed-management-invites.exception-codes.ts +++ b/services/backend-api/src/features/user-feed-management-invites/user-feed-management-invites.exception-codes.ts @@ -10,6 +10,10 @@ export const CREATE_INVITE_EXCEPTION_ERROR_CODES: ExceptionErrorCodes = { status: 400, code: ApiErrorCode.USER_FEED_TRANSFER_REQUEST_EXISTS, }, + WorkspaceFeedSharingDisabledException: { + status: 403, + code: ApiErrorCode.WORKSPACE_FEED_SHARING_DISABLED, + }, }; export const UPDATE_INVITE_EXCEPTION_ERROR_CODES: ExceptionErrorCodes = { diff --git a/services/backend-api/src/features/user-feeds/user-feeds.handlers.ts b/services/backend-api/src/features/user-feeds/user-feeds.handlers.ts index 068694a13..0f580a44d 100644 --- a/services/backend-api/src/features/user-feeds/user-feeds.handlers.ts +++ b/services/backend-api/src/features/user-feeds/user-feeds.handlers.ts @@ -31,6 +31,11 @@ import type { } from "./user-feeds.schemas"; import { UpdateUserFeedsOp } from "./user-feeds.schemas"; import { UserFeedTargetFeedSelectionType } from "../../services/feed-connections-discord-channels/types"; +import { + getRequesterWorkspaceIds, + hasFullFeedAccess, + resolveFeedForRequester, +} from "../../shared/utils/feed-access"; import type { GetUserFeedsInputFilters, GetUserFeedsInputSortKey, @@ -58,7 +63,7 @@ export async function createUserFeedHandler( const { userFeedsService, supportersService, curatedFeedRepository } = request.container; const { discordUserId, accessToken } = request; - const { url, curatedFeedId, title, sourceFeedId } = request.body; + const { url, curatedFeedId, title, sourceFeedId, workspaceId } = request.body; if (!!url === !!curatedFeedId) { throw new BadRequestError( @@ -90,6 +95,7 @@ export async function createUserFeedHandler( url: resolvedUrl!, title: resolvedTitle, sourceFeedId, + workspaceId, }, ); @@ -153,6 +159,7 @@ export async function formatUserFeedResponse( return { id: feed.id, + isWorkspaceFeed: !!feed.workspaceId, sharedAccessDetails: userInviteId ? { inviteId: userInviteId } : undefined, title: feed.title, url: feed.url, @@ -175,7 +182,10 @@ export async function formatUserFeedResponse( ) ).refreshRateSeconds, userRefreshRateSeconds: feed.userRefreshRateSeconds, - shareManageOptions: isOwner ? feed.shareManageOptions : undefined, + // Workspace feeds use workspace membership for access, not per-user share invites, + // so the sharing UI is never surfaced for them. + shareManageOptions: + isOwner && !feed.workspaceId ? feed.shareManageOptions : undefined, refreshRateOptions, }; } @@ -233,12 +243,14 @@ export async function updateUserFeedsHandler( const user = await usersService.getOrCreateUserByDiscordId(discordUserId); const isAdmin = config.BACKEND_API_ADMIN_USER_IDS.includes(user.id); + const myWorkspaceIds = await getRequesterWorkspaceIds(request, user); const authorizedFeedIds = isAdmin ? requestedFeedIds : await userFeedRepository.filterFeedIdsByOwnership( requestedFeedIds, discordUserId, + myWorkspaceIds, ); if (op === UpdateUserFeedsOp.BulkDelete) { @@ -263,25 +275,11 @@ export async function getUserFeedHandler( request: FastifyRequest<{ Params: GetUserFeedParams }>, reply: FastifyReply, ): Promise { - const { userFeedRepository, usersService, supportersService, config } = - request.container; + const { supportersService } = request.container; const { discordUserId } = request; const { feedId } = request.params; - if (!userFeedRepository.areAllValidIds([feedId])) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } - - const user = await usersService.getOrCreateUserByDiscordId(discordUserId); - const isAdmin = config.BACKEND_API_ADMIN_USER_IDS.includes(user.id); - - const feed = isAdmin - ? await userFeedRepository.findById(feedId) - : await userFeedRepository.findByIdAndOwnership(feedId, discordUserId); - - if (!feed) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } + const { feed } = await resolveFeedForRequester(request, feedId); const acceptedInvite = feed.shareManageOptions?.invites?.find( (inv) => @@ -315,29 +313,14 @@ export async function updateUserFeedHandler( }>, reply: FastifyReply, ): Promise { - const { - userFeedRepository, - userFeedsService, - usersService, - supportersService, - config, - } = request.container; + const { userFeedsService, supportersService } = request.container; const { discordUserId } = request; const { feedId } = request.params; - if (!userFeedRepository.areAllValidIds([feedId])) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } - - const user = await usersService.getOrCreateUserByDiscordId(discordUserId); - const isAdmin = config.BACKEND_API_ADMIN_USER_IDS.includes(user.id); + const { feed, user } = await resolveFeedForRequester(request, feedId); - const feed = isAdmin - ? await userFeedRepository.findById(feedId) - : await userFeedRepository.findByIdAndOwnership(feedId, discordUserId); - - if (!feed) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); + if (request.body.shareManageOptions && feed.workspaceId) { + throw new ForbiddenError(ApiErrorCode.WORKSPACE_FEED_SHARING_DISABLED); } if (request.body.externalProperties) { @@ -386,37 +369,17 @@ export async function deleteUserFeedHandler( request: FastifyRequest<{ Params: GetUserFeedParams }>, reply: FastifyReply, ): Promise { - const { userFeedRepository, userFeedsService, usersService, config } = - request.container; + const { userFeedsService } = request.container; const { discordUserId } = request; const { feedId } = request.params; - if (!userFeedRepository.areAllValidIds([feedId])) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } - - const user = await usersService.getOrCreateUserByDiscordId(discordUserId); - const isAdmin = config.BACKEND_API_ADMIN_USER_IDS.includes(user.id); - - const feed = isAdmin - ? await userFeedRepository.findById(feedId) - : await userFeedRepository.findByIdAndCreator(feedId, discordUserId); + const { feed, isAdmin } = await resolveFeedForRequester(request, feedId); - if (!feed) { - if (!isAdmin) { - const feedByOwnership = await userFeedRepository.findByIdAndOwnership( - feedId, - discordUserId, - ); - - if (feedByOwnership) { - throw new ForbiddenError( - ApiErrorCode.MISSING_SHARED_MANAGER_PERMISSIONS, - ); - } - } - - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); + // Any workspace member may delete a workspace feed. For a personal feed, + // only the creator may delete it — a personal share-invite co-manager + // resolves the feed via ownership but cannot delete it. + if (!hasFullFeedAccess(feed, discordUserId, isAdmin)) { + throw new ForbiddenError(ApiErrorCode.MISSING_SHARED_MANAGER_PERMISSIONS); } await userFeedsService.deleteFeedById(feedId); @@ -431,25 +394,11 @@ export async function cloneUserFeedHandler( }>, reply: FastifyReply, ): Promise { - const { userFeedRepository, userFeedsService, usersService, config } = - request.container; + const { userFeedsService } = request.container; const { discordUserId, accessToken } = request; const { feedId } = request.params; - if (!userFeedRepository.areAllValidIds([feedId])) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } - - const user = await usersService.getOrCreateUserByDiscordId(discordUserId); - const isAdmin = config.BACKEND_API_ADMIN_USER_IDS.includes(user.id); - - const feed = isAdmin - ? await userFeedRepository.findById(feedId) - : await userFeedRepository.findByIdAndOwnership(feedId, discordUserId); - - if (!feed) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } + const { user } = await resolveFeedForRequester(request, feedId); const { id } = await userFeedsService.clone( feedId, @@ -468,30 +417,12 @@ export async function sendTestArticleHandler( }>, reply: FastifyReply, ): Promise { - const { - feedsService, - feedConnectionsDiscordChannelsService, - userFeedRepository, - usersService, - config, - } = request.container; + const { feedsService, feedConnectionsDiscordChannelsService } = + request.container; const { discordUserId, accessToken } = request; const { feedId } = request.params; - if (!userFeedRepository.areAllValidIds([feedId])) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } - - const user = await usersService.getOrCreateUserByDiscordId(discordUserId); - const isAdmin = config.BACKEND_API_ADMIN_USER_IDS.includes(user.id); - - const feed = isAdmin - ? await userFeedRepository.findById(feedId) - : await userFeedRepository.findByIdAndOwnership(feedId, discordUserId); - - if (!feed) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } + const { feed } = await resolveFeedForRequester(request, feedId); await feedsService.canUseChannel({ channelId: request.body.channelId, @@ -522,25 +453,10 @@ export async function getFeedRequestsHandler( }>, reply: FastifyReply, ): Promise { - const { userFeedRepository, userFeedsService, usersService, config } = - request.container; - const { discordUserId } = request; + const { userFeedsService } = request.container; const { feedId } = request.params; - if (!userFeedRepository.areAllValidIds([feedId])) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } - - const user = await usersService.getOrCreateUserByDiscordId(discordUserId); - const isAdmin = config.BACKEND_API_ADMIN_USER_IDS.includes(user.id); - - const feed = isAdmin - ? await userFeedRepository.findById(feedId) - : await userFeedRepository.findByIdAndOwnership(feedId, discordUserId); - - if (!feed) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } + const { feed, user } = await resolveFeedForRequester(request, feedId); const result = await userFeedsService.getFeedRequests({ feed, @@ -559,25 +475,10 @@ export async function getDeliveryLogsHandler( }>, reply: FastifyReply, ): Promise { - const { userFeedRepository, userFeedsService, usersService, config } = - request.container; - const { discordUserId } = request; + const { userFeedsService } = request.container; const { feedId } = request.params; - if (!userFeedRepository.areAllValidIds([feedId])) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } - - const user = await usersService.getOrCreateUserByDiscordId(discordUserId); - const isAdmin = config.BACKEND_API_ADMIN_USER_IDS.includes(user.id); - - const feed = isAdmin - ? await userFeedRepository.findById(feedId) - : await userFeedRepository.findByIdAndOwnership(feedId, discordUserId); - - if (!feed) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } + const { feed } = await resolveFeedForRequester(request, feedId); const result = await userFeedsService.getDeliveryLogs(feed.id, { limit: request.query.limit ?? 25, @@ -614,25 +515,10 @@ export async function getArticlePropertiesHandler( }>, reply: FastifyReply, ): Promise { - const { userFeedRepository, userFeedsService, usersService, config } = - request.container; - const { discordUserId } = request; + const { userFeedsService } = request.container; const { feedId } = request.params; - if (!userFeedRepository.areAllValidIds([feedId])) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } - - const user = await usersService.getOrCreateUserByDiscordId(discordUserId); - const isAdmin = config.BACKEND_API_ADMIN_USER_IDS.includes(user.id); - - const feed = isAdmin - ? await userFeedRepository.findById(feedId) - : await userFeedRepository.findByIdAndOwnership(feedId, discordUserId); - - if (!feed) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } + const { feed, user } = await resolveFeedForRequester(request, feedId); const { properties, requestStatus } = await userFeedsService.getFeedArticleProperties({ @@ -659,25 +545,10 @@ export async function getArticlesHandler( }>, reply: FastifyReply, ): Promise { - const { userFeedRepository, userFeedsService, usersService, config } = - request.container; - const { discordUserId } = request; + const { userFeedsService } = request.container; const { feedId } = request.params; - if (!userFeedRepository.areAllValidIds([feedId])) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } - - const user = await usersService.getOrCreateUserByDiscordId(discordUserId); - const isAdmin = config.BACKEND_API_ADMIN_USER_IDS.includes(user.id); - - const feed = isAdmin - ? await userFeedRepository.findById(feedId) - : await userFeedRepository.findByIdAndOwnership(feedId, discordUserId); - - if (!feed) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } + const { feed, user } = await resolveFeedForRequester(request, feedId); const { limit, @@ -744,25 +615,11 @@ export async function deliveryPreviewHandler( }>, reply: FastifyReply, ): Promise { - const { userFeedRepository, userFeedsService, usersService, config } = - request.container; + const { userFeedsService } = request.container; const { discordUserId } = request; const { feedId } = request.params; - if (!userFeedRepository.areAllValidIds([feedId])) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } - - const user = await usersService.getOrCreateUserByDiscordId(discordUserId); - const isAdmin = config.BACKEND_API_ADMIN_USER_IDS.includes(user.id); - - const feed = isAdmin - ? await userFeedRepository.findById(feedId) - : await userFeedRepository.findByIdAndOwnership(feedId, discordUserId); - - if (!feed) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } + const { feed, user } = await resolveFeedForRequester(request, feedId); const acceptedInvite = feed.shareManageOptions?.invites?.find( (inv) => @@ -794,25 +651,10 @@ export async function getDailyLimitHandler( request: FastifyRequest<{ Params: GetUserFeedParams }>, reply: FastifyReply, ): Promise { - const { userFeedRepository, userFeedsService, usersService, config } = - request.container; - const { discordUserId } = request; + const { userFeedsService } = request.container; const { feedId } = request.params; - if (!userFeedRepository.areAllValidIds([feedId])) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } - - const user = await usersService.getOrCreateUserByDiscordId(discordUserId); - const isAdmin = config.BACKEND_API_ADMIN_USER_IDS.includes(user.id); - - const feed = isAdmin - ? await userFeedRepository.findById(feedId) - : await userFeedRepository.findByIdAndOwnership(feedId, discordUserId); - - if (!feed) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } + const { feed } = await resolveFeedForRequester(request, feedId); const { progress, max } = await userFeedsService.getFeedDailyLimit(feed); @@ -823,25 +665,10 @@ export async function manualRequestHandler( request: FastifyRequest<{ Params: GetUserFeedParams }>, reply: FastifyReply, ): Promise { - const { userFeedRepository, userFeedsService, usersService, config } = - request.container; - const { discordUserId } = request; + const { userFeedsService } = request.container; const { feedId } = request.params; - if (!userFeedRepository.areAllValidIds([feedId])) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } - - const user = await usersService.getOrCreateUserByDiscordId(discordUserId); - const isAdmin = config.BACKEND_API_ADMIN_USER_IDS.includes(user.id); - - const feed = isAdmin - ? await userFeedRepository.findById(feedId) - : await userFeedRepository.findByIdAndOwnership(feedId, discordUserId); - - if (!feed) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } + const { feed } = await resolveFeedForRequester(request, feedId); try { const { @@ -923,11 +750,18 @@ export async function getUserFeedsHandler( request: FastifyRequest<{ Querystring: GetUserFeedsQuery }>, reply: FastifyReply, ): Promise { - const { userFeedsService, usersService } = request.container; + const { userFeedsService, usersService, workspacesService } = request.container; const { discordUserId } = request; + const { workspaceId } = request.query; const user = await usersService.getOrCreateUserByDiscordId(discordUserId); + if (workspaceId) { + // Verify membership before scoping to the workspace. Non-members (or unknown + // workspaces) get 404 WORKSPACE_NOT_FOUND — no existence leak. + await workspacesService.getWorkspaceForMember(workspaceId, user.id); + } + const rawQuery = request.url.split("?")[1] || ""; const parsed = qs.parse(rawQuery); const filters = parseFilters(parsed.filters); @@ -940,12 +774,17 @@ export async function getUserFeedsHandler( | GetUserFeedsInputSortKey | undefined, filters, + workspaceId, }; const [feeds, count, feedsWithoutConnectionsCount] = await Promise.all([ userFeedsService.getFeedsByUser(user.id, discordUserId, input), userFeedsService.getFeedCountByUser(user.id, discordUserId, input), - userFeedsService.getFeedsWithoutConnectionsCount(user.id, discordUserId), + userFeedsService.getFeedsWithoutConnectionsCount( + user.id, + discordUserId, + workspaceId, + ), ]); return reply.status(200).send({ @@ -975,25 +814,11 @@ export async function copySettingsHandler( }>, reply: FastifyReply, ): Promise { - const { userFeedRepository, userFeedsService, usersService, config } = - request.container; + const { userFeedsService } = request.container; const { discordUserId } = request; const { feedId } = request.params; - if (!userFeedRepository.areAllValidIds([feedId])) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } - - const user = await usersService.getOrCreateUserByDiscordId(discordUserId); - const isAdmin = config.BACKEND_API_ADMIN_USER_IDS.includes(user.id); - - const feed = isAdmin - ? await userFeedRepository.findById(feedId) - : await userFeedRepository.findByIdAndOwnership(feedId, discordUserId); - - if (!feed) { - throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); - } + const { feed } = await resolveFeedForRequester(request, feedId); const { targetFeedSelectionType, targetFeedIds } = request.body; diff --git a/services/backend-api/src/features/user-feeds/user-feeds.schemas.ts b/services/backend-api/src/features/user-feeds/user-feeds.schemas.ts index 981b19dfd..c25508787 100644 --- a/services/backend-api/src/features/user-feeds/user-feeds.schemas.ts +++ b/services/backend-api/src/features/user-feeds/user-feeds.schemas.ts @@ -17,6 +17,9 @@ export const CreateUserFeedBodySchema = Type.Object({ curatedFeedId: Type.Optional(Type.String({ minLength: 1 })), title: Type.Optional(Type.String()), sourceFeedId: Type.Optional(Type.String()), + // When set, the feed is created under this workspace. Membership is verified + // server-side; non-members get 404. + workspaceId: Type.Optional(Type.String({ minLength: 1 })), }); export type CreateUserFeedBody = Static; @@ -585,5 +588,8 @@ export const GetUserFeedsQuerySchema = Type.Object({ enum: ["", ...Object.values(GetUserFeedsInputSortKey)], }), ), + // When set, lists feeds for this workspace instead of the user's personal feeds. + // Membership is verified server-side; non-members get 404. + workspaceId: Type.Optional(Type.String({ minLength: 1 })), }); export type GetUserFeedsQuery = Static; diff --git a/services/backend-api/src/features/users/email-verification.handlers.ts b/services/backend-api/src/features/users/email-verification.handlers.ts new file mode 100644 index 000000000..f2eee3356 --- /dev/null +++ b/services/backend-api/src/features/users/email-verification.handlers.ts @@ -0,0 +1,42 @@ +import type { FastifyRequest, FastifyReply } from "fastify"; +import { NotFoundError, ApiErrorCode } from "../../infra/error-handler"; +import type { + SendEmailVerificationBody, + ConfirmEmailVerificationBody, +} from "./email-verification.schemas"; + +export async function sendEmailVerificationHandler( + request: FastifyRequest<{ Body: SendEmailVerificationBody }>, + reply: FastifyReply, +): Promise { + const { userRepository, emailVerificationService } = request.container; + const userId = await userRepository.findIdByDiscordId(request.discordUserId); + + if (!userId) { + throw new NotFoundError(ApiErrorCode.USER_NOT_FOUND); + } + + await emailVerificationService.sendCode(userId, request.body.email); + + return reply.send({ result: { ok: true } }); +} + +export async function confirmEmailVerificationHandler( + request: FastifyRequest<{ Body: ConfirmEmailVerificationBody }>, + reply: FastifyReply, +): Promise { + const { userRepository, emailVerificationService } = request.container; + const userId = await userRepository.findIdByDiscordId(request.discordUserId); + + if (!userId) { + throw new NotFoundError(ApiErrorCode.USER_NOT_FOUND); + } + + await emailVerificationService.confirm( + userId, + request.body.email, + request.body.code, + ); + + return reply.send({ result: { ok: true } }); +} diff --git a/services/backend-api/src/features/users/email-verification.routes.ts b/services/backend-api/src/features/users/email-verification.routes.ts new file mode 100644 index 000000000..d78f1bb3b --- /dev/null +++ b/services/backend-api/src/features/users/email-verification.routes.ts @@ -0,0 +1,42 @@ +import type { FastifyInstance } from "fastify"; +import rateLimit from "@fastify/rate-limit"; +import { requireAuthHook } from "../../infra/auth"; +import { requireWorkspacesFeatureHook } from "../workspaces/workspaces.hooks"; +import { + sendEmailVerificationHandler, + confirmEmailVerificationHandler, +} from "./email-verification.handlers"; +import { + SendEmailVerificationBodySchema, + ConfirmEmailVerificationBodySchema, +} from "./email-verification.schemas"; + +export async function emailVerificationRoutes( + app: FastifyInstance, +): Promise { + await app.register(rateLimit, { global: false }); + + app.post("/@me/email-verification", { + preHandler: [requireAuthHook, requireWorkspacesFeatureHook], + schema: { body: SendEmailVerificationBodySchema }, + config: { + rateLimit: { + max: 10, + timeWindow: "1 hour", + }, + }, + handler: sendEmailVerificationHandler, + }); + + app.post("/@me/email-verification/confirm", { + preHandler: [requireAuthHook, requireWorkspacesFeatureHook], + schema: { body: ConfirmEmailVerificationBodySchema }, + config: { + rateLimit: { + max: 20, + timeWindow: "1 hour", + }, + }, + handler: confirmEmailVerificationHandler, + }); +} diff --git a/services/backend-api/src/features/users/email-verification.schemas.ts b/services/backend-api/src/features/users/email-verification.schemas.ts new file mode 100644 index 000000000..bc08bae25 --- /dev/null +++ b/services/backend-api/src/features/users/email-verification.schemas.ts @@ -0,0 +1,29 @@ +import { Type, type Static } from "@sinclair/typebox"; + +const EmailSchema = Type.String({ + maxLength: 254, + pattern: "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", +}); + +export const SendEmailVerificationBodySchema = Type.Object( + { + email: EmailSchema, + }, + { additionalProperties: false }, +); + +export type SendEmailVerificationBody = Static< + typeof SendEmailVerificationBodySchema +>; + +export const ConfirmEmailVerificationBodySchema = Type.Object( + { + email: EmailSchema, + code: Type.String({ pattern: "^[0-9]{6}$" }), + }, + { additionalProperties: false }, +); + +export type ConfirmEmailVerificationBody = Static< + typeof ConfirmEmailVerificationBodySchema +>; diff --git a/services/backend-api/src/features/users/email-verification.service.ts b/services/backend-api/src/features/users/email-verification.service.ts new file mode 100644 index 000000000..5ee07257b --- /dev/null +++ b/services/backend-api/src/features/users/email-verification.service.ts @@ -0,0 +1,197 @@ +import { createHmac, hkdfSync, randomInt, timingSafeEqual } from "node:crypto"; +import Handlebars from "handlebars"; +import type { Config } from "../../config"; +import type { SmtpTransport } from "../../infra/smtp"; +import { createFromFormatter } from "../../infra/email-from"; +import type { IUserRepository } from "../../repositories/interfaces/user.types"; +import type { EmailVerificationMongooseRepository } from "../../repositories/mongoose/email-verification.mongoose.repository"; +import { + ApiErrorCode, + BadRequestError, + ConflictError, + ServiceUnavailableError, + TooManyRequestsError, +} from "../../infra/error-handler"; +import EMAIL_VERIFICATION_TEMPLATE from "./email-verification.template"; + +const verificationTemplate = Handlebars.compile(EMAIL_VERIFICATION_TEMPLATE); + +const CODE_TTL_MS = 10 * 60 * 1000; +const MAX_ATTEMPTS = 5; +const RESEND_COOLDOWN_MS = 60 * 1000; +// Cap on how many DISTINCT addresses a single user can have codes sent to within +// the window. Independent of the per-(user,email) cooldown and the IP-based +// route limit: bounds the "make MonitoRSS email arbitrary addresses" primitive +// regardless of IP. Re-sending to an already-targeted address is not counted. +const DISTINCT_TARGET_WINDOW_MS = 60 * 60 * 1000; +const MAX_DISTINCT_TARGETS = 5; + +export interface EmailVerificationServiceDeps { + config: Config; + smtpTransport: SmtpTransport; + emailVerificationRepository: EmailVerificationMongooseRepository; + userRepository: IUserRepository; +} + +export class EmailVerificationService { + private readonly otpKey: Buffer; + + constructor(private readonly deps: EmailVerificationServiceDeps) { + // Domain-separated subkey derived from the session secret: keeps OTP + // hashing cryptographically independent of session signing (key separation) + // without requiring a dedicated secret env var. + this.otpKey = Buffer.from( + hkdfSync( + "sha256", + deps.config.BACKEND_API_SESSION_SECRET, + Buffer.alloc(0), + "monitorss:email-verification-otp", + 32, + ), + ); + } + + private normalizeEmail(email: string): string { + return email.trim().toLowerCase(); + } + + private hashCode(code: string): string { + return createHmac("sha256", this.otpKey).update(code).digest("hex"); + } + + async sendCode(userId: string, rawEmail: string): Promise { + if (!this.deps.smtpTransport) { + throw new ServiceUnavailableError( + ApiErrorCode.EMAIL_VERIFICATION_UNAVAILABLE, + ); + } + + const email = this.normalizeEmail(rawEmail); + + // Identity-based resend cooldown: bounds per-(user,email) send frequency + // regardless of IP/proxy (which the IP rate-limit cannot under trustProxy). + // The window is evaluated in-query against the DB clock $$NOW (see repo). + if ( + await this.deps.emailVerificationRepository.hasRecentCode( + userId, + email, + RESEND_COOLDOWN_MS, + ) + ) { + throw new TooManyRequestsError( + ApiErrorCode.EMAIL_VERIFICATION_RESEND_TOO_SOON, + ); + } + + // Distinct-target cap: re-sending to an address already targeted in the + // window is always allowed (only the cooldown gates it); a NEW address is + // blocked once the distinct count is at the cap. + const alreadyTargeted = + await this.deps.emailVerificationRepository.hasRecentTarget( + userId, + email, + DISTINCT_TARGET_WINDOW_MS, + ); + + if (!alreadyTargeted) { + const distinctTargets = + await this.deps.emailVerificationRepository.countDistinctRecentTargets( + userId, + DISTINCT_TARGET_WINDOW_MS, + ); + + if (distinctTargets >= MAX_DISTINCT_TARGETS) { + throw new TooManyRequestsError( + ApiErrorCode.EMAIL_VERIFICATION_TOO_MANY_TARGETS, + ); + } + } + + const code = randomInt(0, 1_000_000).toString().padStart(6, "0"); + + await this.deps.emailVerificationRepository.createCode({ + userId, + email, + codeHash: this.hashCode(code), + expiresAt: new Date(Date.now() + CODE_TTL_MS), + }); + + await this.deps.emailVerificationRepository.recordSend(userId, email); + + await this.deps.smtpTransport.sendMail({ + from: createFromFormatter(this.deps.config)("MonitoRSS", "noreply"), + to: email, + subject: "Verify your email for MonitoRSS", + html: verificationTemplate({ code }), + }); + } + + async confirm(userId: string, rawEmail: string, code: string): Promise { + const email = this.normalizeEmail(rawEmail); + const record = await this.deps.emailVerificationRepository.findByUserEmail( + userId, + email, + ); + + if (!record) { + throw new BadRequestError(ApiErrorCode.EMAIL_VERIFICATION_INVALID_CODE); + } + + if (record.expiresAt.getTime() < Date.now()) { + await this.deps.emailVerificationRepository.deleteForUserEmail( + userId, + email, + ); + throw new BadRequestError(ApiErrorCode.EMAIL_VERIFICATION_EXPIRED); + } + + if (record.attempts >= MAX_ATTEMPTS) { + await this.deps.emailVerificationRepository.deleteForUserEmail( + userId, + email, + ); + throw new TooManyRequestsError( + ApiErrorCode.EMAIL_VERIFICATION_TOO_MANY_ATTEMPTS, + ); + } + + if (!this.codesMatch(this.hashCode(code), record.codeHash)) { + await this.deps.emailVerificationRepository.incrementAttempts( + userId, + email, + ); + throw new BadRequestError(ApiErrorCode.EMAIL_VERIFICATION_INVALID_CODE); + } + + try { + await this.deps.userRepository.setVerifiedEmail(userId, email); + } catch (err) { + if (this.isDuplicateKeyError(err)) { + throw new ConflictError(ApiErrorCode.EMAIL_ALREADY_IN_USE); + } + throw err; + } + + await this.deps.emailVerificationRepository.deleteForUserEmail( + userId, + email, + ); + } + + private codesMatch(a: string, b: string): boolean { + const bufA = Buffer.from(a); + const bufB = Buffer.from(b); + if (bufA.length !== bufB.length) { + return false; + } + return timingSafeEqual(bufA, bufB); + } + + private isDuplicateKeyError(err: unknown): boolean { + return ( + typeof err === "object" && + err !== null && + (err as { code?: number }).code === 11000 + ); + } +} diff --git a/services/backend-api/src/features/users/email-verification.template.ts b/services/backend-api/src/features/users/email-verification.template.ts new file mode 100644 index 000000000..c06f9ac21 --- /dev/null +++ b/services/backend-api/src/features/users/email-verification.template.ts @@ -0,0 +1,12 @@ +export default ` + + + +

Verify your email

+

Enter this code to verify your email address for MonitoRSS:

+

{{code}}

+

This code expires in 10 minutes.

+

If you didn't request this, you can safely ignore this email.

+ + +`; diff --git a/services/backend-api/src/features/users/users.handlers.ts b/services/backend-api/src/features/users/users.handlers.ts index ad21c3a5a..5366bef96 100644 --- a/services/backend-api/src/features/users/users.handlers.ts +++ b/services/backend-api/src/features/users/users.handlers.ts @@ -30,12 +30,16 @@ export async function getMeHandler( id: user.id, discordUserId: user.discordUserId, email: user.email, + verifiedEmail: user.verifiedEmail, preferences: user.preferences || {}, subscription, creditBalance, isOnPatreon, enableBilling: user.enableBilling, featureFlags: user.featureFlags || {}, + capabilities: { + workspaces: !!user.featureFlags?.workspaces, + }, supporterFeatures, externalAccounts, }, @@ -72,12 +76,16 @@ export async function updateMeHandler( id: user.id, discordUserId: user.discordUserId, email: user.email, + verifiedEmail: user.verifiedEmail, preferences: user.preferences || {}, subscription, creditBalance, isOnPatreon, enableBilling: user.enableBilling, featureFlags: user.featureFlags || {}, + capabilities: { + workspaces: !!user.featureFlags?.workspaces, + }, supporterFeatures, externalAccounts, }, diff --git a/services/backend-api/src/features/workspace-invites/workspace-invites.handlers.ts b/services/backend-api/src/features/workspace-invites/workspace-invites.handlers.ts new file mode 100644 index 000000000..08328737f --- /dev/null +++ b/services/backend-api/src/features/workspace-invites/workspace-invites.handlers.ts @@ -0,0 +1,120 @@ +import type { FastifyRequest, FastifyReply } from "fastify"; +import type { IWorkspaceInviteWithContext } from "../../repositories/mongoose/workspace.mongoose.repository"; +import { redactEmail } from "../../shared/utils/redactEmail"; +import type { + SendInviteVerificationBody, + WorkspaceInviteIdParams, +} from "./workspace-invites.schemas"; + +// The @me list is keyed on the caller's own verifiedEmail, so the full address +// is the caller's own — no leak. +function toMyInviteResponse(invite: IWorkspaceInviteWithContext) { + return { + id: invite.id, + email: invite.email, + role: invite.role, + workspaceName: invite.workspaceName, + invitedByUserId: invite.invitedByUserId, + createdAt: invite.createdAt, + }; +} + +// The single-invite GET is reachable by any feature-flagged user who knows the +// invitation id. The full invited address is disclosed only to a caller who has +// already proven ownership of it (emailMatches); to everyone else it is redacted +// to a recognizable hint, so a prober cannot harvest the address (PII / IDOR). +function toInviteContextResponse( + invite: IWorkspaceInviteWithContext, + emailMatches: boolean, + alreadyMember: boolean, +) { + return { + id: invite.id, + emailHint: redactEmail(invite.email), + ...(emailMatches ? { email: invite.email } : {}), + role: invite.role, + workspaceName: invite.workspaceName, + invitedByUserId: invite.invitedByUserId, + createdAt: invite.createdAt, + // Lets the landing page show "you're already a member" instead of pushing an + // existing member through email verification only to fail the accept guard. + alreadyMember, + }; +} + +export async function listMyWorkspaceInvitesHandler( + request: FastifyRequest, + reply: FastifyReply, +): Promise { + const { workspacesService } = request.container; + const invites = await workspacesService.listMyInvites( + request.userId as string, + ); + + return reply.send({ result: invites.map(toMyInviteResponse) }); +} + +export async function getWorkspaceInviteHandler( + request: FastifyRequest<{ Params: WorkspaceInviteIdParams }>, + reply: FastifyReply, +): Promise { + const { workspacesService } = request.container; + const { invite, emailMatches, alreadyMember } = + await workspacesService.getInvite( + request.params.inviteId, + request.userId as string, + ); + + return reply.send({ + result: toInviteContextResponse(invite, emailMatches, alreadyMember), + }); +} + +export async function acceptWorkspaceInviteHandler( + request: FastifyRequest<{ Params: WorkspaceInviteIdParams }>, + reply: FastifyReply, +): Promise { + const { workspacesService } = request.container; + const { workspaceSlug } = await workspacesService.acceptInvite( + request.params.inviteId, + request.userId as string, + ); + + // Return the joined workspace's slug so the client can drop the invitee + // straight into the workspace they just joined. + return reply.send({ result: { workspaceSlug } }); +} + +// Invite-scoped email-verification send. Dispatches a code only when the +// submitted address matches the invited address; otherwise (or for an unknown +// invite) it no-ops. The response is identical in every case so a prober cannot +// distinguish a match from a miss (no address-harvesting oracle). +export async function sendInviteVerificationHandler( + request: FastifyRequest<{ + Params: WorkspaceInviteIdParams; + Body: SendInviteVerificationBody; + }>, + reply: FastifyReply, +): Promise { + const { workspacesService } = request.container; + await workspacesService.sendInviteVerification( + request.params.inviteId, + request.userId as string, + request.body.email, + ); + + return reply.send({ result: { ok: true } }); +} + +export async function declineWorkspaceInviteHandler( + request: FastifyRequest<{ Params: WorkspaceInviteIdParams }>, + reply: FastifyReply, +): Promise { + const { workspacesService } = request.container; + await workspacesService.declineInvite( + request.params.inviteId, + request.userId as string, + ); + + return reply.status(204).send(); +} diff --git a/services/backend-api/src/features/workspace-invites/workspace-invites.routes.ts b/services/backend-api/src/features/workspace-invites/workspace-invites.routes.ts new file mode 100644 index 000000000..d104898c6 --- /dev/null +++ b/services/backend-api/src/features/workspace-invites/workspace-invites.routes.ts @@ -0,0 +1,63 @@ +import type { FastifyInstance } from "fastify"; +import rateLimit from "@fastify/rate-limit"; +import { requireAuthHook } from "../../infra/auth"; +import { requireWorkspacesFeatureHook } from "../workspaces/workspaces.hooks"; +import { + acceptWorkspaceInviteHandler, + declineWorkspaceInviteHandler, + getWorkspaceInviteHandler, + listMyWorkspaceInvitesHandler, + sendInviteVerificationHandler, +} from "./workspace-invites.handlers"; +import { + SendInviteVerificationBodySchema, + WorkspaceInviteIdParamsSchema, +} from "./workspace-invites.schemas"; + +// Invitee-side routes: addressed by invitation id (or the caller's verified +// email for the @me list), independent of any workspace the caller belongs to. +// All require auth + the workspaces feature flag (the flag hook also resolves +// request.userId). +export async function workspaceInvitesRoutes( + app: FastifyInstance, +): Promise { + await app.register(rateLimit, { global: false }); + + app.get("/@me", { + preHandler: [requireAuthHook, requireWorkspacesFeatureHook], + handler: listMyWorkspaceInvitesHandler, + }); + + app.get("/:inviteId", { + preHandler: [requireAuthHook, requireWorkspacesFeatureHook], + schema: { params: WorkspaceInviteIdParamsSchema }, + handler: getWorkspaceInviteHandler, + }); + + app.post("/:inviteId/accept", { + preHandler: [requireAuthHook, requireWorkspacesFeatureHook], + schema: { params: WorkspaceInviteIdParamsSchema }, + handler: acceptWorkspaceInviteHandler, + }); + + app.post("/:inviteId/decline", { + preHandler: [requireAuthHook, requireWorkspacesFeatureHook], + schema: { params: WorkspaceInviteIdParamsSchema }, + handler: declineWorkspaceInviteHandler, + }); + + app.post("/:inviteId/verification", { + preHandler: [requireAuthHook, requireWorkspacesFeatureHook], + schema: { + params: WorkspaceInviteIdParamsSchema, + body: SendInviteVerificationBodySchema, + }, + config: { + rateLimit: { + max: 10, + timeWindow: "1 hour", + }, + }, + handler: sendInviteVerificationHandler, + }); +} diff --git a/services/backend-api/src/features/workspace-invites/workspace-invites.schemas.ts b/services/backend-api/src/features/workspace-invites/workspace-invites.schemas.ts new file mode 100644 index 000000000..b76476345 --- /dev/null +++ b/services/backend-api/src/features/workspace-invites/workspace-invites.schemas.ts @@ -0,0 +1,26 @@ +import { Type, type Static } from "@sinclair/typebox"; + +export const WorkspaceInviteIdParamsSchema = Type.Object( + { + inviteId: Type.String(), + }, + { additionalProperties: false }, +); + +export type WorkspaceInviteIdParams = Static< + typeof WorkspaceInviteIdParamsSchema +>; + +export const SendInviteVerificationBodySchema = Type.Object( + { + email: Type.String({ + maxLength: 254, + pattern: "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", + }), + }, + { additionalProperties: false }, +); + +export type SendInviteVerificationBody = Static< + typeof SendInviteVerificationBodySchema +>; diff --git a/services/backend-api/src/features/workspaces/workspace-invite.template.ts b/services/backend-api/src/features/workspaces/workspace-invite.template.ts new file mode 100644 index 000000000..1da377610 --- /dev/null +++ b/services/backend-api/src/features/workspaces/workspace-invite.template.ts @@ -0,0 +1,14 @@ +export default ` + + + +

You've been invited to a workspace

+

You've been invited to join the {{workspaceName}} workspace on MonitoRSS.

+

Sign in and verify this email address to accept the invitation:

+

+ View invitation +

+

If you weren't expecting this, you can safely ignore this email.

+ + +`; diff --git a/services/backend-api/src/features/workspaces/workspaces.handlers.ts b/services/backend-api/src/features/workspaces/workspaces.handlers.ts new file mode 100644 index 000000000..20b806756 --- /dev/null +++ b/services/backend-api/src/features/workspaces/workspaces.handlers.ts @@ -0,0 +1,206 @@ +import type { FastifyRequest, FastifyReply } from "fastify"; +import { ApiErrorCode, ForbiddenError } from "../../infra/error-handler"; +import type { IWorkspaceInvite } from "../../repositories/mongoose/workspace.mongoose.repository"; +import type { + CreateWorkspaceBody, + CreateWorkspaceInviteBody, + UpdateWorkspaceBody, + WorkspaceInviteParams, + WorkspaceMemberParams, + WorkspaceSlugParams, +} from "./workspaces.schemas"; + +function toInviteResponse(invite: IWorkspaceInvite) { + return { + id: invite.id, + email: invite.email, + role: invite.role, + invitedByUserId: invite.invitedByUserId, + createdAt: invite.createdAt, + }; +} + +export async function createWorkspaceHandler( + request: FastifyRequest<{ Body: CreateWorkspaceBody }>, + reply: FastifyReply, +): Promise { + const { workspacesService } = request.container; + const workspace = await workspacesService.createWorkspace( + request.userId as string, + request.body.name, + request.body.slug, + ); + + return reply.status(201).send({ result: workspace }); +} + +export async function listWorkspacesHandler( + request: FastifyRequest, + reply: FastifyReply, +): Promise { + const { workspacesService } = request.container; + const workspaces = await workspacesService.listWorkspaces( + request.userId as string, + ); + + return reply.send({ result: workspaces }); +} + +export async function getWorkspaceHandler( + request: FastifyRequest<{ Params: WorkspaceSlugParams }>, + reply: FastifyReply, +): Promise { + const { workspacesService, supportersService } = request.container; + const { workspace, role } = + await workspacesService.getWorkspaceForMemberBySlug( + request.params.workspaceSlug, + request.userId as string, + ); + + // The workspace's feed limit (hardcoded today; workspace Paddle subscription + // later). Surfaced so the client can render the workspace's feed-limit bar. + const { maxFeeds } = await supportersService.getWorkspaceBenefits( + workspace.id, + ); + + return reply.send({ + result: { + id: workspace.id, + name: workspace.name, + slug: workspace.slug, + role, + maxFeeds, + }, + }); +} + +export async function updateWorkspaceHandler( + request: FastifyRequest<{ + Params: WorkspaceSlugParams; + Body: UpdateWorkspaceBody; + }>, + reply: FastifyReply, +): Promise { + const { workspacesService } = request.container; + const { workspace, role } = + await workspacesService.getWorkspaceForMemberBySlug( + request.params.workspaceSlug, + request.userId as string, + ); + + if (!workspacesService.can("changeSettings", role)) { + throw new ForbiddenError(ApiErrorCode.WORKSPACE_INSUFFICIENT_ROLE); + } + + let updated = workspace; + + if (request.body.name && request.body.name !== workspace.name) { + updated = await workspacesService.updateWorkspaceName( + workspace.id, + request.userId as string, + request.body.name, + ); + } + + if (request.body.slug && request.body.slug !== workspace.slug) { + updated = await workspacesService.updateWorkspaceSlug( + workspace.id, + request.userId as string, + request.body.slug, + ); + } + + return reply.send({ result: updated }); +} + +export async function createWorkspaceInviteHandler( + request: FastifyRequest<{ + Params: WorkspaceSlugParams; + Body: CreateWorkspaceInviteBody; + }>, + reply: FastifyReply, +): Promise { + const { workspacesService } = request.container; + const invite = await workspacesService.createInvite( + request.params.workspaceSlug, + request.userId as string, + request.body.email, + ); + + return reply.status(201).send({ result: toInviteResponse(invite) }); +} + +export async function listWorkspaceInvitesHandler( + request: FastifyRequest<{ Params: WorkspaceSlugParams }>, + reply: FastifyReply, +): Promise { + const { workspacesService } = request.container; + const invites = await workspacesService.listInvites( + request.params.workspaceSlug, + request.userId as string, + ); + + return reply.send({ result: invites.map(toInviteResponse) }); +} + +export async function resendWorkspaceInviteHandler( + request: FastifyRequest<{ Params: WorkspaceInviteParams }>, + reply: FastifyReply, +): Promise { + const { workspacesService } = request.container; + await workspacesService.resendInvite( + request.params.workspaceSlug, + request.userId as string, + request.params.inviteId, + ); + + return reply.status(200).send({ result: { ok: true } }); +} + +export async function revokeWorkspaceInviteHandler( + request: FastifyRequest<{ Params: WorkspaceInviteParams }>, + reply: FastifyReply, +): Promise { + const { workspacesService } = request.container; + await workspacesService.revokeInvite( + request.params.workspaceSlug, + request.userId as string, + request.params.inviteId, + ); + + return reply.status(200).send({ result: { ok: true } }); +} + +export async function listWorkspaceMembersHandler( + request: FastifyRequest<{ Params: WorkspaceSlugParams }>, + reply: FastifyReply, +): Promise { + const { workspacesService } = request.container; + const members = await workspacesService.listMembers( + request.params.workspaceSlug, + request.userId as string, + ); + + return reply.send({ result: members }); +} + +export async function removeWorkspaceMemberHandler( + request: FastifyRequest<{ Params: WorkspaceMemberParams }>, + reply: FastifyReply, +): Promise { + const { workspacesService } = request.container; + const actorUserId = request.userId as string; + + // "@me" is the leave path; otherwise the target is another member. The + // service routes by identity (actor === target) keeping can() pure. + const targetUserId = + request.params.userId === "@me" ? actorUserId : request.params.userId; + + await workspacesService.removeMember( + request.params.workspaceSlug, + actorUserId, + targetUserId, + ); + + return reply.status(200).send({ result: { ok: true } }); +} diff --git a/services/backend-api/src/features/workspaces/workspaces.hooks.ts b/services/backend-api/src/features/workspaces/workspaces.hooks.ts new file mode 100644 index 000000000..e69ed2436 --- /dev/null +++ b/services/backend-api/src/features/workspaces/workspaces.hooks.ts @@ -0,0 +1,21 @@ +import type { FastifyRequest, FastifyReply } from "fastify"; +import { sendError, ApiErrorCode } from "../../infra/error-handler"; + +// Per-user gate for the workspaces feature; runs after requireAuthHook. Resolves +// the internal user id onto request.userId (the provider-agnostic identity seam) +// and returns 404 when the user lacks the workspaces rollout flag, hiding the +// feature. +export async function requireWorkspacesFeatureHook( + request: FastifyRequest, + reply: FastifyReply, +): Promise { + const { userRepository } = request.container; + const user = await userRepository.findByDiscordId(request.discordUserId); + + if (!user || !user.featureFlags?.workspaces) { + sendError(reply, 404, ApiErrorCode.ROUTE_NOT_FOUND, "Not Found"); + return; + } + + request.userId = user.id; +} diff --git a/services/backend-api/src/features/workspaces/workspaces.routes.ts b/services/backend-api/src/features/workspaces/workspaces.routes.ts new file mode 100644 index 000000000..88dd64071 --- /dev/null +++ b/services/backend-api/src/features/workspaces/workspaces.routes.ts @@ -0,0 +1,89 @@ +import type { FastifyInstance } from "fastify"; +import { requireAuthHook } from "../../infra/auth"; +import { requireWorkspacesFeatureHook } from "./workspaces.hooks"; +import { + createWorkspaceHandler, + listWorkspacesHandler, + getWorkspaceHandler, + updateWorkspaceHandler, + createWorkspaceInviteHandler, + listWorkspaceInvitesHandler, + resendWorkspaceInviteHandler, + revokeWorkspaceInviteHandler, + listWorkspaceMembersHandler, + removeWorkspaceMemberHandler, +} from "./workspaces.handlers"; +import { + CreateWorkspaceBodySchema, + CreateWorkspaceInviteBodySchema, + UpdateWorkspaceBodySchema, + WorkspaceInviteParamsSchema, + WorkspaceMemberParamsSchema, + WorkspaceSlugParamsSchema, +} from "./workspaces.schemas"; + +export async function workspacesRoutes(app: FastifyInstance): Promise { + app.post("/", { + preHandler: [requireAuthHook, requireWorkspacesFeatureHook], + schema: { body: CreateWorkspaceBodySchema }, + handler: createWorkspaceHandler, + }); + + app.get("/", { + preHandler: [requireAuthHook, requireWorkspacesFeatureHook], + handler: listWorkspacesHandler, + }); + + app.get("/:workspaceSlug", { + preHandler: [requireAuthHook, requireWorkspacesFeatureHook], + schema: { params: WorkspaceSlugParamsSchema }, + handler: getWorkspaceHandler, + }); + + app.patch("/:workspaceSlug", { + preHandler: [requireAuthHook, requireWorkspacesFeatureHook], + schema: { params: WorkspaceSlugParamsSchema, body: UpdateWorkspaceBodySchema }, + handler: updateWorkspaceHandler, + }); + + app.post("/:workspaceSlug/invites", { + preHandler: [requireAuthHook, requireWorkspacesFeatureHook], + schema: { + params: WorkspaceSlugParamsSchema, + body: CreateWorkspaceInviteBodySchema, + }, + handler: createWorkspaceInviteHandler, + }); + + app.get("/:workspaceSlug/invites", { + preHandler: [requireAuthHook, requireWorkspacesFeatureHook], + schema: { params: WorkspaceSlugParamsSchema }, + handler: listWorkspaceInvitesHandler, + }); + + app.post("/:workspaceSlug/invites/:inviteId/resend", { + preHandler: [requireAuthHook, requireWorkspacesFeatureHook], + schema: { params: WorkspaceInviteParamsSchema }, + handler: resendWorkspaceInviteHandler, + }); + + app.delete("/:workspaceSlug/invites/:inviteId", { + preHandler: [requireAuthHook, requireWorkspacesFeatureHook], + schema: { params: WorkspaceInviteParamsSchema }, + handler: revokeWorkspaceInviteHandler, + }); + + app.get("/:workspaceSlug/members", { + preHandler: [requireAuthHook, requireWorkspacesFeatureHook], + schema: { params: WorkspaceSlugParamsSchema }, + handler: listWorkspaceMembersHandler, + }); + + // One route covers both remove-other (:userId) and leave (@me); the handler + // routes by identity. + app.delete("/:workspaceSlug/members/:userId", { + preHandler: [requireAuthHook, requireWorkspacesFeatureHook], + schema: { params: WorkspaceMemberParamsSchema }, + handler: removeWorkspaceMemberHandler, + }); +} diff --git a/services/backend-api/src/features/workspaces/workspaces.schemas.ts b/services/backend-api/src/features/workspaces/workspaces.schemas.ts new file mode 100644 index 000000000..4d458f706 --- /dev/null +++ b/services/backend-api/src/features/workspaces/workspaces.schemas.ts @@ -0,0 +1,72 @@ +import { Type, type Static } from "@sinclair/typebox"; +import { SLUG_MAX, SLUG_PATTERN } from "../../shared/utils/slugify"; + +const WorkspaceNameSchema = Type.String({ minLength: 1, maxLength: 100 }); + +const WorkspaceSlugFieldSchema = Type.String({ + minLength: 2, + maxLength: SLUG_MAX, + pattern: SLUG_PATTERN.source, +}); + +export const CreateWorkspaceBodySchema = Type.Object( + { + name: WorkspaceNameSchema, + slug: WorkspaceSlugFieldSchema, + }, + { additionalProperties: false }, +); + +export type CreateWorkspaceBody = Static; + +export const UpdateWorkspaceBodySchema = Type.Object( + { + name: Type.Optional(WorkspaceNameSchema), + slug: Type.Optional(WorkspaceSlugFieldSchema), + }, + { additionalProperties: false }, +); + +export type UpdateWorkspaceBody = Static; + +export const WorkspaceSlugParamsSchema = Type.Object( + { + workspaceSlug: Type.String(), + }, + { additionalProperties: false }, +); + +export type WorkspaceSlugParams = Static; + +export const CreateWorkspaceInviteBodySchema = Type.Object( + { + email: Type.String({ minLength: 3, maxLength: 254, format: "email" }), + }, + { additionalProperties: false }, +); + +export type CreateWorkspaceInviteBody = Static< + typeof CreateWorkspaceInviteBodySchema +>; + +export const WorkspaceInviteParamsSchema = Type.Object( + { + workspaceSlug: Type.String(), + inviteId: Type.String(), + }, + { additionalProperties: false }, +); + +export type WorkspaceInviteParams = Static; + +// userId accepts "@me" so a member can leave via DELETE .../members/@me; the +// handler resolves it to the caller's own id before routing by identity. +export const WorkspaceMemberParamsSchema = Type.Object( + { + workspaceSlug: Type.String(), + userId: Type.String(), + }, + { additionalProperties: false }, +); + +export type WorkspaceMemberParams = Static; diff --git a/services/backend-api/src/features/workspaces/workspaces.service.ts b/services/backend-api/src/features/workspaces/workspaces.service.ts new file mode 100644 index 000000000..a47755cbe --- /dev/null +++ b/services/backend-api/src/features/workspaces/workspaces.service.ts @@ -0,0 +1,651 @@ +import Handlebars from "handlebars"; +import { + ApiErrorCode, + ConflictError, + ForbiddenError, + NotFoundError, + ServiceUnavailableError, + TooManyRequestsError, +} from "../../infra/error-handler"; +import { isReservedSlug, isValidSlug } from "../../shared/utils/slugify"; +import { normalizeEmail } from "../../shared/utils/normalizeEmail"; +import type { Config } from "../../config"; +import type { SmtpTransport } from "../../infra/smtp"; +import { createFromFormatter } from "../../infra/email-from"; +import type { UserMongooseRepository } from "../../repositories/mongoose/user.mongoose.repository"; +import { + CannotRemoveLastOwnerError, + WorkspaceInviteExistsError, + WorkspaceSlugTakenError, + type IWorkspace, + type IWorkspaceInvite, + type IWorkspaceInviteWithContext, + type IWorkspaceMember, + type IWorkspaceWithRole, + type WorkspaceRole, + type WorkspaceMongooseRepository, +} from "../../repositories/mongoose/workspace.mongoose.repository"; +import type { EmailVerificationService } from "../users/email-verification.service"; +import WORKSPACE_INVITE_TEMPLATE from "./workspace-invite.template"; + +const inviteTemplate = Handlebars.compile(WORKSPACE_INVITE_TEMPLATE); + +// v1 invitations grant admin only; the role is stored for forward-compatibility. +const INVITE_ROLE: WorkspaceRole = "admin"; + +// Mirrors EmailVerificationService's resend cooldown: bounds how often a given +// invitation's notification email can be re-dispatched. +const INVITE_RESEND_COOLDOWN_MS = 60 * 1000; + +// Cap on pending invitations a single workspace may hold at once. Bounds abuse +// (mass invite spam) and the unbounded growth of the invite collection. +const MAX_PENDING_INVITES_PER_WORKSPACE = 25; + +// owner ⊇ admin. changeSettings/manageMembers/leaveWorkspace are open to every +// member (all are ≥ admin); removeMember/deleteWorkspace/transferOwnership are +// owner-gated. The function is the seam: handlers call can(), never compare role +// strings, so a future role or action is a change here, not across handlers. It +// stays a pure (action, role) function — identity (actor vs target) is handler +// routing, not policy. +type WorkspaceAction = + | "changeSettings" + | "manageMembers" + | "removeMember" + | "leaveWorkspace" + | "deleteWorkspace" + | "transferOwnership"; + +export interface WorkspacesServiceDeps { + config: Config; + smtpTransport: SmtpTransport; + workspaceRepository: WorkspaceMongooseRepository; + userRepository: UserMongooseRepository; + emailVerificationService: EmailVerificationService; +} + +export class WorkspacesService { + constructor(private readonly deps: WorkspacesServiceDeps) {} + + // Authorization seam: callers check can(...) rather than comparing roles. + can(action: WorkspaceAction, role: WorkspaceRole): boolean { + switch (action) { + case "changeSettings": + case "manageMembers": + case "leaveWorkspace": + return role === "owner" || role === "admin"; + case "removeMember": + case "deleteWorkspace": + case "transferOwnership": + return role === "owner"; + default: + return false; + } + } + + async createWorkspace( + userId: string, + name: string, + slug: string, + ): Promise { + const user = await this.deps.userRepository.findById(userId); + + if (!user?.verifiedEmail) { + throw new ForbiddenError(ApiErrorCode.EMAIL_NOT_VERIFIED); + } + + if (isReservedSlug(slug)) { + throw new ConflictError(ApiErrorCode.WORKSPACE_SLUG_RESERVED); + } + + if (!isValidSlug(slug)) { + throw new ConflictError(ApiErrorCode.VALIDATION_FAILED); + } + + const taken = await this.deps.workspaceRepository.isSlugTaken(slug); + + if (taken) { + throw new ConflictError(ApiErrorCode.WORKSPACE_SLUG_TAKEN); + } + + try { + return await this.deps.workspaceRepository.createWorkspaceWithOwner({ + name, + slug, + ownerUserId: userId, + }); + } catch (err) { + if (err instanceof WorkspaceSlugTakenError) { + throw new ConflictError(ApiErrorCode.WORKSPACE_SLUG_TAKEN); + } + + throw err; + } + } + + async listWorkspaces(userId: string): Promise { + return this.deps.workspaceRepository.listWorkspacesForUser(userId); + } + + // The workspace ids a user belongs to, for workspace-feed authorization. + async listWorkspaceIds(userId: string): Promise { + return this.deps.workspaceRepository.listWorkspaceIdsForUser(userId); + } + + async getWorkspaceForMember( + workspaceId: string, + userId: string, + ): Promise<{ workspace: IWorkspace; role: WorkspaceRole }> { + const found = + await this.deps.workspaceRepository.findMembershipWithWorkspace( + workspaceId, + userId, + ); + + if (!found) { + throw new NotFoundError(ApiErrorCode.WORKSPACE_NOT_FOUND); + } + + return found; + } + + async getWorkspaceForMemberBySlug( + slug: string, + userId: string, + ): Promise<{ workspace: IWorkspace; role: WorkspaceRole }> { + const found = + await this.deps.workspaceRepository.findMembershipWithWorkspaceBySlug( + slug, + userId, + ); + + if (!found) { + throw new NotFoundError(ApiErrorCode.WORKSPACE_NOT_FOUND); + } + + return found; + } + + async updateWorkspaceName( + workspaceId: string, + userId: string, + name: string, + ): Promise { + const { role } = await this.getWorkspaceForMember(workspaceId, userId); + + if (!this.can("changeSettings", role)) { + throw new ForbiddenError(ApiErrorCode.WORKSPACE_INSUFFICIENT_ROLE); + } + + const updated = await this.deps.workspaceRepository.updateName( + workspaceId, + name, + ); + + if (!updated) { + throw new NotFoundError(ApiErrorCode.WORKSPACE_NOT_FOUND); + } + + return updated; + } + + async updateWorkspaceSlug( + workspaceId: string, + userId: string, + slug: string, + ): Promise { + const { role } = await this.getWorkspaceForMember(workspaceId, userId); + + if (!this.can("changeSettings", role)) { + throw new ForbiddenError(ApiErrorCode.WORKSPACE_INSUFFICIENT_ROLE); + } + + if (isReservedSlug(slug)) { + throw new ConflictError(ApiErrorCode.WORKSPACE_SLUG_RESERVED); + } + + if (!isValidSlug(slug)) { + throw new ConflictError(ApiErrorCode.VALIDATION_FAILED); + } + + const taken = await this.deps.workspaceRepository.isSlugTaken( + slug, + workspaceId, + ); + + if (taken) { + throw new ConflictError(ApiErrorCode.WORKSPACE_SLUG_TAKEN); + } + + let updated: IWorkspace | null; + + try { + updated = await this.deps.workspaceRepository.updateSlug( + workspaceId, + slug, + ); + } catch (err) { + if (err instanceof WorkspaceSlugTakenError) { + throw new ConflictError(ApiErrorCode.WORKSPACE_SLUG_TAKEN); + } + + throw err; + } + + if (!updated) { + throw new NotFoundError(ApiErrorCode.WORKSPACE_NOT_FOUND); + } + + return updated; + } + + async createInvite( + slug: string, + userId: string, + rawEmail: string, + ): Promise { + const { workspace, role } = await this.getWorkspaceForMemberBySlug( + slug, + userId, + ); + + if (!this.can("manageMembers", role)) { + throw new ForbiddenError(ApiErrorCode.WORKSPACE_INSUFFICIENT_ROLE); + } + + const email = normalizeEmail(rawEmail); + + // Membership binds to the verified email, so "already a member" resolves the + // invited email to its verified-email owner and checks this workspace only. + const existingUser = + await this.deps.userRepository.findByVerifiedEmail(email); + + if ( + existingUser && + (await this.deps.workspaceRepository.isMember( + workspace.id, + existingUser.id, + )) + ) { + throw new ConflictError(ApiErrorCode.WORKSPACE_MEMBER_ALREADY_EXISTS); + } + + const pending = await this.deps.workspaceRepository.findPendingInvite( + workspace.id, + email, + ); + + if (pending) { + throw new ConflictError(ApiErrorCode.WORKSPACE_ALREADY_INVITED); + } + + const pendingCount = + await this.deps.workspaceRepository.countInvitesForWorkspace( + workspace.id, + ); + + if (pendingCount >= MAX_PENDING_INVITES_PER_WORKSPACE) { + throw new ConflictError(ApiErrorCode.WORKSPACE_INVITE_LIMIT_REACHED); + } + + // The row is only created if the notification can be dispatched: a failed + // send must never leave a stranded invitation. The link is keyed by the + // invitation id, so the id is generated up front, the email is sent, and + // only then is the row persisted — a send failure persists nothing. + const inviteId = this.deps.workspaceRepository.generateInviteId(); + + await this.sendInviteEmail(inviteId, email, workspace.name); + + try { + return await this.deps.workspaceRepository.createInvite({ + id: inviteId, + workspaceId: workspace.id, + email, + role: INVITE_ROLE, + invitedByUserId: userId, + }); + } catch (err) { + if (err instanceof WorkspaceInviteExistsError) { + throw new ConflictError(ApiErrorCode.WORKSPACE_ALREADY_INVITED); + } + + throw err; + } + } + + private async sendInviteEmail( + inviteId: string, + email: string, + workspaceName: string, + ): Promise { + if (!this.deps.smtpTransport) { + throw new ServiceUnavailableError( + ApiErrorCode.WORKSPACE_INVITE_EMAIL_UNAVAILABLE, + ); + } + + // The link is keyed by invitation id; the invited email never appears in it. + const inviteUrl = `${this.deps.config.BACKEND_API_LOGIN_REDIRECT_URI}/invites/${inviteId}`; + + await this.deps.smtpTransport.sendMail({ + from: createFromFormatter(this.deps.config)("MonitoRSS", "noreply"), + to: email, + subject: `You've been invited to ${workspaceName} on MonitoRSS`, + html: inviteTemplate({ workspaceName, inviteUrl }), + }); + } + + async listInvites( + slug: string, + userId: string, + ): Promise { + const { workspace, role } = await this.getWorkspaceForMemberBySlug( + slug, + userId, + ); + + if (!this.can("manageMembers", role)) { + throw new ForbiddenError(ApiErrorCode.WORKSPACE_INSUFFICIENT_ROLE); + } + + return this.deps.workspaceRepository.listInvitesForWorkspace(workspace.id); + } + + // Minimal context for the invitation landing page. The invited email is + // resolved from the row, never trusted from the URL. Reachable by any + // feature-flagged user who has the invite id, so the full invited address is + // disclosed only when the caller has already proven ownership of it + // (verifiedEmail matches); otherwise emailMatches is false and the handler + // returns a redacted hint, preventing address harvesting by a prober. + async getInvite( + inviteId: string, + userId: string, + ): Promise<{ + invite: IWorkspaceInviteWithContext; + emailMatches: boolean; + alreadyMember: boolean; + }> { + const invite = + await this.deps.workspaceRepository.findInviteWithContext(inviteId); + + if (!invite) { + throw new NotFoundError(ApiErrorCode.WORKSPACE_INVITE_NOT_FOUND); + } + + const user = await this.deps.userRepository.findById(userId); + const emailMatches = !!user?.verifiedEmail && user.verifiedEmail === invite.email; + + // Surfaced so the landing page can short-circuit the verify-then-accept flow + // for a caller who is already a member (the path an owner hits on their own + // invite). Without it the page would push them through email verification — + // which overwrites their verified email — only for the accept to be rejected + // by the same-member guard. This check is independent of verifiedEmail, so it + // can be evaluated before any verification happens. + const alreadyMember = await this.deps.workspaceRepository.isMember( + invite.workspaceId, + userId, + ); + + return { invite, emailMatches, alreadyMember }; + } + + // Invite-scoped email-verification send. Unlike the generic send endpoint, + // this dispatches a code ONLY when the submitted address matches the real + // invited address — so the invite flow can never email an unrelated address. + // The match decision is never reflected in the response: a matching and a + // non-matching (or unknown-invite) call return identically, so this endpoint + // is not an oracle a prober could use to harvest the invited address (the same + // anti-harvesting property getInvite preserves with its redacted hint). + async sendInviteVerification( + inviteId: string, + userId: string, + submittedEmail: string, + ): Promise { + const invite = + await this.deps.workspaceRepository.findInviteWithContext(inviteId); + + // Unknown invite or mismatched address: no-op, no send, uniform return. + if (!invite || normalizeEmail(submittedEmail) !== invite.email) { + return; + } + + await this.deps.emailVerificationService.sendCode(userId, invite.email); + } + + // Invitations addressed to the caller's verified email, with workspace name + + // inviter. Returns nothing until the user verifies a matching email, so a + // freshly-verified address surfaces its pending invitations here with no + // extra hook. + async listMyInvites(userId: string): Promise { + const user = await this.deps.userRepository.findById(userId); + + if (!user?.verifiedEmail) { + return []; + } + + return this.deps.workspaceRepository.listInvitesForEmail( + user.verifiedEmail, + ); + } + + // Both accept and decline are gated server-side on the caller's verifiedEmail + // matching the invitation's email. The decision axis is purely the user's + // verifiedEmail state; the Discord-provided email never enters the logic. + private async assertCanClaim( + inviteId: string, + userId: string, + ): Promise { + const invite = + await this.deps.workspaceRepository.findInviteWithContext(inviteId); + + if (!invite) { + throw new NotFoundError(ApiErrorCode.WORKSPACE_INVITE_NOT_FOUND); + } + + const user = await this.deps.userRepository.findById(userId); + + // The error CODE alone tells the client which case it is. The invited email + // is never echoed here: the invitee gets the (redacted) address from the + // gated single-invite GET, so this 403 must not leak it to a prober. + if (!user?.verifiedEmail) { + throw new ForbiddenError(ApiErrorCode.WORKSPACE_INVITE_EMAIL_UNVERIFIED); + } + + if (user.verifiedEmail !== invite.email) { + throw new ForbiddenError(ApiErrorCode.WORKSPACE_INVITE_EMAIL_MISMATCH); + } + + return invite; + } + + async acceptInvite( + inviteId: string, + userId: string, + ): Promise<{ workspaceSlug: string }> { + const invite = await this.assertCanClaim(inviteId, userId); + + // A user who is already a member of the workspace cannot consume the invite. + // This is the path an owner hits when they verify the invited email onto + // their own account and accept their own invitation: the membership insert + // would collide with their existing membership. Rejecting here (rather than + // swallowing the collision as idempotent success) leaves the invite pending + // so it can still reach the intended person. + if ( + await this.deps.workspaceRepository.isMember(invite.workspaceId, userId) + ) { + throw new ConflictError(ApiErrorCode.WORKSPACE_INVITE_ALREADY_MEMBER); + } + + const accepted = await this.deps.workspaceRepository.acceptInvite({ + inviteId, + userId, + }); + + if (!accepted) { + throw new NotFoundError(ApiErrorCode.WORKSPACE_INVITE_NOT_FOUND); + } + + return { workspaceSlug: invite.workspaceSlug }; + } + + async declineInvite(inviteId: string, userId: string): Promise { + await this.assertCanClaim(inviteId, userId); + + const declined = await this.deps.workspaceRepository.deleteInvite(inviteId); + + if (!declined) { + throw new NotFoundError(ApiErrorCode.WORKSPACE_INVITE_NOT_FOUND); + } + } + + async resendInvite( + slug: string, + userId: string, + inviteId: string, + ): Promise { + const { workspace, role } = await this.getWorkspaceForMemberBySlug( + slug, + userId, + ); + + if (!this.can("manageMembers", role)) { + throw new ForbiddenError(ApiErrorCode.WORKSPACE_INSUFFICIENT_ROLE); + } + + const invite = + await this.deps.workspaceRepository.findInviteByIdForWorkspace( + inviteId, + workspace.id, + ); + + if (!invite) { + throw new NotFoundError(ApiErrorCode.WORKSPACE_INVITE_NOT_FOUND); + } + + // Atomically acquire the resend slot before sending: the cooldown check and + // the lastSentAt advance are one conditional update, so two concurrent + // resends cannot both pass the window. A null return means the slot is still + // on cooldown (the invite exists — its existence was just verified above). + const claimed = await this.deps.workspaceRepository.claimInviteForResend( + invite.id, + workspace.id, + INVITE_RESEND_COOLDOWN_MS, + ); + + if (!claimed) { + throw new TooManyRequestsError( + ApiErrorCode.WORKSPACE_INVITE_RESEND_TOO_SOON, + ); + } + + // Send after acquiring the slot. A send failure leaves lastSentAt advanced — + // the safe direction (a transient failure cannot be retried until the next + // window rather than enabling a send-spam loop). + await this.sendInviteEmail(claimed.id, claimed.email, workspace.name); + } + + async revokeInvite( + slug: string, + userId: string, + inviteId: string, + ): Promise { + const { workspace, role } = await this.getWorkspaceForMemberBySlug( + slug, + userId, + ); + + if (!this.can("manageMembers", role)) { + throw new ForbiddenError(ApiErrorCode.WORKSPACE_INSUFFICIENT_ROLE); + } + + const deleted = await this.deps.workspaceRepository.deleteInvite( + inviteId, + workspace.id, + ); + + if (!deleted) { + throw new NotFoundError(ApiErrorCode.WORKSPACE_INVITE_NOT_FOUND); + } + } + + async listMembers(slug: string, userId: string): Promise { + const { workspace, role } = await this.getWorkspaceForMemberBySlug( + slug, + userId, + ); + + if (!this.can("manageMembers", role)) { + throw new ForbiddenError(ApiErrorCode.WORKSPACE_INSUFFICIENT_ROLE); + } + + return this.deps.workspaceRepository.listMembers(workspace.id); + } + + // Identity-aware routing: removing oneself is leaving (leaveWorkspace); + // removing another member requires removeMember (owner only). The actor vs + // target decision lives here, not in can(). + async removeMember( + slug: string, + actorUserId: string, + targetUserId: string, + ): Promise { + if (actorUserId === targetUserId) { + return this.leaveWorkspace(slug, actorUserId); + } + + const { workspace, role } = await this.getWorkspaceForMemberBySlug( + slug, + actorUserId, + ); + + if (!this.can("removeMember", role)) { + throw new ForbiddenError(ApiErrorCode.WORKSPACE_INSUFFICIENT_ROLE); + } + + const removed = await this.removeMembershipEnforcingOwnerCount( + workspace.id, + targetUserId, + ); + + if (!removed) { + throw new NotFoundError(ApiErrorCode.WORKSPACE_NOT_FOUND); + } + } + + async leaveWorkspace(slug: string, userId: string): Promise { + const { workspace, role } = await this.getWorkspaceForMemberBySlug( + slug, + userId, + ); + + if (!this.can("leaveWorkspace", role)) { + throw new ForbiddenError(ApiErrorCode.WORKSPACE_INSUFFICIENT_ROLE); + } + + const removed = await this.removeMembershipEnforcingOwnerCount( + workspace.id, + userId, + ); + + if (!removed) { + throw new NotFoundError(ApiErrorCode.WORKSPACE_NOT_FOUND); + } + } + + private async removeMembershipEnforcingOwnerCount( + workspaceId: string, + userId: string, + ): Promise { + try { + return await this.deps.workspaceRepository.removeMembership( + workspaceId, + userId, + ); + } catch (err) { + if (err instanceof CannotRemoveLastOwnerError) { + throw new ConflictError(ApiErrorCode.CANNOT_REMOVE_LAST_OWNER); + } + + throw err; + } + } +} diff --git a/services/backend-api/src/infra/email-from.ts b/services/backend-api/src/infra/email-from.ts new file mode 100644 index 000000000..b2774b0ab --- /dev/null +++ b/services/backend-api/src/infra/email-from.ts @@ -0,0 +1,20 @@ +import type { Config } from "../config"; + +export type FormatFrom = (displayName: string, localPart: string) => string; + +export function createFromFormatter(config: Config): FormatFrom { + const override = config.BACKEND_API_SMTP_FROM; + const domain = config.BACKEND_API_SMTP_FROM_DOMAIN; + + return (displayName, localPart) => { + if (override) { + return override; + } + if (!domain) { + throw new Error( + "createFromFormatter requires BACKEND_API_SMTP_FROM or BACKEND_API_SMTP_FROM_DOMAIN to be set", + ); + } + return `"${displayName}" <${localPart}@${domain}>`; + }; +} diff --git a/services/backend-api/src/infra/error-handler.ts b/services/backend-api/src/infra/error-handler.ts index 87111d4e6..86c61dce1 100644 --- a/services/backend-api/src/infra/error-handler.ts +++ b/services/backend-api/src/infra/error-handler.ts @@ -30,8 +30,8 @@ export class UnauthorizedError extends HttpError { } export class ForbiddenError extends HttpError { - constructor(code: ApiErrorCode, message?: string) { - super(403, code, message); + constructor(code: ApiErrorCode, message?: string, details?: unknown) { + super(403, code, message, details); this.name = "ForbiddenError"; } } @@ -186,6 +186,16 @@ export function errorHandler( ); } + // @fastify/rate-limit throws a plain FastifyError (statusCode 429), not an + // HttpError — without this it would fall through to the 500 branch below and a + // throttle would surface as an Internal Error. Normalize it to a standardized + // 429 (the plugin has already set Retry-After / RateLimit-* headers). + if (error.statusCode === 429) { + return reply + .status(429) + .send(createErrorResponse(ApiErrorCode.TOO_MANY_REQUESTS)); + } + logger.error(`Unhandled error - ${error.message}`, { exception: error.stack, discordId, diff --git a/services/backend-api/src/infra/smtp.ts b/services/backend-api/src/infra/smtp.ts index 07d25513c..428692e33 100644 --- a/services/backend-api/src/infra/smtp.ts +++ b/services/backend-api/src/infra/smtp.ts @@ -14,10 +14,14 @@ export function createSmtpTransport(config: Config): SmtpTransport { return null; } + // Production default is implicit TLS on 465; both are overridable so a local + // or test mailer can listen on a plain-SMTP port. + const secure = config.BACKEND_API_SMTP_SECURE; + return nodemailer.createTransport({ host, - port: 465, - secure: true, + port: config.BACKEND_API_SMTP_PORT ?? (secure ? 465 : 587), + secure, auth: { user: username, pass: password, diff --git a/services/backend-api/src/repositories/interfaces/user-feed.types.ts b/services/backend-api/src/repositories/interfaces/user-feed.types.ts index fda18c882..3af45dade 100644 --- a/services/backend-api/src/repositories/interfaces/user-feed.types.ts +++ b/services/backend-api/src/repositories/interfaces/user-feed.types.ts @@ -69,6 +69,7 @@ export interface IUserFeed { healthStatus: UserFeedHealthStatus; connections: IFeedConnections; user: IUserFeedUser; + workspaceId?: string; formatOptions?: IUserFeedFormatOptions; dateCheckOptions?: IUserFeedDateCheckOptions; shareManageOptions?: IUserFeedShareManageOptions; @@ -138,6 +139,7 @@ export interface CreateUserFeedInput { title: string; url: string; user: { id: string; discordUserId: string }; + workspaceId?: string; inputUrl?: string; connections?: IFeedConnections; feedRequestLookupKey?: string; @@ -196,6 +198,12 @@ export interface UserFeedListingFilters { export interface UserFeedListingInput { discordUserId: string; + /** + * Workspace scope. When set, the listing returns only feeds associated with + * this workspace (membership is verified by the caller). When absent, the + * listing returns the user's personal feeds (those with no workspaceId). + */ + workspaceId?: string; limit?: number; offset?: number; search?: string; @@ -375,8 +383,29 @@ export interface UserFeedForDelivery { users: Array; } +// Thrown inside the create transaction to abort it (rolling back the insert) +// when over limit; the service rethrows it as FeedLimitReachedException. +export class FeedLimitExceededError extends Error { + constructor() { + super("Feed limit exceeded"); + this.name = "FeedLimitExceededError"; + } +} + +export type FeedLimitScope = + | { scope: "workspace"; workspaceId: string; maxFeeds: number } + | { scope: "personal"; discordUserId: string; maxFeeds: number }; + export interface IUserFeedRepository { create(input: CreateUserFeedInput): Promise; + createWithLimitEnforcement( + input: CreateUserFeedInput, + limit: FeedLimitScope, + ): Promise; + cloneWithLimitEnforcement( + input: CloneUserFeedInput, + limit: FeedLimitScope, + ): Promise; findById(id: string): Promise; deleteAll(): Promise; bulkUpdateLookupKeys(operations: LookupKeyOperation[]): Promise; @@ -390,6 +419,7 @@ export interface IUserFeedRepository { filterFeedIdsByOwnership( feedIds: string[], discordUserId: string, + myWorkspaceIds?: string[], ): Promise; findOneAndUpdate( @@ -416,6 +446,7 @@ export interface IUserFeedRepository { // CRUD methods for UserFeedsService countByOwnership(discordUserId: string): Promise; + countByWorkspace(workspaceId: string): Promise; countByOwnershipExcludingDisabled( discordUserId: string, excludeDisabledCodes: UserFeedDisabledCode[], @@ -424,6 +455,7 @@ export interface IUserFeedRepository { findByIdAndOwnership( id: string, discordUserId: string, + myWorkspaceIds?: string[], ): Promise; findByIdAndCreator( id: string, diff --git a/services/backend-api/src/repositories/interfaces/user.types.ts b/services/backend-api/src/repositories/interfaces/user.types.ts index ac8efeea9..a0bca7835 100644 --- a/services/backend-api/src/repositories/interfaces/user.types.ts +++ b/services/backend-api/src/repositories/interfaces/user.types.ts @@ -38,6 +38,7 @@ export interface IUserPreferences { export interface IUserFeatureFlags { externalProperties?: boolean; + workspaces?: boolean; } export interface IUserExternalCredential { @@ -52,6 +53,8 @@ export interface IUser { id: string; discordUserId: string; email?: string; + verifiedEmail?: string; + verifiedEmailVerifiedAt?: Date; preferences?: IUserPreferences; featureFlags?: IUserFeatureFlags; enableBilling?: boolean; @@ -92,6 +95,7 @@ export interface IUserRepository { discordUserId: string, email: string, ): Promise; + setVerifiedEmail(userId: string, email: string): Promise; updatePreferencesByDiscordId( discordUserId: string, preferences: UpdateUserPreferencesInput, diff --git a/services/backend-api/src/repositories/mongoose/email-verification.mongoose.repository.ts b/services/backend-api/src/repositories/mongoose/email-verification.mongoose.repository.ts new file mode 100644 index 000000000..3b434c3c9 --- /dev/null +++ b/services/backend-api/src/repositories/mongoose/email-verification.mongoose.repository.ts @@ -0,0 +1,205 @@ +import { + Schema, + Types, + type Connection, + type Model, + type InferSchemaType, +} from "mongoose"; +import { BaseMongooseRepository } from "./base.mongoose.repository"; + +export interface IEmailVerification { + id: string; + userId: string; + email: string; + codeHash: string; + expiresAt: Date; + attempts: number; + createdAt: Date; +} + +const EmailVerificationSchema = new Schema( + { + userId: { type: Schema.Types.ObjectId, required: true }, + email: { type: String, required: true }, + codeHash: { type: String, required: true }, + expiresAt: { type: Date, required: true }, + attempts: { type: Number, required: true, default: 0 }, + }, + { timestamps: true }, +); + +EmailVerificationSchema.index({ userId: 1, email: 1 }); +EmailVerificationSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); + +type EmailVerificationDoc = InferSchemaType; + +// Append-only audit of dispatched verification sends, kept separate from the +// active-code collection (which is wiped per (user,email) on resend/confirm and +// so cannot count historical distinct targets). Used to cap how many DISTINCT +// addresses a single user can have codes sent to within a window. Self-pruning +// via TTL on createdAt. +const SEND_AUDIT_TTL_SECONDS = 24 * 60 * 60; + +const EmailVerificationSendSchema = new Schema( + { + userId: { type: Schema.Types.ObjectId, required: true }, + email: { type: String, required: true }, + }, + { timestamps: true }, +); + +EmailVerificationSendSchema.index({ userId: 1, createdAt: 1 }); +EmailVerificationSendSchema.index( + { createdAt: 1 }, + { expireAfterSeconds: SEND_AUDIT_TTL_SECONDS }, +); + +type EmailVerificationSendDoc = InferSchemaType< + typeof EmailVerificationSendSchema +>; + +export class EmailVerificationMongooseRepository extends BaseMongooseRepository< + IEmailVerification, + EmailVerificationDoc +> { + private model: Model; + + private sendModel: Model; + + constructor(connection: Connection) { + super(); + this.model = connection.model( + "EmailVerification", + EmailVerificationSchema, + ); + this.sendModel = connection.model( + "EmailVerificationSend", + EmailVerificationSendSchema, + ); + } + + protected toEntity( + doc: EmailVerificationDoc & { _id: Types.ObjectId }, + ): IEmailVerification { + return { + id: this.objectIdToString(doc._id), + userId: this.objectIdToString(doc.userId), + email: doc.email, + codeHash: doc.codeHash, + expiresAt: doc.expiresAt, + attempts: doc.attempts, + createdAt: doc.createdAt, + }; + } + + async createCode(input: { + userId: string; + email: string; + codeHash: string; + expiresAt: Date; + }): Promise { + const userId = this.stringToObjectId(input.userId); + await this.model.deleteMany({ userId, email: input.email }); + await this.model.create({ + userId, + email: input.email, + codeHash: input.codeHash, + expiresAt: input.expiresAt, + attempts: 0, + }); + } + + async findByUserEmail( + userId: string, + email: string, + ): Promise { + const doc = await this.model + .findOne({ userId: this.stringToObjectId(userId), email }) + .lean(); + + return doc + ? this.toEntity(doc as EmailVerificationDoc & { _id: Types.ObjectId }) + : null; + } + + // Recency window evaluated against the DB's own clock ($$NOW), so it does not + // depend on the app server's clock (no skew across instances). + async hasRecentCode( + userId: string, + email: string, + withinMs: number, + ): Promise { + const doc = await this.model + .findOne({ + userId: this.stringToObjectId(userId), + email, + $expr: { $gt: ["$createdAt", { $subtract: ["$$NOW", withinMs] }] }, + }) + .select("_id") + .lean(); + + return !!doc; + } + + async incrementAttempts(userId: string, email: string): Promise { + await this.model.updateOne( + { userId: this.stringToObjectId(userId), email }, + { $inc: { attempts: 1 } }, + ); + } + + async deleteForUserEmail(userId: string, email: string): Promise { + await this.model.deleteMany({ + userId: this.stringToObjectId(userId), + email, + }); + } + + // Append a row to the send audit (used by the distinct-target cap). Separate + // from createCode so the audit survives the per-(user,email) wipe on resend. + async recordSend(userId: string, email: string): Promise { + await this.sendModel.create({ + userId: this.stringToObjectId(userId), + email, + }); + } + + // Count distinct target addresses this user has had codes sent to within the + // window. Window evaluated against the DB clock ($$NOW) to avoid app clock skew. + async countDistinctRecentTargets( + userId: string, + withinMs: number, + ): Promise { + const result = await this.sendModel.aggregate<{ count: number }>([ + { + $match: { + userId: this.stringToObjectId(userId), + $expr: { $gt: ["$createdAt", { $subtract: ["$$NOW", withinMs] }] }, + }, + }, + { $group: { _id: "$email" } }, + { $count: "count" }, + ]); + + return result[0]?.count ?? 0; + } + + // Whether this user already targeted this exact address within the window (so + // re-sending to it is not counted against the distinct-target cap). + async hasRecentTarget( + userId: string, + email: string, + withinMs: number, + ): Promise { + const doc = await this.sendModel + .findOne({ + userId: this.stringToObjectId(userId), + email, + $expr: { $gt: ["$createdAt", { $subtract: ["$$NOW", withinMs] }] }, + }) + .select("_id") + .lean(); + + return !!doc; + } +} diff --git a/services/backend-api/src/repositories/mongoose/user-feed.mongoose.repository.ts b/services/backend-api/src/repositories/mongoose/user-feed.mongoose.repository.ts index 48cf83ab4..df87ef69c 100644 --- a/services/backend-api/src/repositories/mongoose/user-feed.mongoose.repository.ts +++ b/services/backend-api/src/repositories/mongoose/user-feed.mongoose.repository.ts @@ -35,7 +35,9 @@ import type { RefreshRateSyncInput, MaxDailyArticlesSyncInput, UserFeedForDelivery, + FeedLimitScope, } from "../interfaces/user-feed.types"; +import { FeedLimitExceededError } from "../interfaces/user-feed.types"; import type { SlotWindow } from "../../shared/types/slot-window.types"; import { getCommonFeedAggregateStages } from "../../shared/utils/get-common-feed-aggregate-stages"; import { calculateSlotOffsetMs } from "../../shared/utils/fnv1a-hash"; @@ -162,6 +164,10 @@ const UserFeedSchema = new Schema( }, connections: { type: FeedConnectionsSchema, default: {} }, user: { type: UserFeedUserSchema, required: true }, + // When set, the feed belongs to a workspace (all members share access). When + // absent, the feed is personal (owned by `user`). A feed is personal XOR + // one workspace's. + workspaceId: { type: Schema.Types.ObjectId }, formatOptions: { type: UserFeedFormatOptionsSchema }, dateCheckOptions: { type: UserFeedDateCheckOptionsSchema }, shareManageOptions: { type: UserFeedShareManageOptionsSchema }, @@ -191,6 +197,7 @@ UserFeedSchema.index({ url: 1, }); UserFeedSchema.index({ userRefreshRateSeconds: 1, refreshRateSeconds: 1 }); +UserFeedSchema.index({ workspaceId: 1 }); UserFeedSchema.index({ refreshRateSeconds: 1, slotOffsetMs: 1, @@ -270,6 +277,7 @@ export class UserFeedMongooseRepository id: this.objectIdToString(doc.user.id), discordUserId: doc.user.discordUserId, }, + workspaceId: this.objectIdToString(doc.workspaceId), formatOptions: doc.formatOptions, dateCheckOptions: doc.dateCheckOptions, shareManageOptions: doc.shareManageOptions @@ -431,8 +439,8 @@ export class UserFeedMongooseRepository ); } - async create(input: CreateUserFeedInput): Promise { - const doc = await this.model.create({ + private buildCreateDoc(input: CreateUserFeedInput) { + return { title: input.title, url: input.url, inputUrl: input.inputUrl, @@ -444,6 +452,7 @@ export class UserFeedMongooseRepository id: new Types.ObjectId(input.user.id), discordUserId: input.user.discordUserId, }, + workspaceId: input.workspaceId ? new Types.ObjectId(input.workspaceId) : undefined, refreshRateSeconds: input.refreshRateSeconds, maxDailyArticles: input.maxDailyArticles, dateCheckOptions: input.dateCheckOptions, @@ -464,16 +473,87 @@ export class UserFeedMongooseRepository externalProperties: input.externalProperties, formatOptions: input.formatOptions, userRefreshRateSeconds: input.userRefreshRateSeconds, - }); + }; + } + + async create(input: CreateUserFeedInput): Promise { + const doc = await this.model.create(this.buildCreateDoc(input)); return this.toEntity( doc as unknown as UserFeedDoc & { _id: Types.ObjectId }, ); } - async clone(input: CloneUserFeedInput): Promise { + // Inserts the feed and, in the same transaction, aborts (rolling back the + // insert) if it pushes the scope over `maxFeeds`. The in-transaction count + // makes concurrent creations serialize on write conflicts rather than racing + // a separate count-then-delete whose compensating delete could leak an + // over-limit feed on failure. Throws FeedLimitExceededError when over limit. + async createWithLimitEnforcement( + input: CreateUserFeedInput, + limit: FeedLimitScope, + ): Promise { + return this.createDocWithLimitEnforcement( + () => this.buildCreateDoc(input), + limit, + ); + } + + private async createDocWithLimitEnforcement( + buildDoc: () => Record, + limit: FeedLimitScope, + ): Promise { + const countFilter = + limit.scope === "workspace" + ? { workspaceId: this.stringToObjectId(limit.workspaceId) } + : { + $and: [this.getOwnershipFilter(limit.discordUserId), { workspaceId: null }], + }; + + const session = await this.model.db.startSession(); + + try { + let result: IUserFeed | undefined; + + await session.withTransaction(async () => { + const docs = await this.model.create([buildDoc()], { session }); + const doc = docs[0]; + + if (!doc) { + throw new Error("Feed creation transaction produced no feed"); + } + + const count = await this.model + .countDocuments(countFilter) + .session(session); + + if (count > limit.maxFeeds) { + throw new FeedLimitExceededError(); + } + + result = this.toEntity( + doc.toObject() as UserFeedDoc & { _id: Types.ObjectId }, + ); + }); + + if (!result) { + throw new Error("Feed creation transaction produced no feed"); + } + + return result; + } finally { + await session.endSession(); + } + } + + private buildCloneDoc(input: CloneUserFeedInput) { const { sourceFeed, overrides } = input; + // `cloneableFields` intentionally retains `user` and `workspaceId` from the + // source, so a clone lands in the same scope as its source (a workspace feed + // clones into the same workspace; a personal feed stays personal). The + // service enforces the matching feed limit (workspace vs personal) + // accordingly. const { id, connections, @@ -488,7 +568,7 @@ export class UserFeedMongooseRepository const effectiveRefreshRate = getEffectiveRefreshRateSeconds(cloneableFields); - const doc = await this.model.create({ + return { ...cloneableFields, title: overrides.title || cloneableFields.title, url, @@ -497,13 +577,28 @@ export class UserFeedMongooseRepository slotOffsetMs: effectiveRefreshRate ? calculateSlotOffsetMs(url, effectiveRefreshRate) : undefined, - }); + }; + } + + async clone(input: CloneUserFeedInput): Promise { + const doc = await this.model.create(this.buildCloneDoc(input)); return this.toEntity( doc as unknown as UserFeedDoc & { _id: Types.ObjectId }, ); } + // See createWithLimitEnforcement — same atomic insert-and-check, for clones. + async cloneWithLimitEnforcement( + input: CloneUserFeedInput, + limit: FeedLimitScope, + ): Promise { + return this.createDocWithLimitEnforcement( + () => this.buildCloneDoc(input) as Record, + limit, + ); + } + async findById(id: string): Promise { const doc = await this.model.findById(this.stringToObjectId(id)).lean(); if (!doc) { @@ -512,20 +607,28 @@ export class UserFeedMongooseRepository return this.toEntity(doc as UserFeedDoc & { _id: Types.ObjectId }); } - private getOwnershipFilter(discordUserId: string) { - return { - $or: [ - { "user.discordUserId": discordUserId }, - { - "shareManageOptions.invites": { - $elemMatch: { - discordUserId, - status: UserFeedManagerStatus.Accepted, - }, + private getOwnershipFilter(discordUserId: string, myWorkspaceIds: string[] = []) { + const $or: Record[] = [ + { "user.discordUserId": discordUserId }, + { + "shareManageOptions.invites": { + $elemMatch: { + discordUserId, + status: UserFeedManagerStatus.Accepted, }, }, - ], - }; + }, + ]; + + if (myWorkspaceIds.length) { + // Any member of a workspace may access that workspace's feeds; roles do not + // restrict feed actions. + $or.push({ + workspaceId: { $in: myWorkspaceIds.map((id) => this.stringToObjectId(id)) }, + }); + } + + return { $or }; } private escapeRegExp(string: string): string { @@ -542,7 +645,7 @@ export class UserFeedMongooseRepository } private buildListingPipeline(input: UserFeedListingInput): PipelineStage[] { - const { discordUserId, search, filters } = input; + const { discordUserId, workspaceId, search, filters } = input; const badUserFeedCodes = Object.values(UserFeedDisabledCode).filter( (c) => c !== UserFeedDisabledCode.Manual, @@ -552,8 +655,16 @@ export class UserFeedMongooseRepository ); const feedConnectionTypeKeys = Object.values(FeedConnectionTypeEntityKey); + // Strict scope separation: a workspace-scoped listing returns only that workspace's + // feeds (membership is verified by the caller); a personal listing returns + // only personal feeds (no workspaceId). Workspace feeds never leak into personal + // scope and vice versa. + const scopeMatch: PipelineStage.Match["$match"] = workspaceId + ? { workspaceId: this.stringToObjectId(workspaceId) } + : { $and: [this.getOwnershipFilter(discordUserId), { workspaceId: null }] }; + const pipeline: PipelineStage[] = [ - { $match: this.getOwnershipFilter(discordUserId) }, + { $match: scopeMatch }, { $addFields: { ownedByUser: { $eq: ["$user.discordUserId", discordUserId] }, @@ -750,7 +861,15 @@ export class UserFeedMongooseRepository } async countByOwnership(discordUserId: string): Promise { - return this.model.countDocuments(this.getOwnershipFilter(discordUserId)); + // Personal feed count only — workspace feeds (workspaceId set) count against the + // workspace's limit, not the user's. + return this.model.countDocuments({ + $and: [this.getOwnershipFilter(discordUserId), { workspaceId: null }], + }); + } + + async countByWorkspace(workspaceId: string): Promise { + return this.model.countDocuments({ workspaceId: this.stringToObjectId(workspaceId) }); } async countByOwnershipExcludingDisabled( @@ -770,11 +889,12 @@ export class UserFeedMongooseRepository async findByIdAndOwnership( id: string, discordUserId: string, + myWorkspaceIds: string[] = [], ): Promise { const doc = await this.model .findOne({ _id: this.stringToObjectId(id), - ...this.getOwnershipFilter(discordUserId), + ...this.getOwnershipFilter(discordUserId, myWorkspaceIds), }) .lean(); @@ -806,6 +926,7 @@ export class UserFeedMongooseRepository async filterFeedIdsByOwnership( feedIds: string[], discordUserId: string, + myWorkspaceIds: string[] = [], ): Promise { const objectIds = feedIds .filter((id) => Types.ObjectId.isValid(id)) @@ -816,7 +937,7 @@ export class UserFeedMongooseRepository const docs = await this.model .find({ _id: { $in: objectIds }, - ...this.getOwnershipFilter(discordUserId), + ...this.getOwnershipFilter(discordUserId, myWorkspaceIds), }) .select("_id") .lean(); @@ -1044,12 +1165,15 @@ export class UserFeedMongooseRepository async *getFeedsGroupedByUserForLimitEnforcement( query: UserFeedLimitEnforcementQuery, ): AsyncIterable { + // Workspace feeds (workspaceId set) are excluded: the personal feed-limit + // enforcement is keyed on the creator's discordUserId and must not disable + // a workspace feed under the creator's personal limit. const matchFilter = query.type === "include" - ? { "user.discordUserId": { $in: query.discordUserIds } } + ? { "user.discordUserId": { $in: query.discordUserIds }, workspaceId: null } : query.discordUserIds.length > 0 - ? { "user.discordUserId": { $nin: query.discordUserIds } } - : {}; + ? { "user.discordUserId": { $nin: query.discordUserIds }, workspaceId: null } + : { workspaceId: null }; if (query.type === "include" && query.discordUserIds.length === 0) { return; @@ -1160,6 +1284,9 @@ export class UserFeedMongooseRepository await this.model.updateMany( { "user.discordUserId": userFilter, + // Workspace feeds derive webhook entitlement from workspace benefits, not the + // creator's personal subscription. + workspaceId: null, "connections.discordChannels": { $elemMatch: { "details.webhook.id": { $exists: true }, @@ -1190,6 +1317,7 @@ export class UserFeedMongooseRepository await this.model.updateMany( { "user.discordUserId": userFilter, + workspaceId: null, "connections.discordChannels": { $elemMatch: { "details.webhook.id": { $exists: true }, @@ -1222,6 +1350,8 @@ export class UserFeedMongooseRepository { userRefreshRateSeconds: supporterRefreshRateSeconds, "user.discordUserId": { $nin: supporterDiscordUserIds }, + // Workspace feeds keep their workspace-derived rate. + workspaceId: null, }, { $unset: { userRefreshRateSeconds: "" } }, ); @@ -1234,6 +1364,7 @@ export class UserFeedMongooseRepository { userRefreshRateSeconds: supporterRefreshRateSeconds, "user.discordUserId": target.discordUserId, + workspaceId: null, }, { $unset: { userRefreshRateSeconds: "" } }, ); @@ -1914,10 +2045,14 @@ export class UserFeedMongooseRepository ); const bulkOps: Parameters[0] = [ + // workspaceId: null excludes workspace feeds — their refresh rate comes + // from workspace benefits at creation, not the creator's personal + // benefits. ...supporterLimits.map(({ discordUserIds, refreshRateSeconds }) => ({ updateMany: { filter: { "user.discordUserId": { $in: discordUserIds }, + workspaceId: null, refreshRateSeconds: { $ne: refreshRateSeconds }, }, update: { $set: { refreshRateSeconds } }, @@ -1927,6 +2062,7 @@ export class UserFeedMongooseRepository updateMany: { filter: { "user.discordUserId": { $nin: allSupporterUserIds }, + workspaceId: null, refreshRateSeconds: { $ne: defaultRefreshRateSeconds }, }, update: { $set: { refreshRateSeconds: defaultRefreshRateSeconds } }, @@ -1947,10 +2083,13 @@ export class UserFeedMongooseRepository ); const bulkOps: Parameters[0] = [ + // workspaceId: null excludes workspace feeds — their daily article limit comes from + // workspace benefits at creation, not the creator's benefits. ...supporterLimits.map(({ discordUserIds, maxDailyArticles }) => ({ updateMany: { filter: { "user.discordUserId": { $in: discordUserIds }, + workspaceId: null, maxDailyArticles: { $ne: maxDailyArticles }, }, update: { $set: { maxDailyArticles } }, @@ -1960,6 +2099,7 @@ export class UserFeedMongooseRepository updateMany: { filter: { "user.discordUserId": { $nin: allSupporterUserIds }, + workspaceId: null, maxDailyArticles: { $ne: defaultMaxDailyArticles }, }, update: { $set: { maxDailyArticles: defaultMaxDailyArticles } }, @@ -1986,6 +2126,7 @@ export class UserFeedMongooseRepository const cursor = this.model .find({ "user.discordUserId": { $in: discordUserIds }, + workspaceId: null, refreshRateSeconds: { $ne: refreshRateSeconds }, }) .select("_id url userRefreshRateSeconds") @@ -2007,6 +2148,7 @@ export class UserFeedMongooseRepository const cursor = this.model .find({ "user.discordUserId": { $nin: allSupporterUserIds }, + workspaceId: null, refreshRateSeconds: { $ne: defaultRefreshRateSeconds }, }) .select("_id url userRefreshRateSeconds") diff --git a/services/backend-api/src/repositories/mongoose/user.mongoose.repository.ts b/services/backend-api/src/repositories/mongoose/user.mongoose.repository.ts index c196da22c..9c811abdb 100644 --- a/services/backend-api/src/repositories/mongoose/user.mongoose.repository.ts +++ b/services/backend-api/src/repositories/mongoose/user.mongoose.repository.ts @@ -73,6 +73,7 @@ const UserPreferencesSchema = new Schema( const UserFeatureFlagsSchema = new Schema( { externalProperties: { type: Boolean }, + workspaces: { type: Boolean }, }, { _id: false, timestamps: false }, ); @@ -100,6 +101,8 @@ const UserSchema = new Schema( { discordUserId: { type: String, required: true, unique: true }, email: { type: String }, + verifiedEmail: { type: String }, + verifiedEmailVerifiedAt: { type: Date }, preferences: { type: UserPreferencesSchema, default: {} }, featureFlags: { type: UserFeatureFlagsSchema, default: {} }, enableBilling: { type: Boolean }, @@ -118,6 +121,8 @@ UserSchema.index({ "externalCredentials.0": 1, }); +UserSchema.index({ verifiedEmail: 1 }, { unique: true, sparse: true }); + type UserDoc = InferSchemaType; export class UserMongooseRepository @@ -138,6 +143,8 @@ export class UserMongooseRepository id: this.objectIdToString(doc._id), discordUserId: doc.discordUserId, email: doc.email, + verifiedEmail: doc.verifiedEmail, + verifiedEmailVerifiedAt: doc.verifiedEmailVerifiedAt, preferences: doc.preferences, featureFlags: doc.featureFlags, enableBilling: doc.enableBilling, @@ -173,6 +180,11 @@ export class UserMongooseRepository return doc ? this.toEntity(doc as UserDoc & { _id: Types.ObjectId }) : null; } + async findByVerifiedEmail(email: string): Promise { + const doc = await this.model.findOne({ verifiedEmail: email }).lean(); + return doc ? this.toEntity(doc as UserDoc & { _id: Types.ObjectId }) : null; + } + async findByDiscordId(discordUserId: string): Promise { const doc = await this.model.findOne({ discordUserId }).lean(); return doc ? this.toEntity(doc as UserDoc & { _id: Types.ObjectId }) : null; @@ -204,6 +216,13 @@ export class UserMongooseRepository return doc ? this.toEntity(doc as UserDoc & { _id: Types.ObjectId }) : null; } + async setVerifiedEmail(userId: string, email: string): Promise { + await this.model.updateOne( + { _id: this.stringToObjectId(userId) }, + { $set: { verifiedEmail: email, verifiedEmailVerifiedAt: new Date() } }, + ); + } + async updatePreferencesByDiscordId( discordUserId: string, preferences: UpdateUserPreferencesInput, diff --git a/services/backend-api/src/repositories/mongoose/workspace.mongoose.repository.ts b/services/backend-api/src/repositories/mongoose/workspace.mongoose.repository.ts new file mode 100644 index 000000000..f4c73c369 --- /dev/null +++ b/services/backend-api/src/repositories/mongoose/workspace.mongoose.repository.ts @@ -0,0 +1,810 @@ +import { + Schema, + Types, + type Connection, + type Model, + type InferSchemaType, +} from "mongoose"; +import { BaseMongooseRepository } from "./base.mongoose.repository"; +import { normalizeEmail } from "../../shared/utils/normalizeEmail"; + +// owner ⊇ admin. There is no read-only tier: every membership can manage the +// workspace and its feeds. Only owner-gated actions (delete, transfer) require +// the owner role. The creator is the owner; the ≥1-owner invariant (a workspace +// must always have at least one owner) is enforced by the operations that could +// remove one (leave/remove/transfer) when they are built. +export const WORKSPACE_ROLES = ["owner", "admin"] as const; +export type WorkspaceRole = (typeof WORKSPACE_ROLES)[number]; + +// Thrown when the unique slug index rejects a write (concurrent create/rename +// race that slipped past the service's pre-check). The service maps this to a +// WORKSPACE_SLUG_TAKEN conflict so the race surfaces as 409, not a generic 500. +export class WorkspaceSlugTakenError extends Error { + constructor() { + super("Workspace slug already taken"); + this.name = "WorkspaceSlugTakenError"; + } +} + +function isDuplicateKeyError(err: unknown): boolean { + return ( + typeof err === "object" && + err !== null && + (err as { code?: number }).code === 11000 + ); +} + +export interface IWorkspace { + id: string; + name: string; + slug: string; + createdByUserId: string; + createdAt: Date; + updatedAt: Date; +} + +export interface IWorkspaceWithRole { + id: string; + name: string; + slug: string; + role: WorkspaceRole; +} + +export interface IWorkspaceMember { + userId: string; + role: WorkspaceRole; + discordUserId: string; +} + +// Thrown when removing a membership inside the transaction would leave the +// workspace with zero owners. The service maps this to CANNOT_REMOVE_LAST_OWNER. +export class CannotRemoveLastOwnerError extends Error { + constructor() { + super("Workspace must retain at least one owner"); + this.name = "CannotRemoveLastOwnerError"; + } +} + +export interface IWorkspaceInvite { + id: string; + workspaceId: string; + email: string; + role: WorkspaceRole; + invitedByUserId: string; + createdAt: Date; + lastSentAt: Date; +} + +export interface IWorkspaceInviteWithContext extends IWorkspaceInvite { + workspaceName: string; + workspaceSlug: string; +} + +// Thrown when the unique { workspaceId, email } index rejects a write (a +// concurrent create that slipped past the service's already-invited pre-check). +// The service maps this to a WORKSPACE_ALREADY_INVITED conflict. +export class WorkspaceInviteExistsError extends Error { + constructor() { + super("Workspace invite already exists"); + this.name = "WorkspaceInviteExistsError"; + } +} + +const WorkspaceSchema = new Schema( + { + name: { type: String, required: true, trim: true }, + slug: { type: String, required: true, trim: true, lowercase: true }, + createdByUserId: { type: Schema.Types.ObjectId, required: true }, + }, + { timestamps: true }, +); + +WorkspaceSchema.index({ slug: 1 }, { unique: true }); + +const WorkspaceMembershipSchema = new Schema( + { + workspaceId: { type: Schema.Types.ObjectId, required: true }, + userId: { type: Schema.Types.ObjectId, required: true }, + role: { type: String, required: true, enum: WORKSPACE_ROLES }, + }, + { timestamps: true }, +); + +// One membership per (workspace, user). The userId-leading field order also +// serves the hot "workspaces I'm in" lookup (find by userId) via the index +// prefix, so no separate { userId } index is needed. A workspaceId-leading +// index would be added if/when "members of a workspace" listing ships (member +// management). +WorkspaceMembershipSchema.index( + { userId: 1, workspaceId: 1 }, + { unique: true }, +); + +// Serves "members of a workspace" listing (member management) and accelerates +// the owner-count invariant query. Distinct key from the unique +// { userId, workspaceId } index above, so the two do not conflict. +WorkspaceMembershipSchema.index({ workspaceId: 1, role: 1 }); + +// An invitation is bound to an email, never a Discord/user identity, keeping +// the workspace model decoupled from Discord. Existence-based lifecycle: a row +// exists (pending) or is gone (accepted/declined/revoked) — no status, no TTL. +const WorkspaceInviteSchema = new Schema( + { + workspaceId: { type: Schema.Types.ObjectId, required: true }, + email: { type: String, required: true, trim: true, lowercase: true }, + role: { type: String, required: true, enum: WORKSPACE_ROLES, default: "admin" }, + invitedByUserId: { type: Schema.Types.ObjectId, required: true }, + // When the notification email was last dispatched. Drives the per-invite + // resend cooldown; set on creation and on each resend. + lastSentAt: { type: Date, required: true, default: Date.now }, + }, + { timestamps: true }, +); + +// One pending invitation per (workspace, email). The same email may hold +// pending invitations in multiple workspaces, so the uniqueness is on the pair, +// not on email alone. +WorkspaceInviteSchema.index({ workspaceId: 1, email: 1 }, { unique: true }); +// Supports the "which invitations are waiting for this just-verified address?" +// lookup keyed on email across workspaces. +WorkspaceInviteSchema.index({ email: 1 }); + +type WorkspaceDoc = InferSchemaType; +type WorkspaceMembershipDoc = InferSchemaType; +type WorkspaceInviteDoc = InferSchemaType; + +export class WorkspaceMongooseRepository extends BaseMongooseRepository< + IWorkspace, + WorkspaceDoc +> { + private workspaceModel: Model; + private membershipModel: Model; + private inviteModel: Model; + + constructor(connection: Connection) { + super(); + this.workspaceModel = connection.model( + "Workspace", + WorkspaceSchema, + ); + this.membershipModel = connection.model( + "WorkspaceMembership", + WorkspaceMembershipSchema, + ); + this.inviteModel = connection.model( + "WorkspaceInvite", + WorkspaceInviteSchema, + ); + } + + protected toEntity(doc: WorkspaceDoc & { _id: Types.ObjectId }): IWorkspace { + return { + id: this.objectIdToString(doc._id), + name: doc.name, + slug: doc.slug, + createdByUserId: this.objectIdToString(doc.createdByUserId), + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + }; + } + + async createWorkspaceWithOwner(input: { + name: string; + slug: string; + ownerUserId: string; + }): Promise { + const ownerId = this.stringToObjectId(input.ownerUserId); + const session = await this.workspaceModel.db.startSession(); + + try { + let result: IWorkspace | undefined; + + await session.withTransaction(async () => { + let workspace; + + try { + const workspaces = await this.workspaceModel.create( + [{ name: input.name, slug: input.slug, createdByUserId: ownerId }], + { session }, + ); + workspace = workspaces[0]; + } catch (err) { + if (isDuplicateKeyError(err)) { + throw new WorkspaceSlugTakenError(); + } + + throw err; + } + + if (!workspace) { + throw new Error( + "Workspace creation transaction produced no workspace", + ); + } + + await this.membershipModel.create( + [{ workspaceId: workspace._id, userId: ownerId, role: "owner" }], + { session }, + ); + + result = this.toEntity( + workspace.toObject() as WorkspaceDoc & { _id: Types.ObjectId }, + ); + }); + + if (!result) { + throw new Error("Workspace creation transaction produced no workspace"); + } + + return result; + } finally { + await session.endSession(); + } + } + + async listWorkspacesForUser( + userId: string, + ): Promise { + const memberships = await this.membershipModel + .find({ userId: this.stringToObjectId(userId) }) + .lean(); + + if (!memberships.length) { + return []; + } + + const roleByWorkspaceId = new Map( + memberships.map((m) => [ + m.workspaceId.toString(), + m.role as WorkspaceRole, + ]), + ); + + const workspaces = await this.workspaceModel + .find({ _id: { $in: memberships.map((m) => m.workspaceId) } }) + .lean(); + + return workspaces.map((w) => ({ + id: this.objectIdToString(w._id), + name: w.name, + slug: w.slug, + role: roleByWorkspaceId.get(w._id.toString()) as WorkspaceRole, + })); + } + + // The set of workspace ids a user belongs to, for workspace-feed + // authorization. Served by the userId-prefix of the unique membership index. + async listWorkspaceIdsForUser(userId: string): Promise { + const memberships = await this.membershipModel + .find({ userId: this.stringToObjectId(userId) }) + .select("workspaceId") + .lean(); + + return memberships.map((m) => m.workspaceId.toString()); + } + + // Returns null whether the user isn't a member or the workspace no longer + // exists, so the two cases are indistinguishable to callers (no existence + // leak). + async findMembershipWithWorkspace( + workspaceId: string, + userId: string, + ): Promise<{ workspace: IWorkspace; role: WorkspaceRole } | null> { + const results = await this.membershipModel.aggregate<{ + role: WorkspaceRole; + workspace: WorkspaceDoc & { _id: Types.ObjectId }; + }>([ + { + $match: { + workspaceId: this.stringToObjectId(workspaceId), + userId: this.stringToObjectId(userId), + }, + }, + { + $lookup: { + from: this.workspaceModel.collection.name, + localField: "workspaceId", + foreignField: "_id", + as: "workspace", + }, + }, + { $unwind: "$workspace" }, + ]); + + const result = results[0]; + + if (!result) { + return null; + } + + return { workspace: this.toEntity(result.workspace), role: result.role }; + } + + async updateName( + workspaceId: string, + name: string, + ): Promise { + const doc = await this.workspaceModel + .findByIdAndUpdate( + this.stringToObjectId(workspaceId), + { $set: { name } }, + { new: true }, + ) + .lean(); + + return doc + ? this.toEntity(doc as WorkspaceDoc & { _id: Types.ObjectId }) + : null; + } + + async updateSlug( + workspaceId: string, + slug: string, + ): Promise { + let doc; + + try { + doc = await this.workspaceModel + .findByIdAndUpdate( + this.stringToObjectId(workspaceId), + { $set: { slug } }, + { new: true }, + ) + .lean(); + } catch (err) { + if (isDuplicateKeyError(err)) { + throw new WorkspaceSlugTakenError(); + } + + throw err; + } + + return doc + ? this.toEntity(doc as WorkspaceDoc & { _id: Types.ObjectId }) + : null; + } + + // excludeWorkspaceId allows the workspace's own current slug to pass the + // uniqueness check + async isSlugTaken( + slug: string, + excludeWorkspaceId?: string, + ): Promise { + const filter: Record = { slug }; + + if (excludeWorkspaceId) { + filter._id = { $ne: this.stringToObjectId(excludeWorkspaceId) }; + } + + return !!(await this.workspaceModel.exists(filter)); + } + + async findMembershipWithWorkspaceBySlug( + slug: string, + userId: string, + ): Promise<{ workspace: IWorkspace; role: WorkspaceRole } | null> { + const workspaceDoc = await this.workspaceModel.findOne({ slug }).lean(); + + if (!workspaceDoc) { + return null; + } + + return this.findMembershipWithWorkspace( + this.objectIdToString(workspaceDoc._id), + userId, + ); + } + + // Number of owners in a workspace. The ≥1-owner invariant uses this: any + // operation that would remove or demote an owner (leave/remove/transfer, when + // built) must reject if this would drop to zero, so a workspace can never be + // orphaned. + async countOwners(workspaceId: string): Promise { + return this.membershipModel.countDocuments({ + workspaceId: this.stringToObjectId(workspaceId), + role: "owner", + }); + } + + // The members of a workspace with their roles, joined with the minimal user + // identifier (discordUserId) the member-management view needs. The verified + // email is deliberately not exposed here. Served by the { workspaceId, role } + // index. + async listMembers(workspaceId: string): Promise { + const results = await this.membershipModel.aggregate<{ + userId: Types.ObjectId; + role: WorkspaceRole; + user: { discordUserId: string }; + }>([ + { $match: { workspaceId: this.stringToObjectId(workspaceId) } }, + { + $lookup: { + from: "users", + localField: "userId", + foreignField: "_id", + as: "user", + }, + }, + { $unwind: "$user" }, + { $sort: { createdAt: 1 } }, + ]); + + return results.map((m) => ({ + userId: this.objectIdToString(m.userId), + role: m.role, + discordUserId: m.user.discordUserId, + })); + } + + // Delete a membership while preserving the ≥1-owner invariant. The delete and + // the owner re-count run in one transaction (mirrors createWorkspaceWithOwner) + // so a concurrent demotion cannot slip a workspace to zero owners between the + // check and the delete. Throws CannotRemoveLastOwnerError if removing this + // membership would leave no owners; returns false if there was no such + // membership. + async removeMembership( + workspaceId: string, + userId: string, + ): Promise { + const workspaceObjectId = this.stringToObjectId(workspaceId); + const userObjectId = this.stringToObjectId(userId); + const session = await this.membershipModel.db.startSession(); + + try { + let removed = false; + + await session.withTransaction(async () => { + const membership = await this.membershipModel + .findOne( + { workspaceId: workspaceObjectId, userId: userObjectId }, + null, + { session }, + ) + .lean(); + + if (!membership) { + removed = false; + return; + } + + if (membership.role === "owner") { + const ownerCount = await this.membershipModel.countDocuments( + { workspaceId: workspaceObjectId, role: "owner" }, + { session }, + ); + + if (ownerCount <= 1) { + throw new CannotRemoveLastOwnerError(); + } + } + + await this.membershipModel.deleteOne( + { workspaceId: workspaceObjectId, userId: userObjectId }, + { session }, + ); + + removed = true; + }); + + return removed; + } finally { + await session.endSession(); + } + } + + private toInviteEntity( + doc: WorkspaceInviteDoc & { _id: Types.ObjectId }, + ): IWorkspaceInvite { + return { + id: this.objectIdToString(doc._id), + workspaceId: this.objectIdToString(doc.workspaceId), + email: doc.email, + role: doc.role as WorkspaceRole, + invitedByUserId: this.objectIdToString(doc.invitedByUserId), + createdAt: doc.createdAt, + lastSentAt: doc.lastSentAt, + }; + } + + // Whether a user already holds a membership in the workspace. Served by the + // userId-prefix of the unique membership index. + async isMember(workspaceId: string, userId: string): Promise { + return !!(await this.membershipModel.exists({ + workspaceId: this.stringToObjectId(workspaceId), + userId: this.stringToObjectId(userId), + })); + } + + async findPendingInvite( + workspaceId: string, + email: string, + ): Promise { + const doc = await this.inviteModel + .findOne({ + workspaceId: this.stringToObjectId(workspaceId), + email: normalizeEmail(email), + }) + .lean(); + + return doc + ? this.toInviteEntity(doc as WorkspaceInviteDoc & { _id: Types.ObjectId }) + : null; + } + + // A fresh invite id, so the notification link can be built before the row is + // persisted (send-then-persist: a failed send leaves nothing behind). + generateInviteId(): string { + return new Types.ObjectId().toHexString(); + } + + async createInvite(input: { + id: string; + workspaceId: string; + email: string; + role: WorkspaceRole; + invitedByUserId: string; + }): Promise { + let doc; + + try { + doc = await this.inviteModel.create({ + _id: this.stringToObjectId(input.id), + workspaceId: this.stringToObjectId(input.workspaceId), + email: normalizeEmail(input.email), + role: input.role, + invitedByUserId: this.stringToObjectId(input.invitedByUserId), + }); + } catch (err) { + if (isDuplicateKeyError(err)) { + throw new WorkspaceInviteExistsError(); + } + + throw err; + } + + return this.toInviteEntity( + doc.toObject() as WorkspaceInviteDoc & { _id: Types.ObjectId }, + ); + } + + async listInvitesForWorkspace( + workspaceId: string, + ): Promise { + const docs = await this.inviteModel + .find({ workspaceId: this.stringToObjectId(workspaceId) }) + .sort({ createdAt: -1 }) + .lean(); + + return docs.map((d) => + this.toInviteEntity(d as WorkspaceInviteDoc & { _id: Types.ObjectId }), + ); + } + + // A single invitation joined with its workspace name, for the invitation + // landing page. The invited email comes from the row, never the URL. An + // invitation id that isn't a valid ObjectId resolves to null (treated as a + // missing invitation by the caller) rather than throwing. + async findInviteWithContext( + inviteId: string, + ): Promise { + if (!Types.ObjectId.isValid(inviteId)) { + return null; + } + + const results = await this.inviteModel.aggregate< + WorkspaceInviteDoc & { + _id: Types.ObjectId; + workspace: WorkspaceDoc & { _id: Types.ObjectId }; + } + >([ + { $match: { _id: this.stringToObjectId(inviteId) } }, + { + $lookup: { + from: this.workspaceModel.collection.name, + localField: "workspaceId", + foreignField: "_id", + as: "workspace", + }, + }, + { $unwind: "$workspace" }, + ]); + + const result = results[0]; + + if (!result) { + return null; + } + + return { + ...this.toInviteEntity(result), + workspaceName: result.workspace.name, + workspaceSlug: result.workspace.slug, + }; + } + + // The pending invitations addressed to a (verified) email across all + // workspaces, joined with workspace name. Served by the { email } index. This + // is what surfaces invitations to a user once they verify the matching email. + async listInvitesForEmail( + email: string, + ): Promise { + const results = await this.inviteModel.aggregate< + WorkspaceInviteDoc & { + _id: Types.ObjectId; + workspace: WorkspaceDoc & { _id: Types.ObjectId }; + } + >([ + { $match: { email: normalizeEmail(email) } }, + { + $lookup: { + from: this.workspaceModel.collection.name, + localField: "workspaceId", + foreignField: "_id", + as: "workspace", + }, + }, + { $unwind: "$workspace" }, + { $sort: { createdAt: -1 } }, + ]); + + return results.map((result) => ({ + ...this.toInviteEntity(result), + workspaceName: result.workspace.name, + workspaceSlug: result.workspace.slug, + })); + } + + // Transactionally claim an invitation: delete the invite row and insert the + // membership in one atomic unit (mirrors createWorkspaceWithOwner). Returns + // false if the invite no longer exists (already accepted/declined/revoked), + // so neither side is half-applied. A pre-existing membership (the unique + // { userId, workspaceId } index rejects the insert with 11000) aborts the + // transaction, rolling back the delete; the user is already a member, so the + // outcome is idempotent success — the invite is consumed with a standalone + // delete and true is returned. + async acceptInvite(input: { + inviteId: string; + userId: string; + }): Promise { + if (!Types.ObjectId.isValid(input.inviteId)) { + return false; + } + + const inviteId = this.stringToObjectId(input.inviteId); + const userId = this.stringToObjectId(input.userId); + const session = await this.inviteModel.db.startSession(); + + try { + let accepted = false; + let alreadyMember = false; + + try { + await session.withTransaction(async () => { + const invite = await this.inviteModel + .findOneAndDelete({ _id: inviteId }, { session }) + .lean(); + + if (!invite) { + accepted = false; + return; + } + + await this.membershipModel.create( + [ + { + workspaceId: invite.workspaceId, + userId, + role: invite.role, + }, + ], + { session }, + ); + + accepted = true; + }); + } catch (err) { + if (!isDuplicateKeyError(err)) { + throw err; + } + + // The membership already exists; the transaction aborted, rolling back + // the delete. The user is already a member, so consume the invite with a + // standalone delete and treat acceptance as idempotent success. + alreadyMember = true; + } + + if (alreadyMember) { + await this.inviteModel.deleteOne({ _id: inviteId }); + + return true; + } + + return accepted; + } finally { + await session.endSession(); + } + } + + // Scoped to the workspace so an invite id from another workspace resolves to + // null (no cross-workspace resend/revoke by guessing ids). + async findInviteByIdForWorkspace( + inviteId: string, + workspaceId: string, + ): Promise { + if (!Types.ObjectId.isValid(inviteId)) { + return null; + } + + const doc = await this.inviteModel + .findOne({ + _id: this.stringToObjectId(inviteId), + workspaceId: this.stringToObjectId(workspaceId), + }) + .lean(); + + return doc + ? this.toInviteEntity(doc as WorkspaceInviteDoc & { _id: Types.ObjectId }) + : null; + } + + // Atomically claim a resend slot: advance lastSentAt only if the cooldown has + // elapsed, in a single conditional findOneAndUpdate so two concurrent resends + // cannot both pass the window (TOCTOU). Scoped to the workspace so an invite + // id from another workspace never matches. Returns the updated invite when the + // slot is acquired, or null when the cooldown is still active (or no such + // invite). The cooldown boundary is computed against the app clock here, after + // the existence/authz check the service runs first. + async claimInviteForResend( + inviteId: string, + workspaceId: string, + cooldownMs: number, + ): Promise { + if (!Types.ObjectId.isValid(inviteId)) { + return null; + } + + const doc = await this.inviteModel + .findOneAndUpdate( + { + _id: this.stringToObjectId(inviteId), + workspaceId: this.stringToObjectId(workspaceId), + lastSentAt: { $lt: new Date(Date.now() - cooldownMs) }, + }, + { $set: { lastSentAt: new Date() } }, + { new: true }, + ) + .lean(); + + return doc + ? this.toInviteEntity(doc as WorkspaceInviteDoc & { _id: Types.ObjectId }) + : null; + } + + // Delete a pending invitation by id. When workspaceId is given the delete is + // scoped to that workspace (revoke path — no cross-workspace deletion by + // guessing ids); without it, deletes by id alone (decline path, where the + // caller is acting on their own invitation resolved by id). + async deleteInvite(inviteId: string, workspaceId?: string): Promise { + if (!Types.ObjectId.isValid(inviteId)) { + return false; + } + + const filter: Record = { + _id: this.stringToObjectId(inviteId), + }; + + if (workspaceId) { + filter.workspaceId = this.stringToObjectId(workspaceId); + } + + const result = await this.inviteModel.deleteOne(filter); + + return result.deletedCount > 0; + } + + async countInvitesForWorkspace(workspaceId: string): Promise { + return this.inviteModel.countDocuments({ + workspaceId: this.stringToObjectId(workspaceId), + }); + } +} diff --git a/services/backend-api/src/services/notifications/notifications.service.ts b/services/backend-api/src/services/notifications/notifications.service.ts index 232781abe..0d2df9cbc 100644 --- a/services/backend-api/src/services/notifications/notifications.service.ts +++ b/services/backend-api/src/services/notifications/notifications.service.ts @@ -1,6 +1,7 @@ import Handlebars from "handlebars"; import type { Config } from "../../config"; import type { SmtpTransport } from "../../infra/smtp"; +import { createFromFormatter, type FormatFrom } from "../../infra/email-from"; import type { INotificationDeliveryAttemptRepository } from "../../repositories/interfaces/notification-delivery-attempt.types"; import type { IUserFeed, @@ -41,13 +42,14 @@ export interface NotificationsServiceDeps { } export class NotificationsService { - private emailAlertFrom: string; + // Formats lazily at send time: the "from" address requires SMTP config that + // an SMTP-less deployment won't have, and resolving it in the constructor + // would crash app startup rather than only the (optional) email send. + private formatFrom: FormatFrom; private loginRedirectUrl: string; constructor(private readonly deps: NotificationsServiceDeps) { - this.emailAlertFrom = - deps.config.BACKEND_API_SMTP_FROM || - '"MonitoRSS Alerts" '; + this.formatFrom = createFromFormatter(deps.config); this.loginRedirectUrl = deps.config.BACKEND_API_LOGIN_REDIRECT_URI || "https://my.monitorss.xyz"; } @@ -108,7 +110,7 @@ export class NotificationsService { try { await this.deps.smtpTransport?.sendMail({ - from: this.emailAlertFrom, + from: this.formatFrom("MonitoRSS Alerts", "alerts"), to: emails, subject: `Feed has been disabled: ${feed.title}`, html: disabledFeedTemplate(templateData), @@ -175,7 +177,7 @@ export class NotificationsService { try { await this.deps.smtpTransport?.sendMail({ - from: this.emailAlertFrom, + from: this.formatFrom("MonitoRSS Alerts", "alerts"), to: emails, subject: `Feed connection has been disabled: ${connection.name} (feed: ${feed.title})`, html: disabledFeedTemplate(templateData), diff --git a/services/backend-api/src/services/supporters/supporters.service.ts b/services/backend-api/src/services/supporters/supporters.service.ts index 0fd617bea..ec0957ffa 100644 --- a/services/backend-api/src/services/supporters/supporters.service.ts +++ b/services/backend-api/src/services/supporters/supporters.service.ts @@ -40,6 +40,7 @@ export class SupportersService { readonly defaultRefreshRateSeconds: number; readonly defaultSupporterRefreshRateSeconds = 120; readonly defaultMaxUserFeeds: number; + readonly defaultMaxWorkspaceFeeds: number; readonly defaultMaxSupporterUserFeeds: number; readonly maxDailyArticlesSupporter: number; readonly maxDailyArticlesDefault: number; @@ -60,6 +61,9 @@ export class SupportersService { this.defaultMaxUserFeeds = Number( config.BACKEND_API_DEFAULT_MAX_USER_FEEDS, ); + this.defaultMaxWorkspaceFeeds = Number( + config.BACKEND_API_DEFAULT_MAX_WORKSPACE_FEEDS, + ); this.defaultMaxSupporterUserFeeds = Number( config.BACKEND_API_DEFAULT_MAX_SUPPORTER_USER_FEEDS, ); @@ -444,6 +448,33 @@ export class SupportersService { }; } + /** + * Feed-related benefits for a WORKSPACE. Today the feed limit is the + * hardcoded `BACKEND_API_DEFAULT_MAX_WORKSPACE_FEEDS` and the article/refresh + * limits use the default tier — workspace feeds are deliberately insulated + * from the creator's personal supporter perks. + * + * Forward-compat: when a workspace-level Paddle subscription exists, resolve + * it here (look up the workspace's subscription benefits) and return those + * instead. The return shape mirrors the fields `addFeed` consumes from + * `getBenefitsOfDiscordUser`, so the swap is local to this method. + */ + async getWorkspaceBenefits(_workspaceId: string): Promise<{ + maxFeeds: number; + maxDailyArticles: number; + refreshRateSeconds: number; + allowWebhooks: boolean; + }> { + // TODO: workspace Paddle subscription lookup slots in here (keyed on + // _workspaceId). + return { + maxFeeds: this.defaultMaxWorkspaceFeeds, + maxDailyArticles: this.maxDailyArticlesDefault, + refreshRateSeconds: this.defaultRefreshRateSeconds, + allowWebhooks: false, + }; + } + async setGuilds( userId: string, guildIds: string[], diff --git a/services/backend-api/src/services/user-feed-management-invites/user-feed-management-invites.service.ts b/services/backend-api/src/services/user-feed-management-invites/user-feed-management-invites.service.ts index d7ac9a9d2..027e7e0e9 100644 --- a/services/backend-api/src/services/user-feed-management-invites/user-feed-management-invites.service.ts +++ b/services/backend-api/src/services/user-feed-management-invites/user-feed-management-invites.service.ts @@ -9,6 +9,7 @@ import { UserManagerAlreadyInvitedException, UserFeedTransferRequestExistsException, InviteNotFoundException, + WorkspaceFeedSharingDisabledException, } from "../../shared/exceptions/user-feed-management-invites.exceptions"; import type { UserFeedManagementInvitesServiceDeps, @@ -23,6 +24,12 @@ export class UserFeedManagementInvitesService { async createInvite(input: CreateInviteInput): Promise { const { feed, targetDiscordUserId, type, connections } = input; + if (feed.workspaceId) { + throw new WorkspaceFeedSharingDisabledException( + "Per-user feed management invites are disabled for workspace feeds", + ); + } + const existingInvites = feed.shareManageOptions?.invites ?? []; if (existingInvites.find((u) => u.discordUserId === targetDiscordUserId)) { diff --git a/services/backend-api/src/services/user-feeds/types.ts b/services/backend-api/src/services/user-feeds/types.ts index 0fe76d022..dc2b9e132 100644 --- a/services/backend-api/src/services/user-feeds/types.ts +++ b/services/backend-api/src/services/user-feeds/types.ts @@ -21,6 +21,7 @@ import type { FeedFetcherService } from "../feed-fetcher"; import type { FeedHandlerService } from "../feed-handler/feed-handler.service"; import type { SupportersService } from "../supporters/supporters.service"; import type { UsersService } from "../users/users.service"; +import type { WorkspacesService } from "../../features/workspaces/workspaces.service"; import type { IDiscordChannelConnection } from "../../repositories/interfaces/feed-connection.types"; import type { @@ -60,6 +61,7 @@ export interface UserFeedsServiceDeps { userRepository: IUserRepository; feedsService: FeedsService; supportersService: SupportersService; + workspacesService: WorkspacesService; feedFetcherApiService: FeedFetcherApiService; feedFetcherService: FeedFetcherService; feedHandlerService: FeedHandlerService; @@ -119,6 +121,9 @@ export interface GetUserFeedsInput { search?: string; sort?: GetUserFeedsInputSortKey; filters?: GetUserFeedsInputFilters; + // When set, lists this workspace's feeds instead of the user's personal feeds. + // Caller verifies membership before passing it. + workspaceId?: string; } export interface UserFeedListItem { @@ -140,6 +145,9 @@ export interface CreateUserFeedInput { title?: string; url: string; sourceFeedId?: string; + // When set, the feed is created under this workspace. Membership is verified in + // addFeed. + workspaceId?: string; } export interface ValidateFeedUrlOutput { diff --git a/services/backend-api/src/services/user-feeds/user-feeds.service.ts b/services/backend-api/src/services/user-feeds/user-feeds.service.ts index bd385d5fe..cc9a5c33c 100644 --- a/services/backend-api/src/services/user-feeds/user-feeds.service.ts +++ b/services/backend-api/src/services/user-feeds/user-feeds.service.ts @@ -48,7 +48,11 @@ import { UserFeedCopyableSetting } from "./types"; import type { CopySettingsTarget, CopyableSettings, + CloneUserFeedInput, + FeedLimitScope, + CreateUserFeedInput as CreateUserFeedRepoInput, } from "../../repositories/interfaces/user-feed.types"; +import { FeedLimitExceededError } from "../../repositories/interfaces/user-feed.types"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; import timezone from "dayjs/plugin/timezone"; @@ -105,6 +109,7 @@ export class UserFeedsService { ): Promise { return this.deps.userFeedRepository.getUserFeedsListing({ discordUserId, + workspaceId: input.workspaceId, limit: input.limit, offset: input.offset, search: input.search, @@ -120,6 +125,7 @@ export class UserFeedsService { ): Promise { return this.deps.userFeedRepository.getUserFeedsCount({ discordUserId, + workspaceId: input.workspaceId, search: input.search, filters: input.filters, }); @@ -128,34 +134,111 @@ export class UserFeedsService { async getFeedsWithoutConnectionsCount( _userId: string, discordUserId: string, + workspaceId?: string, ): Promise { return this.deps.userFeedRepository.getUserFeedsCount({ discordUserId, + workspaceId, filters: { hasConnections: false }, }); } + private feedLimitScope({ + workspaceId, + ownerDiscordUserId, + maxFeeds, + }: { + workspaceId: string | null; + ownerDiscordUserId: string; + maxFeeds: number; + }): FeedLimitScope { + return workspaceId + ? { scope: "workspace", workspaceId, maxFeeds } + : { scope: "personal", discordUserId: ownerDiscordUserId, maxFeeds }; + } + + // Translates the repository's limit sentinel into the exception callers handle. + private async createWithFeedLimit( + input: CreateUserFeedRepoInput, + scope: FeedLimitScope, + ): Promise { + try { + return await this.deps.userFeedRepository.createWithLimitEnforcement( + input, + scope, + ); + } catch (err) { + if (err instanceof FeedLimitExceededError) { + throw new FeedLimitReachedException("Max feeds reached"); + } + + throw err; + } + } + + private async cloneWithFeedLimit( + input: CloneUserFeedInput, + scope: FeedLimitScope, + ): Promise { + try { + return await this.deps.userFeedRepository.cloneWithLimitEnforcement( + input, + scope, + ); + } catch (err) { + if (err instanceof FeedLimitExceededError) { + throw new FeedLimitReachedException("Max feeds reached"); + } + + throw err; + } + } + async addFeed( { discordUserId, userAccessToken, }: { discordUserId: string; userAccessToken: string }, - { title, url, sourceFeedId }: CreateUserFeedInput, + { title, url, sourceFeedId, workspaceId }: CreateUserFeedInput, ): Promise { - const [ - { maxUserFeeds, maxDailyArticles, refreshRateSeconds }, - user, - sourceFeedToCopyFrom, - ] = await Promise.all([ - this.deps.supportersService.getBenefitsOfDiscordUser(discordUserId), - this.deps.usersService.getOrCreateUserByDiscordId(discordUserId), - sourceFeedId - ? this.deps.userFeedRepository.findByIdAndOwnership( - sourceFeedId, - discordUserId, - ) - : null, - ]); + const user = + await this.deps.usersService.getOrCreateUserByDiscordId(discordUserId); + const userId = user.id; + + // Workspace feeds draw their limits from workspace benefits (hardcoded today, a + // future workspace Paddle subscription later) — never the creator's personal + // supporter perks. Membership is verified before creation. + if (workspaceId) { + await this.deps.workspacesService.getWorkspaceForMember(workspaceId, userId); + } + + const myWorkspaceIds = workspaceId ? [workspaceId] : []; + + const [{ maxFeeds, maxDailyArticles, refreshRateSeconds }, sourceFeedToCopyFrom] = + await Promise.all([ + workspaceId + ? this.deps.supportersService + .getWorkspaceBenefits(workspaceId) + .then((b) => ({ + maxFeeds: b.maxFeeds, + maxDailyArticles: b.maxDailyArticles, + refreshRateSeconds: b.refreshRateSeconds, + })) + : this.deps.supportersService + .getBenefitsOfDiscordUser(discordUserId) + .then((b) => ({ + maxFeeds: b.maxUserFeeds, + maxDailyArticles: b.maxDailyArticles, + refreshRateSeconds: b.refreshRateSeconds, + })), + sourceFeedId + ? this.deps.userFeedRepository.findByIdAndOwnership( + sourceFeedId, + discordUserId, + myWorkspaceIds, + ) + : null, + ]); if (sourceFeedId && !sourceFeedToCopyFrom) { throw new SourceFeedNotFoundException( @@ -163,8 +246,6 @@ export class UserFeedsService { ); } - const userId = user.id; - const tempLookupDetails = getFeedRequestLookupDetails({ decryptionKey: this.deps.config.BACKEND_API_ENCRYPTION_KEY_HEX, feed: { @@ -177,34 +258,37 @@ export class UserFeedsService { const { finalUrl, enableDateChecks, feedTitle } = await this.checkUrlIsValid(url, tempLookupDetails); - const { connections, ...propertiesToCopy } = sourceFeedToCopyFrom || {}; + const { + connections, + workspaceId: _sourceWorkspaceId, + ...propertiesToCopy + } = sourceFeedToCopyFrom || {}; - const created = await this.deps.userFeedRepository.create({ - ...propertiesToCopy, - title: title || feedTitle || "Untitled Feed", - url: finalUrl, - inputUrl: url, - user: { - id: userId, - discordUserId, + const created = await this.createWithFeedLimit( + { + ...propertiesToCopy, + title: title || feedTitle || "Untitled Feed", + url: finalUrl, + inputUrl: url, + user: { + id: userId, + discordUserId, + }, + workspaceId, + refreshRateSeconds, + slotOffsetMs: calculateSlotOffsetMs(finalUrl, refreshRateSeconds), + maxDailyArticles, + feedRequestLookupKey: tempLookupDetails?.key, + dateCheckOptions: enableDateChecks + ? { oldArticleDateDiffMsThreshold: 1000 * 60 * 60 * 24 } + : undefined, }, - refreshRateSeconds, - slotOffsetMs: calculateSlotOffsetMs(finalUrl, refreshRateSeconds), - maxDailyArticles, - feedRequestLookupKey: tempLookupDetails?.key, - dateCheckOptions: enableDateChecks - ? { oldArticleDateDiffMsThreshold: 1000 * 60 * 60 * 24 } - : undefined, - }); - - const feedCount = - await this.deps.userFeedRepository.countByOwnership(discordUserId); - - if (feedCount > maxUserFeeds) { - await this.deps.userFeedRepository.deleteById(created.id); - - throw new FeedLimitReachedException("Max feeds reached"); - } + this.feedLimitScope({ + workspaceId: workspaceId ?? null, + ownerDiscordUserId: discordUserId, + maxFeeds, + }), + ); if (connections) { for (const c of connections.discordChannels) { @@ -239,10 +323,16 @@ export class UserFeedsService { throw new Error(`Feed ${feedId} not found while cloning`); } - const { maxUserFeeds } = - await this.deps.supportersService.getBenefitsOfDiscordUser( - sourceFeed.user.discordUserId, - ); + // Clone lands in the source's scope (the repo carries `workspaceId` over), so + // the limit is the workspace's for a workspace feed, else the creator's. + const maxFeeds = sourceFeed.workspaceId + ? (await this.deps.supportersService.getWorkspaceBenefits(sourceFeed.workspaceId)) + .maxFeeds + : ( + await this.deps.supportersService.getBenefitsOfDiscordUser( + sourceFeed.user.discordUserId, + ) + ).maxUserFeeds; let inputUrl = sourceFeed.inputUrl; let finalUrl = sourceFeed.url; @@ -267,25 +357,22 @@ export class UserFeedsService { inputUrl = data.url; } - const created = await this.deps.userFeedRepository.clone({ - sourceFeed, - overrides: { - title: data?.title, - url: finalUrl, - inputUrl, + const created = await this.cloneWithFeedLimit( + { + sourceFeed, + overrides: { + title: data?.title, + url: finalUrl, + inputUrl, + }, }, - }); - - const feedCount = await this.deps.userFeedRepository.countByOwnership( - sourceFeed.user.discordUserId, + this.feedLimitScope({ + workspaceId: sourceFeed.workspaceId ?? null, + ownerDiscordUserId: sourceFeed.user.discordUserId, + maxFeeds, + }), ); - if (feedCount > maxUserFeeds) { - await this.deps.userFeedRepository.deleteById(created.id); - - throw new FeedLimitReachedException("Max feeds reached"); - } - await this.deps.usersService.syncLookupKeys({ feedIds: [created.id] }); for (const c of sourceFeed.connections.discordChannels) { diff --git a/services/backend-api/src/shared/constants/api-errors.ts b/services/backend-api/src/shared/constants/api-errors.ts index 1354e24a7..bf2c41beb 100644 --- a/services/backend-api/src/shared/constants/api-errors.ts +++ b/services/backend-api/src/shared/constants/api-errors.ts @@ -58,12 +58,36 @@ export enum ApiErrorCode { ADDRESS_LOCATION_NOT_ALLOWED = "ADDRESS_LOCATION_NOT_ALLOWED", SUBSCRIPTION_ALREADY_CANCELLED = "SUBSCRIPTION_ALREADY_CANCELLED", INVALID_REQUEST = "INVALID_REQUEST", + TOO_MANY_REQUESTS = "TOO_MANY_REQUESTS", SERVER_ID_REQUIRED = "SERVER_ID_REQUIRED", GUILD_ID_REQUIRED = "GUILD_ID_REQUIRED", INVALID_AUTH_STATE = "INVALID_AUTH_STATE", INVALID_AUTH_CODE = "INVALID_AUTH_CODE", INVALID_JSON_STATE = "INVALID_JSON_STATE", USER_NOT_FOUND = "USER_NOT_FOUND", + EMAIL_VERIFICATION_INVALID_CODE = "EMAIL_VERIFICATION_INVALID_CODE", + EMAIL_VERIFICATION_EXPIRED = "EMAIL_VERIFICATION_EXPIRED", + EMAIL_VERIFICATION_TOO_MANY_ATTEMPTS = "EMAIL_VERIFICATION_TOO_MANY_ATTEMPTS", + EMAIL_VERIFICATION_RESEND_TOO_SOON = "EMAIL_VERIFICATION_RESEND_TOO_SOON", + EMAIL_VERIFICATION_TOO_MANY_TARGETS = "EMAIL_VERIFICATION_TOO_MANY_TARGETS", + EMAIL_VERIFICATION_UNAVAILABLE = "EMAIL_VERIFICATION_UNAVAILABLE", + EMAIL_ALREADY_IN_USE = "EMAIL_ALREADY_IN_USE", + EMAIL_NOT_VERIFIED = "EMAIL_NOT_VERIFIED", + WORKSPACE_NOT_FOUND = "WORKSPACE_NOT_FOUND", + WORKSPACE_INSUFFICIENT_ROLE = "WORKSPACE_INSUFFICIENT_ROLE", + WORKSPACE_SLUG_TAKEN = "WORKSPACE_SLUG_TAKEN", + WORKSPACE_SLUG_RESERVED = "WORKSPACE_SLUG_RESERVED", + WORKSPACE_MEMBER_ALREADY_EXISTS = "WORKSPACE_MEMBER_ALREADY_EXISTS", + WORKSPACE_ALREADY_INVITED = "WORKSPACE_ALREADY_INVITED", + WORKSPACE_INVITE_EMAIL_UNAVAILABLE = "WORKSPACE_INVITE_EMAIL_UNAVAILABLE", + WORKSPACE_INVITE_NOT_FOUND = "WORKSPACE_INVITE_NOT_FOUND", + WORKSPACE_INVITE_EMAIL_UNVERIFIED = "WORKSPACE_INVITE_EMAIL_UNVERIFIED", + WORKSPACE_INVITE_EMAIL_MISMATCH = "WORKSPACE_INVITE_EMAIL_MISMATCH", + WORKSPACE_INVITE_ALREADY_MEMBER = "WORKSPACE_INVITE_ALREADY_MEMBER", + WORKSPACE_INVITE_RESEND_TOO_SOON = "WORKSPACE_INVITE_RESEND_TOO_SOON", + WORKSPACE_INVITE_LIMIT_REACHED = "WORKSPACE_INVITE_LIMIT_REACHED", + CANNOT_REMOVE_LAST_OWNER = "CANNOT_REMOVE_LAST_OWNER", + WORKSPACE_FEED_SHARING_DISABLED = "WORKSPACE_FEED_SHARING_DISABLED", ROUTE_NOT_FOUND = "ROUTE_NOT_FOUND", } @@ -146,11 +170,49 @@ export const API_ERROR_MESSAGES: Record = { SUBSCRIPTION_ALREADY_CANCELLED: "This subscription has already been cancelled.", INVALID_REQUEST: "Invalid request", + TOO_MANY_REQUESTS: "Too many requests. Please wait a moment and try again.", SERVER_ID_REQUIRED: "Server ID is required", GUILD_ID_REQUIRED: "Guild ID is required", INVALID_AUTH_STATE: "Invalid state", INVALID_AUTH_CODE: "Invalid code", INVALID_JSON_STATE: "Invalid jsonState format", USER_NOT_FOUND: "User not found", + EMAIL_VERIFICATION_INVALID_CODE: "Invalid or incorrect verification code", + EMAIL_VERIFICATION_EXPIRED: "Verification code has expired", + EMAIL_VERIFICATION_TOO_MANY_ATTEMPTS: + "Too many incorrect attempts. Request a new code.", + EMAIL_VERIFICATION_RESEND_TOO_SOON: + "Please wait before requesting another code.", + EMAIL_VERIFICATION_TOO_MANY_TARGETS: + "Too many different email addresses have been used recently. Please wait before trying another address.", + EMAIL_VERIFICATION_UNAVAILABLE: "Email verification is currently unavailable", + EMAIL_ALREADY_IN_USE: "This email is already in use by another account", + EMAIL_NOT_VERIFIED: "A verified email is required to perform this action", + WORKSPACE_NOT_FOUND: "Workspace not found", + WORKSPACE_INSUFFICIENT_ROLE: + "You do not have permission to perform this action in this workspace", + WORKSPACE_SLUG_TAKEN: "This URL slug is already taken by another workspace", + WORKSPACE_SLUG_RESERVED: "This URL slug is reserved and cannot be used", + WORKSPACE_MEMBER_ALREADY_EXISTS: + "This email already belongs to a member of this workspace", + WORKSPACE_ALREADY_INVITED: + "This email already has a pending invitation to this workspace", + WORKSPACE_INVITE_EMAIL_UNAVAILABLE: + "Invitation email could not be sent because email delivery is currently unavailable", + WORKSPACE_INVITE_NOT_FOUND: "Invitation not found", + WORKSPACE_INVITE_EMAIL_UNVERIFIED: + "Verify the invited email address to act on this invitation", + WORKSPACE_INVITE_EMAIL_MISMATCH: + "This invitation was sent to a different email than your verified address", + WORKSPACE_INVITE_ALREADY_MEMBER: + "You are already a member of this workspace", + WORKSPACE_INVITE_RESEND_TOO_SOON: + "Please wait before resending this invitation.", + WORKSPACE_INVITE_LIMIT_REACHED: + "This workspace has reached its limit of pending invitations. Revoke a pending invitation before sending another.", + CANNOT_REMOVE_LAST_OWNER: + "A workspace must have at least one owner. Transfer ownership or delete the workspace instead.", + WORKSPACE_FEED_SHARING_DISABLED: + "Per-user feed management invites are disabled for workspace feeds. Manage access through workspace members instead.", ROUTE_NOT_FOUND: "Not Found", }; diff --git a/services/backend-api/src/shared/exceptions/user-feed-management-invites.exceptions.ts b/services/backend-api/src/shared/exceptions/user-feed-management-invites.exceptions.ts index f8de630a5..822fda8b3 100644 --- a/services/backend-api/src/shared/exceptions/user-feed-management-invites.exceptions.ts +++ b/services/backend-api/src/shared/exceptions/user-feed-management-invites.exceptions.ts @@ -4,6 +4,7 @@ export class UserManagerAlreadyInvitedException extends StandardException {} export class UserFeedTransferRequestExistsException extends StandardException {} export class InviteNotFoundException extends StandardException {} export class InvalidConnectionIdException extends StandardException {} +export class WorkspaceFeedSharingDisabledException extends StandardException {} // Backward compatibility alias for the typo in the original exception name export const UserFeedTransferRequestExiststException = diff --git a/services/backend-api/src/shared/utils/feed-access.ts b/services/backend-api/src/shared/utils/feed-access.ts new file mode 100644 index 000000000..406c35bad --- /dev/null +++ b/services/backend-api/src/shared/utils/feed-access.ts @@ -0,0 +1,125 @@ +import type { FastifyRequest } from "fastify"; +import type { IUserFeed } from "../../repositories/interfaces/user-feed.types"; +import type { IUser } from "../../repositories/interfaces/user.types"; +import { NotFoundError, ApiErrorCode } from "../../infra/error-handler"; + +/** + * Workspace-feed access helpers. + * + * A feed is personal (owned by `user`) XOR workspace-owned (`workspaceId` set). + * Any member of a feed's workspace may access it and take every action — roles + * do not restrict feed actions. These helpers centralize the workspace-id + * resolution and the "does this requester have full management access" check so + * the ~20 feed and connection handlers stay consistent. + */ + +/** + * The set of workspace ids the requester belongs to, used to authorize + * workspace-feed access via `findByIdAndOwnership`/`filterFeedIdsByOwnership`. + * Returns `[]` when the requester lacks the workspaces feature flag so no + * workspace query runs and the feature is fully inert for them. + */ +export async function getRequesterWorkspaceIds( + request: FastifyRequest, + user: IUser, +): Promise { + const { workspacesService } = request.container; + + if (!user.featureFlags?.workspaces) { + return []; + } + + return workspacesService.listWorkspaceIds(user.id); +} + +/** + * Whether the requester has full management access to a feed they already + * resolved via the ownership filter: the feed creator, an admin, or — for a + * workspace feed — any member (membership was already proven by the ownership + * lookup that returned the feed). Personal share-invite co-managers are NOT + * full-access; their per-connection scoping is handled by the caller. + */ +export function hasFullFeedAccess( + feed: IUserFeed, + discordUserId: string, + isAdmin: boolean, +): boolean { + return ( + isAdmin || !!feed.workspaceId || feed.user.discordUserId === discordUserId + ); +} + +export interface ResolvedFeedRequester { + feed: IUserFeed; + user: IUser; + isAdmin: boolean; + myWorkspaceIds: string[]; +} + +/** + * Resolves the feed a requester is acting on and the access context around it: + * validates the id, looks up the requesting user, and fetches the feed scoped + * to what the requester is allowed to see (everything for an admin, else feeds + * they own, co-manage, or share via workspace membership). Throws 404 when the id is + * malformed or the feed is not visible to the requester, so callers cannot + * distinguish "missing" from "not yours". The returned feed is unfiltered; + * per-connection invite scoping is the caller's responsibility. + */ +export async function resolveFeedForRequester( + request: FastifyRequest, + feedId: string, +): Promise { + const { userFeedRepository, usersService, config } = request.container; + const { discordUserId } = request; + + if (!userFeedRepository.areAllValidIds([feedId])) { + throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); + } + + const user = await usersService.getOrCreateUserByDiscordId(discordUserId); + const isAdmin = config.BACKEND_API_ADMIN_USER_IDS.includes(user.id); + const myWorkspaceIds = await getRequesterWorkspaceIds(request, user); + + const feed = isAdmin + ? await userFeedRepository.findById(feedId) + : await userFeedRepository.findByIdAndOwnership( + feedId, + discordUserId, + myWorkspaceIds, + ); + + if (!feed) { + throw new NotFoundError(ApiErrorCode.USER_FEED_NOT_FOUND); + } + + return { feed, user, isAdmin, myWorkspaceIds }; +} + +/** + * Whether the requester may act on a specific connection of a feed they already + * resolved. Full-access requesters (owner, admin, workspace member) may touch every + * connection. A personal share-invite co-manager is scoped to the connections + * named on their invite — unless the invite lists none, which grants access to + * all of them. + */ +export function canAccessConnection( + feed: IUserFeed, + discordUserId: string, + isAdmin: boolean, + connectionId: string, +): boolean { + if (hasFullFeedAccess(feed, discordUserId, isAdmin)) { + return true; + } + + const invite = feed.shareManageOptions?.invites.find( + (i) => i.discordUserId === discordUserId, + ); + const allowedConnectionIds = invite?.connections?.map((c) => c.connectionId); + + return ( + !allowedConnectionIds || + allowedConnectionIds.length === 0 || + allowedConnectionIds.includes(connectionId) + ); +} diff --git a/services/backend-api/src/shared/utils/normalizeEmail.ts b/services/backend-api/src/shared/utils/normalizeEmail.ts new file mode 100644 index 000000000..58d536a8e --- /dev/null +++ b/services/backend-api/src/shared/utils/normalizeEmail.ts @@ -0,0 +1,6 @@ +// Canonical email normalization used wherever an email is compared or stored: +// an invitation's email and a user's verified email must compare equal +// byte-for-byte. The mongoose `lowercase: true` index option backstops storage. +export function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} diff --git a/services/backend-api/src/shared/utils/redactEmail.ts b/services/backend-api/src/shared/utils/redactEmail.ts new file mode 100644 index 000000000..4cb256e1f --- /dev/null +++ b/services/backend-api/src/shared/utils/redactEmail.ts @@ -0,0 +1,15 @@ +// Obfuscates an email to a human-meaningful hint (e.g. "a***@example.com") so +// the invitee can recognize which address to verify without a random prober +// being able to harvest the full address from a guessed invitation id. +export function redactEmail(email: string): string { + const atIndex = email.lastIndexOf("@"); + + if (atIndex <= 0) { + return "***"; + } + + const local = email.slice(0, atIndex); + const domain = email.slice(atIndex + 1); + + return `${local[0]}***@${domain}`; +} diff --git a/services/backend-api/src/shared/utils/slugify.ts b/services/backend-api/src/shared/utils/slugify.ts new file mode 100644 index 000000000..60bbc7ea0 --- /dev/null +++ b/services/backend-api/src/shared/utils/slugify.ts @@ -0,0 +1,39 @@ +export const SLUG_MAX = 50; +// Lowercase alphanumerics and single hyphens; no leading, trailing, or +// consecutive hyphens. Two-char minimum is allowed by the second branch. +export const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +// Slugs that would collide with existing or likely-future literal route +// segments under /workspaces (e.g. /workspaces/new). Reserving them keeps +// user-chosen slugs from shadowing app routes. +export const RESERVED_SLUGS: ReadonlySet = new Set([ + "new", + "create", + "edit", + "settings", + "admin", + "api", + "me", + "account", + "login", + "logout", + "workspaces", + "workspace", + "feeds", + "feed", + "null", + "undefined", +]); + +export function isReservedSlug(slug: string): boolean { + return RESERVED_SLUGS.has(slug); +} + +export function isValidSlug(slug: string): boolean { + return ( + SLUG_PATTERN.test(slug) && + slug.length >= 2 && + slug.length <= SLUG_MAX && + !RESERVED_SLUGS.has(slug) + ); +} diff --git a/services/backend-api/test/api/email-verification.test.ts b/services/backend-api/test/api/email-verification.test.ts new file mode 100644 index 000000000..a83ebcb89 --- /dev/null +++ b/services/backend-api/test/api/email-verification.test.ts @@ -0,0 +1,396 @@ +import { describe, it, before, after, beforeEach } from "node:test"; +import assert from "node:assert"; +import { randomUUID } from "node:crypto"; +import { Types } from "mongoose"; +import { + createAppTestContext, + type AppTestContext, +} from "../helpers/test-context"; +import { EmailVerificationService } from "../../src/features/users/email-verification.service"; +import type { SmtpTransport } from "../../src/infra/smtp"; + +async function readJson(res: Response): Promise { + return (await res.json()) as T; +} + +interface ErrorResult { + code: string; +} + +describe("Email verification API", () => { + let ctx: AppTestContext; + let sent: Array<{ to: string; code: string }>; + + before(async () => { + // A from-domain is required for the sender address (createFromFormatter); + // the fake transport below stands in for an actual SMTP server. + ctx = await createAppTestContext({ + configOverrides: { BACKEND_API_SMTP_FROM_DOMAIN: "example.com" }, + }); + }); + + after(async () => { + await ctx.teardown(); + }); + + // Swap in a capturing mailer so the real send→confirm endpoints can be + // exercised without an SMTP server (the harness leaves SMTP unconfigured). + beforeEach(() => { + sent = []; + const fakeTransport = { + sendMail: async (msg: { to: string; html: string }) => { + const match = /(\d{6})/.exec(String(msg.html)); + sent.push({ to: msg.to, code: match?.[1] ?? "" }); + return {}; + }, + } as unknown as SmtpTransport; + + ctx.container.emailVerificationService = new EmailVerificationService({ + config: ctx.container.config, + smtpTransport: fakeTransport, + emailVerificationRepository: ctx.container.emailVerificationRepository, + userRepository: ctx.container.userRepository, + }); + }); + + // Email verification is gated by the per-user workspaces feature flag, so every + // test user is seeded with it. + async function makeUser() { + const discordUserId = randomUUID(); + await ctx.container.userRepository.create({ discordUserId }); + await ctx.connection + .collection("users") + .updateOne({ discordUserId }, { $set: { "featureFlags.workspaces": true } }); + const user = await ctx.asUser(discordUserId); + const internalId = + await ctx.container.userRepository.findIdByDiscordId(discordUserId); + return { discordUserId, user, internalId: internalId as string }; + } + + it("sends a code and confirms it, setting verifiedEmail", async () => { + const { user, internalId } = await makeUser(); + const email = `${randomUUID()}@example.com`; + + const sendRes = await user.fetch("/api/v1/users/@me/email-verification", { + method: "POST", + body: JSON.stringify({ email }), + }); + assert.strictEqual(sendRes.status, 200); + assert.strictEqual(sent.length, 1); + + const captured = sent[0]; + assert.ok(captured); + assert.strictEqual(captured.to, email.toLowerCase()); + assert.match(captured.code, /^\d{6}$/); + + const confirmRes = await user.fetch( + "/api/v1/users/@me/email-verification/confirm", + { method: "POST", body: JSON.stringify({ email, code: captured.code }) }, + ); + assert.strictEqual(confirmRes.status, 200); + + const updated = await ctx.container.userRepository.findById(internalId); + assert.strictEqual(updated?.verifiedEmail, email.toLowerCase()); + }); + + it("rejects an incorrect code", async () => { + const { user } = await makeUser(); + const email = `${randomUUID()}@example.com`; + + await user.fetch("/api/v1/users/@me/email-verification", { + method: "POST", + body: JSON.stringify({ email }), + }); + + const res = await user.fetch( + "/api/v1/users/@me/email-verification/confirm", + { method: "POST", body: JSON.stringify({ email, code: "000000" }) }, + ); + assert.strictEqual(res.status, 400); + assert.strictEqual( + (await readJson(res)).code, + "EMAIL_VERIFICATION_INVALID_CODE", + ); + }); + + it("rejects an expired code", async () => { + const { user, internalId } = await makeUser(); + const email = `${randomUUID()}@example.com`; + + await ctx.connection.collection("emailverifications").insertOne({ + userId: new Types.ObjectId(internalId), + email: email.toLowerCase(), + codeHash: "deadbeef", + expiresAt: new Date(Date.now() - 1000), + attempts: 0, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const res = await user.fetch( + "/api/v1/users/@me/email-verification/confirm", + { method: "POST", body: JSON.stringify({ email, code: "123456" }) }, + ); + assert.strictEqual(res.status, 400); + assert.strictEqual( + (await readJson(res)).code, + "EMAIL_VERIFICATION_EXPIRED", + ); + }); + + it("rejects confirmation after too many attempts (429) and clears the code", async () => { + const { user, internalId } = await makeUser(); + const email = `${randomUUID()}@example.com`; + + await ctx.connection.collection("emailverifications").insertOne({ + userId: new Types.ObjectId(internalId), + email: email.toLowerCase(), + codeHash: "deadbeef", + expiresAt: new Date(Date.now() + 60_000), + attempts: 5, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const res = await user.fetch( + "/api/v1/users/@me/email-verification/confirm", + { method: "POST", body: JSON.stringify({ email, code: "123456" }) }, + ); + assert.strictEqual(res.status, 429); + assert.strictEqual( + (await readJson(res)).code, + "EMAIL_VERIFICATION_TOO_MANY_ATTEMPTS", + ); + + const remaining = await ctx.connection + .collection("emailverifications") + .countDocuments({ userId: new Types.ObjectId(internalId) }); + assert.strictEqual(remaining, 0); + }); + + it("blocks a new distinct target once the per-user cap is reached, but allows re-sending to an already-targeted address", async () => { + const { user } = await makeUser(); + // These tests intentionally exceed the per-IP send limit, so give this test + // its own source IP (trustProxy is on) to isolate it from the shared bucket. + const headers = { "x-forwarded-for": `203.0.113.${Math.floor(Math.random() * 250) + 1}` }; + + // Five distinct addresses succeed (the cap is 5 distinct targets / hour). + for (let i = 0; i < 5; i += 1) { + const email = `cap-${i}-${randomUUID()}@example.com`; + const res = await user.fetch("/api/v1/users/@me/email-verification", { + method: "POST", + headers, + body: JSON.stringify({ email }), + }); + assert.strictEqual(res.status, 200, `distinct target #${i + 1} should send`); + } + + // A sixth, brand-new distinct address is rejected by the distinct-target cap. + const sixth = `cap-6-${randomUUID()}@example.com`; + const blocked = await user.fetch("/api/v1/users/@me/email-verification", { + method: "POST", + headers, + body: JSON.stringify({ email: sixth }), + }); + assert.strictEqual(blocked.status, 429); + assert.strictEqual( + (await readJson(blocked)).code, + "EMAIL_VERIFICATION_TOO_MANY_TARGETS", + ); + + // No code was dispatched for the blocked sixth address. + assert.ok(!sent.some((s) => s.to === sixth.toLowerCase())); + }); + + it("does not count re-sends to an already-targeted address against the cap", async () => { + const { user } = await makeUser(); + const headers = { "x-forwarded-for": `203.0.114.${Math.floor(Math.random() * 250) + 1}` }; + + // Reach the cap with five distinct targets. + const firstEmail = `repeat-${randomUUID()}@example.com`; + const firstRes = await user.fetch("/api/v1/users/@me/email-verification", { + method: "POST", + headers, + body: JSON.stringify({ email: firstEmail }), + }); + assert.strictEqual(firstRes.status, 200); + + for (let i = 1; i < 5; i += 1) { + const email = `repeat-${i}-${randomUUID()}@example.com`; + const res = await user.fetch("/api/v1/users/@me/email-verification", { + method: "POST", + headers, + body: JSON.stringify({ email }), + }); + assert.strictEqual(res.status, 200); + } + + // Re-sending to the first (already-targeted) address is NOT blocked by the + // distinct-target cap — only the 60s cooldown gates it. Clear the active + // code so the cooldown does not interfere with this assertion. + await ctx.connection + .collection("emailverifications") + .deleteMany({ email: firstEmail.toLowerCase() }); + + const resend = await user.fetch("/api/v1/users/@me/email-verification", { + method: "POST", + headers, + body: JSON.stringify({ email: firstEmail }), + }); + assert.strictEqual(resend.status, 200); + }); + + it("returns a standardized 429 (not a 500) when the per-IP send limit is exceeded", async () => { + const { user } = await makeUser(); + // The per-route IP limit is 10/hour; pin a dedicated source IP so this test + // owns its own bucket. Distinct emails so each request reaches the limiter. + const ip = `203.0.115.${Math.floor(Math.random() * 250) + 1}`; + const headers = { "x-forwarded-for": ip }; + + let lastStatus = 0; + let lastBody: ErrorResult = { code: "" }; + // 11 requests: the 11th must be rejected by the rate-limit plugin (before the + // handler). Earlier requests may be 200 or handler-level 429s — we only care + // that the plugin-level rejection is a clean 429, not a masked 500. + for (let i = 0; i < 11; i += 1) { + const res = await user.fetch("/api/v1/users/@me/email-verification", { + method: "POST", + headers, + body: JSON.stringify({ email: `ip-${i}-${randomUUID()}@example.com` }), + }); + lastStatus = res.status; + lastBody = await readJson(res).catch(() => ({ code: "" })); + } + + assert.strictEqual(lastStatus, 429, "IP-limit rejection must be 429, not 500"); + assert.strictEqual(lastBody.code, "TOO_MANY_REQUESTS"); + }); + + it("rejects a too-soon resend with 429", async () => { + const { user } = await makeUser(); + const email = `${randomUUID()}@example.com`; + + const first = await user.fetch("/api/v1/users/@me/email-verification", { + method: "POST", + body: JSON.stringify({ email }), + }); + assert.strictEqual(first.status, 200); + + const second = await user.fetch("/api/v1/users/@me/email-verification", { + method: "POST", + body: JSON.stringify({ email }), + }); + assert.strictEqual(second.status, 429); + assert.strictEqual( + (await readJson(second)).code, + "EMAIL_VERIFICATION_RESEND_TOO_SOON", + ); + }); + + it("preserves plus-addressing and dots in email normalization", async () => { + const { user, internalId } = await makeUser(); + const rawEmail = "Foo.Bar+tag@Example.com"; + const expectedNormalized = "foo.bar+tag@example.com"; + + const sendRes = await user.fetch("/api/v1/users/@me/email-verification", { + method: "POST", + body: JSON.stringify({ email: rawEmail }), + }); + assert.strictEqual(sendRes.status, 200); + assert.strictEqual(sent.length, 1); + + const captured = sent[0]; + assert.ok(captured); + // Mailer must receive the normalized address (lowercased, dots/plus intact) + assert.strictEqual(captured.to, expectedNormalized); + + const confirmRes = await user.fetch( + "/api/v1/users/@me/email-verification/confirm", + { + method: "POST", + body: JSON.stringify({ email: rawEmail, code: captured.code }), + }, + ); + assert.strictEqual(confirmRes.status, 200); + + const updated = await ctx.container.userRepository.findById(internalId); + // Stored verifiedEmail must be lowercased but keep plus-tag and dots intact + assert.strictEqual(updated?.verifiedEmail, expectedNormalized); + }); + + it("stores codeHash as an HMAC hex digest, not the plaintext code", async () => { + const { user, internalId } = await makeUser(); + const email = `${randomUUID()}@example.com`; + + const sendRes = await user.fetch("/api/v1/users/@me/email-verification", { + method: "POST", + body: JSON.stringify({ email }), + }); + assert.strictEqual(sendRes.status, 200); + + const captured = sent[0]; + assert.ok(captured); + assert.match(captured.code, /^\d{6}$/); + + const record = await ctx.connection + .collection("emailverifications") + .findOne({ userId: new Types.ObjectId(internalId) }); + assert.ok(record, "emailverification record must exist after send"); + + // The stored hash must NOT equal the plaintext 6-digit code + assert.notStrictEqual( + record["codeHash"], + captured.code, + "codeHash must not be the plaintext code", + ); + + // The stored hash must look like a SHA-256 HMAC hex string (64 hex chars) + assert.match( + String(record["codeHash"]), + /^[0-9a-f]{64}$/, + "codeHash must be a 64-char lowercase hex string", + ); + }); + + it("rejects confirming an email already verified by another user (409)", async () => { + await ctx.connection + .collection("users") + .createIndex({ verifiedEmail: 1 }, { unique: true, sparse: true }); + + const taken = `${randomUUID()}@example.com`.toLowerCase(); + const otherDiscordId = randomUUID(); + await ctx.container.userRepository.create({ + discordUserId: otherDiscordId, + }); + const otherId = + await ctx.container.userRepository.findIdByDiscordId(otherDiscordId); + await ctx.connection + .collection("users") + .updateOne( + { _id: new Types.ObjectId(otherId as string) }, + { $set: { verifiedEmail: taken, verifiedEmailVerifiedAt: new Date() } }, + ); + + const { user } = await makeUser(); + const sendRes = await user.fetch("/api/v1/users/@me/email-verification", { + method: "POST", + body: JSON.stringify({ email: taken }), + }); + assert.strictEqual(sendRes.status, 200); + const captured = sent[0]; + assert.ok(captured); + + const res = await user.fetch( + "/api/v1/users/@me/email-verification/confirm", + { + method: "POST", + body: JSON.stringify({ email: taken, code: captured.code }), + }, + ); + assert.strictEqual(res.status, 409); + assert.strictEqual( + (await readJson(res)).code, + "EMAIL_ALREADY_IN_USE", + ); + }); +}); diff --git a/services/backend-api/test/api/oauth-verified-email-invariant.test.ts b/services/backend-api/test/api/oauth-verified-email-invariant.test.ts new file mode 100644 index 000000000..29a9a3689 --- /dev/null +++ b/services/backend-api/test/api/oauth-verified-email-invariant.test.ts @@ -0,0 +1,136 @@ +import { describe, it, before, after, beforeEach } from "node:test"; +import assert from "node:assert"; +import { randomUUID } from "node:crypto"; +import { + createAppTestContext, + type AppTestContext, +} from "../helpers/test-context"; +import { EmailVerificationService } from "../../src/features/users/email-verification.service"; +import type { SmtpTransport } from "../../src/infra/smtp"; + +/** + * Regression test for the core security invariant: + * + * verifiedEmail is written ONLY by EmailVerificationService.confirm (the + * one-time-code flow). The Discord OAuth sign-in path (initDiscordUser) must + * NEVER write verifiedEmail, regardless of what Discord says the user's email + * is. + * + * This defeats the Discord-email-spoofing attack: an attacker can set their + * Discord account's email to a victim's address, but they cannot receive a + * one-time code delivered to that inbox. + */ +describe("OAuth verified-email security invariant", () => { + let ctx: AppTestContext; + let capturedCodes: Array<{ to: string; code: string }>; + + before(async () => { + ctx = await createAppTestContext({ + configOverrides: { BACKEND_API_SMTP_FROM_DOMAIN: "example.com" }, + }); + }); + + after(async () => { + await ctx.teardown(); + }); + + beforeEach(() => { + capturedCodes = []; + const fakeTransport = { + sendMail: async (msg: { to: string; html: string }) => { + const match = /(\d{6})/.exec(String(msg.html)); + capturedCodes.push({ to: msg.to, code: match?.[1] ?? "" }); + return {}; + }, + } as unknown as SmtpTransport; + + ctx.container.emailVerificationService = new EmailVerificationService({ + config: ctx.container.config, + smtpTransport: fakeTransport, + emailVerificationRepository: ctx.container.emailVerificationRepository, + userRepository: ctx.container.userRepository, + }); + }); + + it("create path: new user via initDiscordUser has verifiedEmail === undefined", async () => { + const discordId = randomUUID(); + const discordEmail = `discord-${discordId}@discord.example`; + + const created = await ctx.container.usersService.initDiscordUser(discordId, { + email: discordEmail, + }); + + const persisted = await ctx.container.userRepository.findById(created.id); + assert.ok(persisted, "user should be persisted"); + assert.strictEqual( + persisted.email, + discordEmail, + "Discord-provided email should be stored on email field", + ); + assert.strictEqual( + persisted.verifiedEmail, + undefined, + "verifiedEmail must not be set by the Discord OAuth path (create)", + ); + }); + + it("update-email path: existing user whose Discord email changes via initDiscordUser retains verifiedEmail === undefined", async () => { + const discordId = randomUUID(); + const originalEmail = `original-${discordId}@discord.example`; + const updatedEmail = `updated-${discordId}@discord.example`; + + await ctx.container.usersService.initDiscordUser(discordId, { + email: originalEmail, + }); + + const afterCreate = await ctx.container.userRepository.findByDiscordId(discordId); + assert.ok(afterCreate, "user should exist after initial create"); + assert.strictEqual(afterCreate.verifiedEmail, undefined); + + await ctx.container.usersService.initDiscordUser(discordId, { + email: updatedEmail, + }); + + const afterUpdate = await ctx.container.userRepository.findByDiscordId(discordId); + assert.ok(afterUpdate, "user should exist after email update"); + assert.strictEqual( + afterUpdate.email, + updatedEmail, + "Discord-provided email should be updated on email field", + ); + assert.strictEqual( + afterUpdate.verifiedEmail, + undefined, + "verifiedEmail must not be set by the Discord OAuth path (update-email)", + ); + }); + + it("contrast: EmailVerificationService.confirm IS the sole writer of verifiedEmail", async () => { + const discordId = randomUUID(); + const verifyEmail = `verify-${discordId}@example.com`; + + await ctx.container.userRepository.create({ discordUserId: discordId }); + await ctx.connection + .collection("users") + .updateOne({ discordUserId: discordId }, { $set: { "featureFlags.workspaces": true } }); + + const userId = await ctx.container.userRepository.findIdByDiscordId(discordId); + assert.ok(userId); + + const beforeVerify = await ctx.container.userRepository.findById(userId); + assert.strictEqual(beforeVerify?.verifiedEmail, undefined, "starts unverified"); + + await ctx.container.emailVerificationService.sendCode(userId, verifyEmail); + assert.ok(capturedCodes.length === 1, "one code should be captured"); + const { code } = capturedCodes[0]!; + + await ctx.container.emailVerificationService.confirm(userId, verifyEmail, code); + + const afterVerify = await ctx.container.userRepository.findById(userId); + assert.strictEqual( + afterVerify?.verifiedEmail, + verifyEmail.toLowerCase(), + "verifiedEmail is set ONLY after EmailVerificationService.confirm succeeds", + ); + }); +}); diff --git a/services/backend-api/test/api/user-feeds/workspace-feeds.test.ts b/services/backend-api/test/api/user-feeds/workspace-feeds.test.ts new file mode 100644 index 000000000..2c358f38d --- /dev/null +++ b/services/backend-api/test/api/user-feeds/workspace-feeds.test.ts @@ -0,0 +1,362 @@ +import { describe, it, before, after, beforeEach } from "node:test"; +import assert from "node:assert"; +import { randomUUID } from "node:crypto"; +import { Types } from "mongoose"; +import { + createAppTestContext, + type AppTestContext, +} from "../../helpers/test-context"; +import { generateSnowflake, generateTestId } from "../../helpers/test-id"; +import { + createTestHttpServer, + type TestHttpServer, +} from "../../helpers/test-http-server"; + +// Workspace-feed association: feeds carry an optional workspaceId, any workspace member can +// see/manage them, they count against the workspace limit, and personal/workspace scopes +// are strictly separated. + +let ctx: AppTestContext; +let feedApiMockServer: TestHttpServer; + +before(async () => { + feedApiMockServer = createTestHttpServer(); + ctx = await createAppTestContext({ + configOverrides: { + BACKEND_API_USER_FEEDS_API_HOST: feedApiMockServer.host, + BACKEND_API_FEED_REQUESTS_API_HOST: feedApiMockServer.host, + }, + }); +}); + +after(async () => { + await ctx.teardown(); + await feedApiMockServer.stop(); +}); + +beforeEach(() => { + feedApiMockServer.clear(); +}); + +function mockFeedFetch(feedUrl: string) { + feedApiMockServer.registerRoute("POST", "/v1/user-feeds/get-articles", { + status: 200, + body: { + result: { + requestStatus: "SUCCESS", + articles: [], + totalArticles: 0, + selectedProperties: [], + url: feedUrl, + feedTitle: "Workspace Feed", + }, + }, + }); +} + +async function seedWorkspaceUser(discordUserId: string): Promise { + await ctx.container.userRepository.create({ + discordUserId, + email: `${discordUserId}@example.com`, + }); + await ctx.connection.collection("users").updateOne( + { discordUserId }, + { + $set: { + "featureFlags.workspaces": true, + verifiedEmail: `verified-${discordUserId}@example.com`, + verifiedEmailVerifiedAt: new Date(), + }, + }, + ); + const id = await ctx.container.userRepository.findIdByDiscordId(discordUserId); + return id as string; +} + +async function createWorkspace( + user: Awaited>, + slug: string, +): Promise { + const res = await user.fetch("/api/v1/workspaces", { + method: "POST", + body: JSON.stringify({ name: "Workspace", slug }), + }); + assert.strictEqual(res.status, 201); + const body = (await res.json()) as { result: { id: string } }; + return body.result.id; +} + +async function addMembership(workspaceId: string, userId: string, role = "admin") { + await ctx.connection.collection("workspacememberships").insertOne({ + workspaceId: new Types.ObjectId(workspaceId), + userId: new Types.ObjectId(userId), + role, + createdAt: new Date(), + updatedAt: new Date(), + }); +} + +describe("Workspace-associated user feeds", { concurrency: false }, () => { + it("creates a workspace feed visible in workspace scope but not personal scope", async () => { + const discordId = randomUUID(); + await seedWorkspaceUser(discordId); + const user = await ctx.asUser(discordId); + const workspaceId = await createWorkspace(user, `workspace-${discordId.slice(0, 8)}`); + + const feedUrl = "https://example.com/workspace-feed.xml"; + mockFeedFetch(feedUrl); + + const createRes = await user.fetch("/api/v1/user-feeds", { + method: "POST", + body: JSON.stringify({ url: feedUrl, workspaceId }), + }); + assert.strictEqual(createRes.status, 201); + + // Workspace scope lists the feed. + const workspaceList = await user.fetch( + `/api/v1/user-feeds?limit=10&offset=0&workspaceId=${workspaceId}`, + ); + assert.strictEqual(workspaceList.status, 200); + const workspaceBody = (await workspaceList.json()) as { total: number }; + assert.strictEqual(workspaceBody.total, 1); + + // Personal scope excludes the workspace feed. + const personalList = await user.fetch( + "/api/v1/user-feeds?limit=10&offset=0", + ); + assert.strictEqual(personalList.status, 200); + const personalBody = (await personalList.json()) as { total: number }; + assert.strictEqual(personalBody.total, 0); + }); + + it("returns 404 listing a workspace's feeds as a non-member", async () => { + const ownerId = randomUUID(); + await seedWorkspaceUser(ownerId); + const owner = await ctx.asUser(ownerId); + const workspaceId = await createWorkspace(owner, `workspace-${ownerId.slice(0, 8)}`); + + const outsider = await ctx.asUser(generateSnowflake()); + const res = await outsider.fetch( + `/api/v1/user-feeds?limit=10&offset=0&workspaceId=${workspaceId}`, + ); + assert.strictEqual(res.status, 404); + }); + + it("lets any workspace member view and delete a workspace feed (role-agnostic)", async () => { + const ownerId = randomUUID(); + const ownerUserId = await seedWorkspaceUser(ownerId); + const owner = await ctx.asUser(ownerId); + const workspaceId = await createWorkspace(owner, `workspace-${ownerId.slice(0, 8)}`); + + const feedUrl = "https://example.com/shared-workspace-feed.xml"; + mockFeedFetch(feedUrl); + const createRes = await owner.fetch("/api/v1/user-feeds", { + method: "POST", + body: JSON.stringify({ url: feedUrl, workspaceId }), + }); + const { result: created } = (await createRes.json()) as { + result: { id: string }; + }; + + // A second member (not the creator) joins the workspace. + const memberDiscordId = randomUUID(); + const memberUserId = await seedWorkspaceUser(memberDiscordId); + assert.notStrictEqual(memberUserId, ownerUserId); + await addMembership(workspaceId, memberUserId, "admin"); + const member = await ctx.asUser(memberDiscordId); + + // The member can read the feed. + const getRes = await member.fetch(`/api/v1/user-feeds/${created.id}`); + assert.strictEqual(getRes.status, 200); + + // The member can delete the feed even though they did not create it. + const deleteRes = await member.fetch(`/api/v1/user-feeds/${created.id}`, { + method: "DELETE", + }); + assert.strictEqual(deleteRes.status, 204); + + const afterList = await owner.fetch( + `/api/v1/user-feeds?limit=10&offset=0&workspaceId=${workspaceId}`, + ); + const afterBody = (await afterList.json()) as { total: number }; + assert.strictEqual(afterBody.total, 0); + }); + + it("does not let a non-member access a workspace feed by id", async () => { + const ownerId = randomUUID(); + await seedWorkspaceUser(ownerId); + const owner = await ctx.asUser(ownerId); + const workspaceId = await createWorkspace(owner, `workspace-${ownerId.slice(0, 8)}`); + + const feedUrl = "https://example.com/private-workspace-feed.xml"; + mockFeedFetch(feedUrl); + const createRes = await owner.fetch("/api/v1/user-feeds", { + method: "POST", + body: JSON.stringify({ url: feedUrl, workspaceId }), + }); + const { result: created } = (await createRes.json()) as { + result: { id: string }; + }; + + const outsider = await ctx.asUser(generateSnowflake()); + const res = await outsider.fetch(`/api/v1/user-feeds/${created.id}`); + assert.strictEqual(res.status, 404); + }); +}); + +describe( + "Workspace feed quota enforcement", + { concurrency: false }, + () => { + // A dedicated context with the workspace feed limit set to 1 so tests can reach + // the ceiling with a single repository-seeded fixture instead of 140 HTTP calls. + let quotaCtx: AppTestContext; + let quotaMockServer: TestHttpServer; + + before(async () => { + quotaMockServer = createTestHttpServer(); + quotaCtx = await createAppTestContext({ + configOverrides: { + BACKEND_API_DEFAULT_MAX_WORKSPACE_FEEDS: 1, + BACKEND_API_DEFAULT_MAX_USER_FEEDS: 1, + BACKEND_API_USER_FEEDS_API_HOST: quotaMockServer.host, + BACKEND_API_FEED_REQUESTS_API_HOST: quotaMockServer.host, + }, + }); + }); + + after(async () => { + await quotaCtx.teardown(); + await quotaMockServer.stop(); + }); + + beforeEach(() => { + quotaMockServer.clear(); + }); + + function mockQuotaFeedFetch(feedUrl: string) { + quotaMockServer.registerRoute("POST", "/v1/user-feeds/get-articles", { + status: 200, + body: { + result: { + requestStatus: "SUCCESS", + articles: [], + totalArticles: 0, + selectedProperties: [], + url: feedUrl, + feedTitle: "Feed", + }, + }, + }); + } + + async function seedQuotaWorkspaceUser(discordUserId: string): Promise { + await quotaCtx.container.userRepository.create({ + discordUserId, + email: `${discordUserId}@example.com`, + }); + await quotaCtx.connection.collection("users").updateOne( + { discordUserId }, + { + $set: { + "featureFlags.workspaces": true, + verifiedEmail: `verified-${discordUserId}@example.com`, + verifiedEmailVerifiedAt: new Date(), + }, + }, + ); + const id = await quotaCtx.container.userRepository.findIdByDiscordId(discordUserId); + return id as string; + } + + async function createQuotaWorkspace( + user: Awaited>, + slug: string, + ): Promise { + const res = await user.fetch("/api/v1/workspaces", { + method: "POST", + body: JSON.stringify({ name: "Workspace", slug }), + }); + assert.strictEqual(res.status, 201); + const body = (await res.json()) as { result: { id: string } }; + return body.result.id; + } + + it("returns 400 with FEED_LIMIT_REACHED when the workspace already has maxWorkspaceFeeds feeds", async () => { + const discordId = randomUUID(); + const userId = await seedQuotaWorkspaceUser(discordId); + const user = await quotaCtx.asUser(discordId); + const workspaceId = await createQuotaWorkspace(user, `quota-${discordId.slice(0, 8)}`); + + // Seed one workspace feed directly to reach the limit (maxWorkspaceFeeds = 1) + // without going through the HTTP layer. + await quotaCtx.container.userFeedRepository.create({ + title: "Existing Workspace Feed", + url: `https://example.com/${generateTestId()}.xml`, + user: { id: userId, discordUserId: discordId }, + workspaceId, + }); + + const newFeedUrl = `https://example.com/${generateTestId()}.xml`; + mockQuotaFeedFetch(newFeedUrl); + + const res = await user.fetch("/api/v1/user-feeds", { + method: "POST", + body: JSON.stringify({ url: newFeedUrl, workspaceId }), + }); + + assert.strictEqual(res.status, 400); + const body = (await res.json()) as { code: string }; + assert.strictEqual(body.code, "FEED_LIMIT_REACHED"); + + // Confirm the feed count did not grow: the workspace should still have exactly + // the one seeded feed. + const listRes = await user.fetch( + `/api/v1/user-feeds?limit=10&offset=0&workspaceId=${workspaceId}`, + ); + const listBody = (await listRes.json()) as { total: number }; + assert.strictEqual(listBody.total, 1); + }); + + it("allows creating a workspace feed even when the user is at their personal feed limit", async () => { + const discordId = randomUUID(); + const userId = await seedQuotaWorkspaceUser(discordId); + const user = await quotaCtx.asUser(discordId); + const workspaceId = await createQuotaWorkspace(user, `scope-${discordId.slice(0, 8)}`); + + // Seed one personal feed (no workspaceId) so the user is at their personal + // limit (maxUserFeeds = 1). Personal and workspace quotas are tracked + // independently — countByOwnership excludes workspaceId-bearing feeds, and + // countByWorkspace only counts feeds for the given workspace. + await quotaCtx.container.userFeedRepository.create({ + title: "Personal Feed", + url: `https://example.com/${generateTestId()}.xml`, + user: { id: userId, discordUserId: discordId }, + }); + + const workspaceFeedUrl = `https://example.com/${generateTestId()}.xml`; + mockQuotaFeedFetch(workspaceFeedUrl); + + const res = await user.fetch("/api/v1/user-feeds", { + method: "POST", + body: JSON.stringify({ url: workspaceFeedUrl, workspaceId }), + }); + + assert.strictEqual(res.status, 201); + + // The workspace scope shows exactly the one new workspace feed. + const workspaceList = await user.fetch( + `/api/v1/user-feeds?limit=10&offset=0&workspaceId=${workspaceId}`, + ); + const workspaceBody = (await workspaceList.json()) as { total: number }; + assert.strictEqual(workspaceBody.total, 1); + + // The personal scope still shows exactly the one pre-existing personal feed. + const personalList = await user.fetch( + "/api/v1/user-feeds?limit=10&offset=0", + ); + const personalBody = (await personalList.json()) as { total: number }; + assert.strictEqual(personalBody.total, 1); + }); + }, +); diff --git a/services/backend-api/test/api/users.test.ts b/services/backend-api/test/api/users.test.ts index 953c72553..3089c1f91 100644 --- a/services/backend-api/test/api/users.test.ts +++ b/services/backend-api/test/api/users.test.ts @@ -99,6 +99,7 @@ describe("GET /api/v1/users/@me", { concurrency: true }, () => { creditBalance: { availableFormatted: string }; enableBilling: boolean; featureFlags: Record; + capabilities: { workspaces: boolean }; supporterFeatures: { exrternalProperties: { enabled: boolean }; }; @@ -119,6 +120,7 @@ describe("GET /api/v1/users/@me", { concurrency: true }, () => { assert.strictEqual(body.result.creditBalance.availableFormatted, "0"); assert.strictEqual(body.result.enableBilling, true); assert.deepStrictEqual(body.result.featureFlags, {}); + assert.deepStrictEqual(body.result.capabilities, { workspaces: false }); assert.ok(body.result.supporterFeatures); assert.strictEqual( body.result.supporterFeatures.exrternalProperties.enabled, @@ -759,3 +761,74 @@ describe("PATCH /api/v1/users/@me", { concurrency: true }, () => { }); }); }); + +describe( + "capabilities.workspaces reflects the per-user feature flag", + { concurrency: true }, + () => { + let ctx: AppTestContext; + + before(async () => { + ctx = await createAppTestContext(); + }); + + after(async () => { + await ctx.teardown(); + }); + + async function enableWorkspacesFlag(discordUserId: string): Promise { + await ctx.connection + .collection("users") + .updateOne( + { discordUserId }, + { $set: { "featureFlags.workspaces": true } }, + ); + } + + it("GET /users/@me returns capabilities.workspaces=true when the user has the flag", async () => { + const discordUserId = generateSnowflake(); + const user = await ctx.asUser(discordUserId); + await enableWorkspacesFlag(discordUserId); + + const response = await user.fetch("/api/v1/users/@me"); + + assert.strictEqual(response.status, 200); + const body = (await response.json()) as { + result: { capabilities: { workspaces: boolean } }; + }; + assert.deepStrictEqual(body.result.capabilities, { workspaces: true }); + }); + + it("GET /users/@me returns capabilities.workspaces=false without the flag", async () => { + const user = await ctx.asUser(generateSnowflake()); + + const response = await user.fetch("/api/v1/users/@me"); + + assert.strictEqual(response.status, 200); + const body = (await response.json()) as { + result: { capabilities: { workspaces: boolean } }; + }; + assert.deepStrictEqual(body.result.capabilities, { workspaces: false }); + }); + + it("PATCH /users/@me also returns capabilities.workspaces=true when the user has the flag", async () => { + const discordUserId = generateSnowflake(); + const user = await ctx.asUser(discordUserId); + await enableWorkspacesFlag(discordUserId); + + const response = await user.fetch("/api/v1/users/@me", { + method: "PATCH", + body: JSON.stringify({ + preferences: { alertOnDisabledFeeds: true }, + }), + headers: { "Content-Type": "application/json" }, + }); + + assert.strictEqual(response.status, 200); + const body = (await response.json()) as { + result: { capabilities: { workspaces: boolean } }; + }; + assert.deepStrictEqual(body.result.capabilities, { workspaces: true }); + }); + }, +); diff --git a/services/backend-api/test/api/workspace-invite-claim.test.ts b/services/backend-api/test/api/workspace-invite-claim.test.ts new file mode 100644 index 000000000..73fe8c04d --- /dev/null +++ b/services/backend-api/test/api/workspace-invite-claim.test.ts @@ -0,0 +1,586 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert"; +import { randomUUID } from "node:crypto"; +import { Types } from "mongoose"; +import { + createAppTestContext, + type AppTestContext, +} from "../helpers/test-context"; + +async function readJson(res: Response): Promise { + return (await res.json()) as T; +} + +interface ErrorResult { + code: string; + message: string; + errors: Array<{ message: string }>; +} + +describe("Workspace invite claim API", () => { + let ctx: AppTestContext; + + before(async () => { + ctx = await createAppTestContext({ + configOverrides: { BACKEND_API_SMTP_FROM_DOMAIN: "example.com" }, + }); + }); + + after(async () => { + await ctx.teardown(); + }); + + // Seeds a user with the workspaces feature flag and (optionally) a verified + // email, returning the internal user id + discord id. + async function seedUser( + opts: { verifiedEmail?: string } = {}, + ): Promise<{ discordUserId: string; internalId: string }> { + const discordUserId = randomUUID(); + await ctx.container.userRepository.create({ + discordUserId, + email: `${discordUserId}@example.com`, + }); + + const set: Record = { + "featureFlags.workspaces": true, + }; + if (opts.verifiedEmail) { + set.verifiedEmail = opts.verifiedEmail; + set.verifiedEmailVerifiedAt = new Date(); + } + + await ctx.connection + .collection("users") + .updateOne({ discordUserId }, { $set: set }); + + const internalId = + await ctx.container.userRepository.findIdByDiscordId(discordUserId); + return { discordUserId, internalId: internalId as string }; + } + + async function seedWorkspace( + ownerUserId: string, + name = "Test Workspace", + ): Promise<{ id: string; slug: string }> { + const slug = `ws-${randomUUID().slice(0, 8)}`; + const workspace = + await ctx.container.workspaceRepository.createWorkspaceWithOwner({ + name, + slug, + ownerUserId, + }); + return { id: workspace.id, slug: workspace.slug }; + } + + async function seedInvite( + workspaceId: string, + email: string, + invitedByUserId: string, + ): Promise { + const id = ctx.container.workspaceRepository.generateInviteId(); + await ctx.container.workspaceRepository.createInvite({ + id, + workspaceId, + email, + role: "admin", + invitedByUserId, + }); + return id; + } + + it("returns workspace name, inviter, and a redacted email hint resolved from the row", async () => { + const owner = await seedUser(); + const workspace = await seedWorkspace(owner.internalId, "Acme Team"); + const invitedEmail = `${randomUUID()}@example.com`; + const inviteId = await seedInvite( + workspace.id, + invitedEmail, + owner.internalId, + ); + + const invitee = await seedUser(); + const authed = await ctx.asUser(invitee.discordUserId); + + const res = await authed.fetch(`/api/v1/workspace-invites/${inviteId}`); + assert.strictEqual(res.status, 200); + + const body = await readJson<{ + result: { + id: string; + emailHint: string; + email?: string; + workspaceName: string; + invitedByUserId: string; + }; + }>(res); + assert.strictEqual(body.result.id, inviteId); + // The single-invite GET is reachable by any feature-flagged user who knows + // the id, so it must redact the address to a recognizable hint and never + // return it in plaintext. + assert.strictEqual( + body.result.emailHint, + `${invitedEmail[0]}***@example.com`, + ); + assert.strictEqual(body.result.email, undefined); + assert.strictEqual(body.result.workspaceName, "Acme Team"); + assert.strictEqual(body.result.invitedByUserId, owner.internalId); + }); + + it("discloses the full invited email to a caller whose verifiedEmail matches, alongside the redacted hint", async () => { + const owner = await seedUser(); + const workspace = await seedWorkspace(owner.internalId, "Acme Team"); + const invitedEmail = `${randomUUID()}@example.com`; + const inviteId = await seedInvite( + workspace.id, + invitedEmail, + owner.internalId, + ); + + // The caller has proven ownership of the invited address, so the gate opens + // and the full plaintext email is returned (in addition to the hint). + const invitee = await seedUser({ verifiedEmail: invitedEmail }); + const authed = await ctx.asUser(invitee.discordUserId); + + const res = await authed.fetch(`/api/v1/workspace-invites/${inviteId}`); + assert.strictEqual(res.status, 200); + + const body = await readJson<{ + result: { id: string; emailHint: string; email?: string }; + }>(res); + assert.strictEqual(body.result.id, inviteId); + assert.strictEqual(body.result.email, invitedEmail); + assert.strictEqual( + body.result.emailHint, + `${invitedEmail[0]}***@example.com`, + ); + }); + + it("reports alreadyMember=false for a caller who is not yet a member", async () => { + const owner = await seedUser(); + const workspace = await seedWorkspace(owner.internalId, "Acme Team"); + const invitedEmail = `${randomUUID()}@example.com`; + const inviteId = await seedInvite( + workspace.id, + invitedEmail, + owner.internalId, + ); + + const invitee = await seedUser(); + const authed = await ctx.asUser(invitee.discordUserId); + + const res = await authed.fetch(`/api/v1/workspace-invites/${inviteId}`); + assert.strictEqual(res.status, 200); + + const body = await readJson<{ result: { alreadyMember?: boolean } }>(res); + assert.strictEqual(body.result.alreadyMember, false); + }); + + it("reports alreadyMember=true for the owner opening their own invite, before any email verification", async () => { + // This is the self-accept dead-end: the owner (already a member) opens an + // invite addressed to an email they do not own yet. The flag must be true + // here — while the owner is still unverified — so the landing page can show + // an "already a member" state instead of pushing them through verification + // (which would overwrite their verified email) only to fail the accept guard. + const owner = await seedUser(); + const workspace = await seedWorkspace(owner.internalId, "Acme Team"); + const invitedEmail = `${randomUUID()}@example.com`; + const inviteId = await seedInvite( + workspace.id, + invitedEmail, + owner.internalId, + ); + + const authed = await ctx.asUser(owner.discordUserId); + + const res = await authed.fetch(`/api/v1/workspace-invites/${inviteId}`); + assert.strictEqual(res.status, 200); + + const body = await readJson<{ + result: { alreadyMember?: boolean; email?: string }; + }>(res); + assert.strictEqual(body.result.alreadyMember, true); + // The owner has not verified the invited address, so it stays redacted. + assert.strictEqual(body.result.email, undefined); + }); + + it("returns 404 WORKSPACE_INVITE_NOT_FOUND for an unknown invite id", async () => { + const invitee = await seedUser(); + const authed = await ctx.asUser(invitee.discordUserId); + + const unknownId = ctx.container.workspaceRepository.generateInviteId(); + const res = await authed.fetch(`/api/v1/workspace-invites/${unknownId}`); + assert.strictEqual(res.status, 404); + assert.strictEqual( + (await readJson(res)).code, + "WORKSPACE_INVITE_NOT_FOUND", + ); + }); + + it("accepts an invite when verifiedEmail matches: creates an admin membership and removes the invite, in one transaction", async () => { + const owner = await seedUser(); + const workspace = await seedWorkspace(owner.internalId); + const invitedEmail = `${randomUUID()}@example.com`; + const inviteId = await seedInvite( + workspace.id, + invitedEmail, + owner.internalId, + ); + + const invitee = await seedUser({ verifiedEmail: invitedEmail }); + const authed = await ctx.asUser(invitee.discordUserId); + + const res = await authed.fetch( + `/api/v1/workspace-invites/${inviteId}/accept`, + { method: "POST" }, + ); + assert.strictEqual(res.status, 200); + + // The accept response returns the joined workspace's slug so the client + // can redirect the invitee straight into it. + const body = await res.json(); + assert.strictEqual(body.result.workspaceSlug, workspace.slug); + + // Membership row created with role admin. + const membership = await ctx.connection + .collection("workspacememberships") + .findOne({ + workspaceId: new Types.ObjectId(workspace.id), + userId: new Types.ObjectId(invitee.internalId), + }); + assert.ok(membership, "membership must be created"); + assert.strictEqual(membership.role, "admin"); + + // Invite row removed. + const remaining = await ctx.connection + .collection("workspaceinvites") + .countDocuments({ _id: new Types.ObjectId(inviteId) }); + assert.strictEqual(remaining, 0); + }); + + it("rejects accept with WORKSPACE_INVITE_EMAIL_UNVERIFIED (without leaking the invited email) when the user has no verified email", async () => { + const owner = await seedUser(); + const workspace = await seedWorkspace(owner.internalId); + const invitedEmail = `${randomUUID()}@example.com`; + const inviteId = await seedInvite( + workspace.id, + invitedEmail, + owner.internalId, + ); + + const invitee = await seedUser(); // no verified email + const authed = await ctx.asUser(invitee.discordUserId); + + const res = await authed.fetch( + `/api/v1/workspace-invites/${inviteId}/accept`, + { method: "POST" }, + ); + assert.strictEqual(res.status, 403); + const body = await readJson(res); + assert.strictEqual(body.code, "WORKSPACE_INVITE_EMAIL_UNVERIFIED"); + // The code alone tells the client which case it is; the address must not be + // echoed here (PII / IDOR harvest by a prober). + assert.ok( + !body.errors.some((e) => e.message === invitedEmail), + "payload must not leak the invited email", + ); + + // No membership created, invite still pending. + const membershipCount = await ctx.connection + .collection("workspacememberships") + .countDocuments({ userId: new Types.ObjectId(invitee.internalId) }); + assert.strictEqual(membershipCount, 0); + const inviteCount = await ctx.connection + .collection("workspaceinvites") + .countDocuments({ _id: new Types.ObjectId(inviteId) }); + assert.strictEqual(inviteCount, 1); + }); + + it("rejects accept with WORKSPACE_INVITE_EMAIL_MISMATCH (without leaking the invited email) when verified under a different address", async () => { + const owner = await seedUser(); + const workspace = await seedWorkspace(owner.internalId); + const invitedEmail = `${randomUUID()}@example.com`; + const inviteId = await seedInvite( + workspace.id, + invitedEmail, + owner.internalId, + ); + + const invitee = await seedUser({ + verifiedEmail: `${randomUUID()}@example.com`, + }); + const authed = await ctx.asUser(invitee.discordUserId); + + const res = await authed.fetch( + `/api/v1/workspace-invites/${inviteId}/accept`, + { method: "POST" }, + ); + assert.strictEqual(res.status, 403); + const body = await readJson(res); + assert.strictEqual(body.code, "WORKSPACE_INVITE_EMAIL_MISMATCH"); + assert.ok( + !body.errors.some((e) => e.message === invitedEmail), + "payload must not leak the invited email", + ); + }); + + it("rejects decline with the same granular gating codes", async () => { + const owner = await seedUser(); + const workspace = await seedWorkspace(owner.internalId); + const invitedEmail = `${randomUUID()}@example.com`; + + // Unverified caller. + const unverifiedInviteId = await seedInvite( + workspace.id, + invitedEmail, + owner.internalId, + ); + const unverified = await seedUser(); + const unverifiedAuthed = await ctx.asUser(unverified.discordUserId); + const unverifiedRes = await unverifiedAuthed.fetch( + `/api/v1/workspace-invites/${unverifiedInviteId}/decline`, + { method: "POST" }, + ); + assert.strictEqual(unverifiedRes.status, 403); + const unverifiedBody = await readJson(unverifiedRes); + assert.strictEqual( + unverifiedBody.code, + "WORKSPACE_INVITE_EMAIL_UNVERIFIED", + ); + assert.ok(!unverifiedBody.errors.some((e) => e.message === invitedEmail)); + + // Mismatched caller. + const mismatched = await seedUser({ + verifiedEmail: `${randomUUID()}@example.com`, + }); + const mismatchedAuthed = await ctx.asUser(mismatched.discordUserId); + const mismatchedRes = await mismatchedAuthed.fetch( + `/api/v1/workspace-invites/${unverifiedInviteId}/decline`, + { method: "POST" }, + ); + assert.strictEqual(mismatchedRes.status, 403); + assert.strictEqual( + (await readJson(mismatchedRes)).code, + "WORKSPACE_INVITE_EMAIL_MISMATCH", + ); + + // The invite is still pending after both rejected attempts. + const inviteCount = await ctx.connection + .collection("workspaceinvites") + .countDocuments({ _id: new Types.ObjectId(unverifiedInviteId) }); + assert.strictEqual(inviteCount, 1); + }); + + it("declines an invite when verifiedEmail matches: removes the invite row and creates no membership", async () => { + const owner = await seedUser(); + const workspace = await seedWorkspace(owner.internalId); + const invitedEmail = `${randomUUID()}@example.com`; + const inviteId = await seedInvite( + workspace.id, + invitedEmail, + owner.internalId, + ); + + const invitee = await seedUser({ verifiedEmail: invitedEmail }); + const authed = await ctx.asUser(invitee.discordUserId); + + const res = await authed.fetch( + `/api/v1/workspace-invites/${inviteId}/decline`, + { method: "POST" }, + ); + assert.strictEqual(res.status, 204); + + const inviteCount = await ctx.connection + .collection("workspaceinvites") + .countDocuments({ _id: new Types.ObjectId(inviteId) }); + assert.strictEqual(inviteCount, 0); + const membershipCount = await ctx.connection + .collection("workspacememberships") + .countDocuments({ userId: new Types.ObjectId(invitee.internalId) }); + assert.strictEqual(membershipCount, 0); + }); + + it("surfaces pending invitations to a user who has verified the matching email, and not otherwise", async () => { + const owner = await seedUser(); + const workspace = await seedWorkspace(owner.internalId, "Surfacing Team"); + const invitedEmail = `${randomUUID()}@example.com`; + await seedInvite(workspace.id, invitedEmail, owner.internalId); + + // A user who has NOT verified the invited email sees nothing. + const unverified = await seedUser(); + const unverifiedAuthed = await ctx.asUser(unverified.discordUserId); + const emptyRes = await unverifiedAuthed.fetch( + `/api/v1/workspace-invites/@me`, + ); + assert.strictEqual(emptyRes.status, 200); + const emptyBody = await readJson<{ result: Array<{ id: string }> }>( + emptyRes, + ); + assert.strictEqual(emptyBody.result.length, 0); + + // A user who HAS verified the invited email sees it surface. + const invitee = await seedUser({ verifiedEmail: invitedEmail }); + const inviteeAuthed = await ctx.asUser(invitee.discordUserId); + const listRes = await inviteeAuthed.fetch(`/api/v1/workspace-invites/@me`); + assert.strictEqual(listRes.status, 200); + const listBody = await readJson<{ + result: Array<{ email: string; workspaceName: string }>; + }>(listRes); + assert.strictEqual(listBody.result.length, 1); + assert.strictEqual(listBody.result[0]?.email, invitedEmail); + assert.strictEqual(listBody.result[0]?.workspaceName, "Surfacing Team"); + }); + + it("accepting one invitation leaves the user's other pending invitations untouched", async () => { + const sharedEmail = `${randomUUID()}@example.com`; + + const ownerA = await seedUser(); + const workspaceA = await seedWorkspace(ownerA.internalId, "Workspace A"); + const inviteA = await seedInvite( + workspaceA.id, + sharedEmail, + ownerA.internalId, + ); + + const ownerB = await seedUser(); + const workspaceB = await seedWorkspace(ownerB.internalId, "Workspace B"); + const inviteB = await seedInvite( + workspaceB.id, + sharedEmail, + ownerB.internalId, + ); + + const invitee = await seedUser({ verifiedEmail: sharedEmail }); + const authed = await ctx.asUser(invitee.discordUserId); + + const acceptRes = await authed.fetch( + `/api/v1/workspace-invites/${inviteA}/accept`, + { method: "POST" }, + ); + assert.strictEqual(acceptRes.status, 200); + + // Invite B is still pending and still surfaces in the @me list. + const inviteBCount = await ctx.connection + .collection("workspaceinvites") + .countDocuments({ _id: new Types.ObjectId(inviteB) }); + assert.strictEqual(inviteBCount, 1); + + const listRes = await authed.fetch(`/api/v1/workspace-invites/@me`); + const listBody = await readJson<{ result: Array<{ id: string }> }>(listRes); + assert.strictEqual(listBody.result.length, 1); + assert.strictEqual(listBody.result[0]?.id, inviteB); + }); + + it("returns 404 WORKSPACE_INVITE_NOT_FOUND when accepting an already-consumed invite", async () => { + const owner = await seedUser(); + const workspace = await seedWorkspace(owner.internalId); + const invitedEmail = `${randomUUID()}@example.com`; + const inviteId = await seedInvite( + workspace.id, + invitedEmail, + owner.internalId, + ); + + const invitee = await seedUser({ verifiedEmail: invitedEmail }); + const authed = await ctx.asUser(invitee.discordUserId); + + const firstRes = await authed.fetch( + `/api/v1/workspace-invites/${inviteId}/accept`, + { method: "POST" }, + ); + assert.strictEqual(firstRes.status, 200); + + // The invite row is now consumed, so a second accept of the same id finds + // nothing to claim and reports it as gone. + const secondRes = await authed.fetch( + `/api/v1/workspace-invites/${inviteId}/accept`, + { method: "POST" }, + ); + assert.strictEqual(secondRes.status, 404); + assert.strictEqual( + (await readJson(secondRes)).code, + "WORKSPACE_INVITE_NOT_FOUND", + ); + }); + + it("returns 404 WORKSPACE_INVITE_NOT_FOUND when declining an invite that was already revoked", async () => { + const owner = await seedUser(); + const workspace = await seedWorkspace(owner.internalId); + const invitedEmail = `${randomUUID()}@example.com`; + const inviteId = await seedInvite( + workspace.id, + invitedEmail, + owner.internalId, + ); + + const invitee = await seedUser({ verifiedEmail: invitedEmail }); + const authed = await ctx.asUser(invitee.discordUserId); + + // The invite is revoked out from under the matching user (e.g. an admin + // revoked it). Deleting the row directly mirrors the revoke outcome. + await ctx.connection + .collection("workspaceinvites") + .deleteOne({ _id: new Types.ObjectId(inviteId) }); + + const res = await authed.fetch( + `/api/v1/workspace-invites/${inviteId}/decline`, + { method: "POST" }, + ); + assert.strictEqual(res.status, 404); + assert.strictEqual( + (await readJson(res)).code, + "WORKSPACE_INVITE_NOT_FOUND", + ); + }); + + it("rejects accept with WORKSPACE_INVITE_ALREADY_MEMBER when the matching user is already a member, leaving the invite pending", async () => { + const owner = await seedUser(); + const workspace = await seedWorkspace(owner.internalId); + const invitedEmail = `${randomUUID()}@example.com`; + const inviteId = await seedInvite( + workspace.id, + invitedEmail, + owner.internalId, + ); + + const invitee = await seedUser({ verifiedEmail: invitedEmail }); + + // Make the invitee already a member of the workspace before accepting. This + // is the shape of the owner-accepts-own-invite bug: a user who verifies the + // invited email onto an account that is already in the workspace must not be + // able to consume the invitation against their existing membership. + await ctx.connection.collection("workspacememberships").insertOne({ + workspaceId: new Types.ObjectId(workspace.id), + userId: new Types.ObjectId(invitee.internalId), + role: "admin", + createdAt: new Date(), + updatedAt: new Date(), + }); + + const authed = await ctx.asUser(invitee.discordUserId); + const res = await authed.fetch( + `/api/v1/workspace-invites/${inviteId}/accept`, + { method: "POST" }, + ); + assert.strictEqual(res.status, 409); + assert.strictEqual( + (await readJson(res)).code, + "WORKSPACE_INVITE_ALREADY_MEMBER", + ); + + // The invite row is untouched, so it can still be claimed by the intended + // person (a different account that verifies the same address). + const inviteCount = await ctx.connection + .collection("workspaceinvites") + .countDocuments({ _id: new Types.ObjectId(inviteId) }); + assert.strictEqual(inviteCount, 1); + + // The pre-existing membership is unchanged (not duplicated). + const membershipCount = await ctx.connection + .collection("workspacememberships") + .countDocuments({ + workspaceId: new Types.ObjectId(workspace.id), + userId: new Types.ObjectId(invitee.internalId), + }); + assert.strictEqual(membershipCount, 1); + }); +}); diff --git a/services/backend-api/test/api/workspace-invite-controls.test.ts b/services/backend-api/test/api/workspace-invite-controls.test.ts new file mode 100644 index 000000000..590b31291 --- /dev/null +++ b/services/backend-api/test/api/workspace-invite-controls.test.ts @@ -0,0 +1,353 @@ +import { describe, it, before, after, beforeEach } from "node:test"; +import assert from "node:assert"; +import { randomUUID } from "node:crypto"; +import { Types } from "mongoose"; +import { + createAppTestContext, + type AppTestContext, +} from "../helpers/test-context"; +import { WorkspacesService } from "../../src/features/workspaces/workspaces.service"; +import { EmailVerificationService } from "../../src/features/users/email-verification.service"; +import type { SmtpTransport } from "../../src/infra/smtp"; + +async function readJson(res: Response): Promise { + return (await res.json()) as T; +} + +interface ErrorResult { + code: string; +} + +interface InviteResult { + result: { + id: string; + email: string; + role: string; + createdAt: string; + invitedByUserId: string; + }; +} + +interface InviteListResult { + result: Array<{ id: string; email: string }>; +} + +describe("Workspace invite controls API", () => { + let ctx: AppTestContext; + let sent: Array<{ to: string; html: string }>; + + before(async () => { + ctx = await createAppTestContext({ + configOverrides: { BACKEND_API_SMTP_FROM_DOMAIN: "example.com" }, + }); + }); + + after(async () => { + await ctx.teardown(); + }); + + // Swap in a capturing mailer so invite sends can be exercised without an SMTP + // server (the harness leaves SMTP unconfigured). + beforeEach(() => { + sent = []; + const fakeTransport = { + sendMail: async (msg: { to: string; html: string }) => { + sent.push({ to: msg.to, html: String(msg.html) }); + return {}; + }, + } as unknown as SmtpTransport; + + ctx.container.workspacesService = new WorkspacesService({ + config: ctx.container.config, + smtpTransport: fakeTransport, + workspaceRepository: ctx.container.workspaceRepository, + userRepository: ctx.container.userRepository, + emailVerificationService: new EmailVerificationService({ + config: ctx.container.config, + smtpTransport: fakeTransport, + emailVerificationRepository: ctx.container.emailVerificationRepository, + userRepository: ctx.container.userRepository, + }), + }); + }); + + async function seedUser(): Promise<{ + discordUserId: string; + internalId: string; + }> { + const discordUserId = randomUUID(); + await ctx.container.userRepository.create({ + discordUserId, + email: `${discordUserId}@example.com`, + }); + + await ctx.connection + .collection("users") + .updateOne( + { discordUserId }, + { $set: { "featureFlags.workspaces": true } }, + ); + + const internalId = + await ctx.container.userRepository.findIdByDiscordId(discordUserId); + return { discordUserId, internalId: internalId as string }; + } + + async function seedWorkspace( + ownerUserId: string, + ): Promise<{ id: string; slug: string }> { + const slug = `ws-${randomUUID().slice(0, 8)}`; + const workspace = + await ctx.container.workspaceRepository.createWorkspaceWithOwner({ + name: "Test Workspace", + slug, + ownerUserId, + }); + return { id: workspace.id, slug: workspace.slug }; + } + + async function createInvite( + slug: string, + discordUserId: string, + email: string, + ): Promise { + const authed = await ctx.asUser(discordUserId); + const res = await authed.fetch(`/api/v1/workspaces/${slug}/invites`, { + method: "POST", + body: JSON.stringify({ email }), + }); + assert.strictEqual(res.status, 201); + return (await readJson(res)).result.id; + } + + // Pushes an invite's last-sent timestamp into the past so a resend is not + // rejected by the cooldown, without waiting in real time. + async function expireResendCooldown(inviteId: string): Promise { + await ctx.connection + .collection("workspaceinvites") + .updateOne( + { _id: new Types.ObjectId(inviteId) }, + { $set: { lastSentAt: new Date(0) } }, + ); + } + + it("lets an owner resend a pending invite, re-sending the notification email", async () => { + const owner = await seedUser(); + const workspace = await seedWorkspace(owner.internalId); + const email = `${randomUUID()}@example.com`; + const inviteId = await createInvite( + workspace.slug, + owner.discordUserId, + email, + ); + + sent = []; + await expireResendCooldown(inviteId); + + const authed = await ctx.asUser(owner.discordUserId); + const res = await authed.fetch( + `/api/v1/workspaces/${workspace.slug}/invites/${inviteId}/resend`, + { method: "POST" }, + ); + assert.strictEqual(res.status, 200); + + assert.strictEqual(sent.length, 1); + const mail = sent[0]; + assert.ok(mail); + assert.strictEqual(mail.to, email); + assert.ok( + mail.html.includes(`/invites/${inviteId}`), + "resent email must link to the invite id", + ); + }); + + it("rejects a resend within the cooldown window with 429", async () => { + const owner = await seedUser(); + const workspace = await seedWorkspace(owner.internalId); + const email = `${randomUUID()}@example.com`; + const inviteId = await createInvite( + workspace.slug, + owner.discordUserId, + email, + ); + + // No cooldown reset: the create just set lastSentAt to now. + sent = []; + const authed = await ctx.asUser(owner.discordUserId); + const res = await authed.fetch( + `/api/v1/workspaces/${workspace.slug}/invites/${inviteId}/resend`, + { method: "POST" }, + ); + assert.strictEqual(res.status, 429); + assert.strictEqual( + (await readJson(res)).code, + "WORKSPACE_INVITE_RESEND_TOO_SOON", + ); + assert.strictEqual(sent.length, 0); + }); + + it("lets an owner revoke a pending invite, removing it from the pending list", async () => { + const owner = await seedUser(); + const workspace = await seedWorkspace(owner.internalId); + const email = `${randomUUID()}@example.com`; + const inviteId = await createInvite( + workspace.slug, + owner.discordUserId, + email, + ); + + const authed = await ctx.asUser(owner.discordUserId); + const res = await authed.fetch( + `/api/v1/workspaces/${workspace.slug}/invites/${inviteId}`, + { method: "DELETE" }, + ); + assert.strictEqual(res.status, 200); + + const listRes = await authed.fetch( + `/api/v1/workspaces/${workspace.slug}/invites`, + ); + const list = await readJson(listRes); + assert.strictEqual( + list.result.find((i) => i.id === inviteId), + undefined, + "revoked invite must not appear in the pending list", + ); + assert.strictEqual(list.result.length, 0); + }); + + it("rejects creating an invite once the workspace is at its pending-invite cap", async () => { + const owner = await seedUser(); + const workspace = await seedWorkspace(owner.internalId); + + // Seed the workspace up to the cap with raw pending-invite rows. + const CAP = 25; + const rows = Array.from({ length: CAP }, () => ({ + workspaceId: new Types.ObjectId(workspace.id), + email: `${randomUUID()}@example.com`, + role: "admin", + invitedByUserId: new Types.ObjectId(owner.internalId), + lastSentAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + })); + await ctx.connection.collection("workspaceinvites").insertMany(rows); + + const authed = await ctx.asUser(owner.discordUserId); + const res = await authed.fetch( + `/api/v1/workspaces/${workspace.slug}/invites`, + { + method: "POST", + body: JSON.stringify({ email: `${randomUUID()}@example.com` }), + }, + ); + assert.strictEqual(res.status, 409); + assert.strictEqual( + (await readJson(res)).code, + "WORKSPACE_INVITE_LIMIT_REACHED", + ); + // The over-cap creation must not have sent a notification email. + assert.strictEqual(sent.length, 0); + }); + + it("returns 404 WORKSPACE_NOT_FOUND for a non-member trying to resend or revoke", async () => { + const owner = await seedUser(); + const workspace = await seedWorkspace(owner.internalId); + const inviteId = await createInvite( + workspace.slug, + owner.discordUserId, + `${randomUUID()}@example.com`, + ); + + const outsider = await seedUser(); + const authed = await ctx.asUser(outsider.discordUserId); + + // A non-member cannot tell the workspace (or invite) exists: both surface as + // 404 WORKSPACE_NOT_FOUND, matching create/list behavior. + const resendRes = await authed.fetch( + `/api/v1/workspaces/${workspace.slug}/invites/${inviteId}/resend`, + { method: "POST" }, + ); + assert.strictEqual(resendRes.status, 404); + assert.strictEqual( + (await readJson(resendRes)).code, + "WORKSPACE_NOT_FOUND", + ); + + const revokeRes = await authed.fetch( + `/api/v1/workspaces/${workspace.slug}/invites/${inviteId}`, + { method: "DELETE" }, + ); + assert.strictEqual(revokeRes.status, 404); + assert.strictEqual( + (await readJson(revokeRes)).code, + "WORKSPACE_NOT_FOUND", + ); + + // The invite still exists: the non-member's revoke did nothing. + const count = await ctx.connection + .collection("workspaceinvites") + .countDocuments({ _id: new Types.ObjectId(inviteId) }); + assert.strictEqual(count, 1); + }); + + it("returns 404 WORKSPACE_INVITE_NOT_FOUND when an owner resends an invite that no longer exists", async () => { + const owner = await seedUser(); + const workspace = await seedWorkspace(owner.internalId); + const inviteId = await createInvite( + workspace.slug, + owner.discordUserId, + `${randomUUID()}@example.com`, + ); + + const authed = await ctx.asUser(owner.discordUserId); + + // Revoke it first so the subsequent resend targets a gone invite. + const revokeRes = await authed.fetch( + `/api/v1/workspaces/${workspace.slug}/invites/${inviteId}`, + { method: "DELETE" }, + ); + assert.strictEqual(revokeRes.status, 200); + + const res = await authed.fetch( + `/api/v1/workspaces/${workspace.slug}/invites/${inviteId}/resend`, + { method: "POST" }, + ); + // The owner IS a member, so this is not a workspace-scope 404 — it is the + // invite-specific not-found code (pins the review #2 error-code fix). + assert.strictEqual(res.status, 404); + assert.strictEqual( + (await readJson(res)).code, + "WORKSPACE_INVITE_NOT_FOUND", + ); + }); + + it("returns 404 WORKSPACE_INVITE_NOT_FOUND when an owner revokes an invite that is already gone", async () => { + const owner = await seedUser(); + const workspace = await seedWorkspace(owner.internalId); + const inviteId = await createInvite( + workspace.slug, + owner.discordUserId, + `${randomUUID()}@example.com`, + ); + + const authed = await ctx.asUser(owner.discordUserId); + + const firstRevoke = await authed.fetch( + `/api/v1/workspaces/${workspace.slug}/invites/${inviteId}`, + { method: "DELETE" }, + ); + assert.strictEqual(firstRevoke.status, 200); + + // Revoking the same (now-gone) invite reports it as not found rather than + // succeeding again or 500ing. + const secondRevoke = await authed.fetch( + `/api/v1/workspaces/${workspace.slug}/invites/${inviteId}`, + { method: "DELETE" }, + ); + assert.strictEqual(secondRevoke.status, 404); + assert.strictEqual( + (await readJson(secondRevoke)).code, + "WORKSPACE_INVITE_NOT_FOUND", + ); + }); +}); diff --git a/services/backend-api/test/api/workspace-invites.test.ts b/services/backend-api/test/api/workspace-invites.test.ts new file mode 100644 index 000000000..1cd0fe497 --- /dev/null +++ b/services/backend-api/test/api/workspace-invites.test.ts @@ -0,0 +1,482 @@ +import { describe, it, before, after, beforeEach } from "node:test"; +import assert from "node:assert"; +import { randomUUID } from "node:crypto"; +import { Types } from "mongoose"; +import { + createAppTestContext, + type AppTestContext, +} from "../helpers/test-context"; +import { WorkspacesService } from "../../src/features/workspaces/workspaces.service"; +import { EmailVerificationService } from "../../src/features/users/email-verification.service"; +import type { SmtpTransport } from "../../src/infra/smtp"; + +async function readJson(res: Response): Promise { + return (await res.json()) as T; +} + +interface ErrorResult { + code: string; +} + +interface InviteResult { + result: { + id: string; + email: string; + role: string; + createdAt: string; + invitedByUserId: string; + }; +} + +interface InviteListResult { + result: Array<{ + id: string; + email: string; + role: string; + createdAt: string; + invitedByUserId: string; + }>; +} + +describe("Workspace invites API", () => { + let ctx: AppTestContext; + let sent: Array<{ to: string; html: string }>; + + before(async () => { + // A from-domain is required for the sender address (createFromFormatter); + // the fake transport below stands in for an actual SMTP server. + ctx = await createAppTestContext({ + configOverrides: { BACKEND_API_SMTP_FROM_DOMAIN: "example.com" }, + }); + }); + + after(async () => { + await ctx.teardown(); + }); + + // Swap in a capturing mailer so invite creation can be exercised without an + // SMTP server (the harness leaves SMTP unconfigured). Individual tests can + // override this with a throwing/absent transport to simulate SMTP-down. + beforeEach(() => { + sent = []; + const fakeTransport = { + sendMail: async (msg: { to: string; html: string }) => { + sent.push({ to: msg.to, html: String(msg.html) }); + return {}; + }, + } as unknown as SmtpTransport; + + // The invite-scoped verification send delegates to EmailVerificationService; + // wire a real one (same capturing transport) so those sends are observable. + const emailVerificationService = new EmailVerificationService({ + config: ctx.container.config, + smtpTransport: fakeTransport, + emailVerificationRepository: ctx.container.emailVerificationRepository, + userRepository: ctx.container.userRepository, + }); + ctx.container.emailVerificationService = emailVerificationService; + + ctx.container.workspacesService = new WorkspacesService({ + config: ctx.container.config, + smtpTransport: fakeTransport, + workspaceRepository: ctx.container.workspaceRepository, + userRepository: ctx.container.userRepository, + emailVerificationService, + }); + }); + + // Seeds a user with the workspaces feature flag and (optionally) a verified + // email, returning the internal user id. + async function seedUser( + opts: { verifiedEmail?: string } = {}, + ): Promise<{ discordUserId: string; internalId: string }> { + const discordUserId = randomUUID(); + await ctx.container.userRepository.create({ + discordUserId, + email: `${discordUserId}@example.com`, + }); + + const set: Record = { + "featureFlags.workspaces": true, + }; + if (opts.verifiedEmail) { + set.verifiedEmail = opts.verifiedEmail; + set.verifiedEmailVerifiedAt = new Date(); + } + + await ctx.connection + .collection("users") + .updateOne({ discordUserId }, { $set: set }); + + const internalId = + await ctx.container.userRepository.findIdByDiscordId(discordUserId); + return { discordUserId, internalId: internalId as string }; + } + + // Creates a workspace owned by the given user and returns its slug + id. + async function seedWorkspace( + ownerUserId: string, + ): Promise<{ id: string; slug: string }> { + const slug = `ws-${randomUUID().slice(0, 8)}`; + const workspace = + await ctx.container.workspaceRepository.createWorkspaceWithOwner({ + name: "Test Workspace", + slug, + ownerUserId, + }); + return { id: workspace.id, slug: workspace.slug }; + } + + it("lets an owner create an invite and sends a notification email", async () => { + const owner = await seedUser(); + const workspace = await seedWorkspace(owner.internalId); + const authed = await ctx.asUser(owner.discordUserId); + + const inviteEmail = `Invitee+Tag@Example.com`; + const res = await authed.fetch( + `/api/v1/workspaces/${workspace.slug}/invites`, + { method: "POST", body: JSON.stringify({ email: inviteEmail }) }, + ); + assert.strictEqual(res.status, 201); + + const body = await readJson(res); + assert.strictEqual(body.result.email, "invitee+tag@example.com"); + assert.strictEqual(body.result.role, "admin"); + assert.strictEqual(body.result.invitedByUserId, owner.internalId); + assert.ok(body.result.id); + + // The notification email was sent to the normalized address. + assert.strictEqual(sent.length, 1); + const email = sent[0]; + assert.ok(email); + assert.strictEqual(email.to, "invitee+tag@example.com"); + // The link is keyed by invite id, never the email address. + assert.ok( + email.html.includes(`/invites/${body.result.id}`), + "email must link to the invite id", + ); + assert.ok( + !email.html.includes("invitee+tag@example.com"), + "email must not contain the invited address in the link", + ); + + // The row is persisted with the normalized email + role + inviter. + const row = await ctx.connection + .collection("workspaceinvites") + .findOne({ _id: new Types.ObjectId(body.result.id) }); + assert.ok(row); + assert.strictEqual(row.email, "invitee+tag@example.com"); + assert.strictEqual(row.role, "admin"); + assert.strictEqual(row.workspaceId.toString(), workspace.id); + assert.strictEqual(row.invitedByUserId.toString(), owner.internalId); + }); + + it("rejects creating an invite with a malformed email with 400", async () => { + const owner = await seedUser(); + const workspace = await seedWorkspace(owner.internalId); + const authed = await ctx.asUser(owner.discordUserId); + + const res = await authed.fetch( + `/api/v1/workspaces/${workspace.slug}/invites`, + { method: "POST", body: JSON.stringify({ email: "not-an-email" }) }, + ); + assert.strictEqual(res.status, 400); + assert.strictEqual(sent.length, 0); + }); + + it("lets a non-owner admin member create and list invites", async () => { + const owner = await seedUser(); + const workspace = await seedWorkspace(owner.internalId); + + const admin = await seedUser(); + await ctx.connection.collection("workspacememberships").insertOne({ + workspaceId: new Types.ObjectId(workspace.id), + userId: new Types.ObjectId(admin.internalId), + role: "admin", + createdAt: new Date(), + updatedAt: new Date(), + }); + const authed = await ctx.asUser(admin.discordUserId); + + const inviteEmail = `${randomUUID()}@example.com`; + const createRes = await authed.fetch( + `/api/v1/workspaces/${workspace.slug}/invites`, + { method: "POST", body: JSON.stringify({ email: inviteEmail }) }, + ); + assert.strictEqual(createRes.status, 201); + const created = await readJson(createRes); + assert.strictEqual(created.result.invitedByUserId, admin.internalId); + + const listRes = await authed.fetch( + `/api/v1/workspaces/${workspace.slug}/invites`, + ); + assert.strictEqual(listRes.status, 200); + const list = await readJson(listRes); + assert.strictEqual(list.result.length, 1); + assert.strictEqual(list.result[0]?.email, inviteEmail); + assert.strictEqual(list.result[0]?.invitedByUserId, admin.internalId); + assert.ok(list.result[0]?.createdAt); + }); + + it("rejects inviting an email that already belongs to a member of this workspace, scoped per-workspace", async () => { + const owner = await seedUser(); + const workspace = await seedWorkspace(owner.internalId); + const authed = await ctx.asUser(owner.discordUserId); + + // A user verified under the target email who is already a member here. + const memberEmail = `${randomUUID()}@example.com`; + const member = await seedUser({ verifiedEmail: memberEmail }); + await ctx.connection.collection("workspacememberships").insertOne({ + workspaceId: new Types.ObjectId(workspace.id), + userId: new Types.ObjectId(member.internalId), + role: "admin", + createdAt: new Date(), + updatedAt: new Date(), + }); + + const res = await authed.fetch( + `/api/v1/workspaces/${workspace.slug}/invites`, + { method: "POST", body: JSON.stringify({ email: memberEmail }) }, + ); + assert.strictEqual(res.status, 409); + assert.strictEqual( + (await readJson(res)).code, + "WORKSPACE_MEMBER_ALREADY_EXISTS", + ); + assert.strictEqual(sent.length, 0); + + // The same email CAN be invited to a DIFFERENT workspace where they are not + // a member: the member check is per-workspace, not global. + const otherOwner = await seedUser(); + const otherWorkspace = await seedWorkspace(otherOwner.internalId); + const otherAuthed = await ctx.asUser(otherOwner.discordUserId); + + const otherRes = await otherAuthed.fetch( + `/api/v1/workspaces/${otherWorkspace.slug}/invites`, + { method: "POST", body: JSON.stringify({ email: memberEmail }) }, + ); + assert.strictEqual(otherRes.status, 201); + }); + + it("rejects a second invite for the same workspace and email", async () => { + const owner = await seedUser(); + const workspace = await seedWorkspace(owner.internalId); + const authed = await ctx.asUser(owner.discordUserId); + + const inviteEmail = `${randomUUID()}@example.com`; + const first = await authed.fetch( + `/api/v1/workspaces/${workspace.slug}/invites`, + { method: "POST", body: JSON.stringify({ email: inviteEmail }) }, + ); + assert.strictEqual(first.status, 201); + + // Re-inviting the same email (any casing) to the same workspace is rejected. + const second = await authed.fetch( + `/api/v1/workspaces/${workspace.slug}/invites`, + { method: "POST", body: JSON.stringify({ email: inviteEmail.toUpperCase() }) }, + ); + assert.strictEqual(second.status, 409); + assert.strictEqual( + (await readJson(second)).code, + "WORKSPACE_ALREADY_INVITED", + ); + + const count = await ctx.connection + .collection("workspaceinvites") + .countDocuments({ + workspaceId: new Types.ObjectId(workspace.id), + email: inviteEmail.toLowerCase(), + }); + assert.strictEqual(count, 1); + }); + + it("fails with service-unavailable and persists no row when SMTP is down", async () => { + const owner = await seedUser(); + const workspace = await seedWorkspace(owner.internalId); + const authed = await ctx.asUser(owner.discordUserId); + + // Swap in a service with no transport to simulate SMTP being unconfigured. + ctx.container.workspacesService = new WorkspacesService({ + config: ctx.container.config, + smtpTransport: null, + workspaceRepository: ctx.container.workspaceRepository, + userRepository: ctx.container.userRepository, + emailVerificationService: new EmailVerificationService({ + config: ctx.container.config, + smtpTransport: null, + emailVerificationRepository: ctx.container.emailVerificationRepository, + userRepository: ctx.container.userRepository, + }), + }); + + const inviteEmail = `${randomUUID()}@example.com`; + const res = await authed.fetch( + `/api/v1/workspaces/${workspace.slug}/invites`, + { method: "POST", body: JSON.stringify({ email: inviteEmail }) }, + ); + assert.strictEqual(res.status, 503); + assert.strictEqual( + (await readJson(res)).code, + "WORKSPACE_INVITE_EMAIL_UNAVAILABLE", + ); + + // A failed send must leave no stranded invitation row. + const count = await ctx.connection + .collection("workspaceinvites") + .countDocuments({ + workspaceId: new Types.ObjectId(workspace.id), + email: inviteEmail.toLowerCase(), + }); + assert.strictEqual(count, 0); + }); + + it("returns 404 WORKSPACE_NOT_FOUND for a non-member trying to create or list invites", async () => { + const owner = await seedUser(); + const workspace = await seedWorkspace(owner.internalId); + + const outsider = await seedUser(); + const authed = await ctx.asUser(outsider.discordUserId); + + // A non-member cannot tell whether the workspace exists: both create and + // list surface as 404 WORKSPACE_NOT_FOUND, the same as an unknown slug. + const createRes = await authed.fetch( + `/api/v1/workspaces/${workspace.slug}/invites`, + { + method: "POST", + body: JSON.stringify({ email: `${randomUUID()}@example.com` }), + }, + ); + assert.strictEqual(createRes.status, 404); + assert.strictEqual( + (await readJson(createRes)).code, + "WORKSPACE_NOT_FOUND", + ); + + const listRes = await authed.fetch( + `/api/v1/workspaces/${workspace.slug}/invites`, + ); + assert.strictEqual(listRes.status, 404); + assert.strictEqual( + (await readJson(listRes)).code, + "WORKSPACE_NOT_FOUND", + ); + + assert.strictEqual(sent.length, 0); + }); + + // --- Invite-scoped email verification send (POST /:inviteId/verification) --- + + // Creates a workspace + a pending invite to `invitedEmail`, returning the + // invite id. The notification send is cleared from `sent` so verification-send + // assertions start clean. + async function seedInvite( + invitedEmail: string, + ): Promise<{ inviteId: string }> { + const owner = await seedUser(); + const workspace = await seedWorkspace(owner.internalId); + const ownerAuthed = await ctx.asUser(owner.discordUserId); + const res = await ownerAuthed.fetch( + `/api/v1/workspaces/${workspace.slug}/invites`, + { method: "POST", body: JSON.stringify({ email: invitedEmail }) }, + ); + assert.strictEqual(res.status, 201); + const body = await readJson(res); + sent.length = 0; + return { inviteId: body.result.id }; + } + + it("sends a verification code when the submitted address matches the invited address", async () => { + const invitedEmail = `invitee-${randomUUID()}@example.com`; + const { inviteId } = await seedInvite(invitedEmail); + + // A separate invitee user (no verified email yet) requests a code for the + // invited address through the invite-scoped endpoint. + const invitee = await seedUser(); + const authed = await ctx.asUser(invitee.discordUserId); + + const res = await authed.fetch( + `/api/v1/workspace-invites/${inviteId}/verification`, + { method: "POST", body: JSON.stringify({ email: invitedEmail }) }, + ); + assert.strictEqual(res.status, 200); + + // Exactly one code mail, to the invited address. + assert.strictEqual(sent.length, 1); + assert.strictEqual(sent[0]?.to, invitedEmail.toLowerCase()); + }); + + it("does NOT send a code when the submitted address does not match the invited address", async () => { + const invitedEmail = `invitee-${randomUUID()}@example.com`; + const { inviteId } = await seedInvite(invitedEmail); + + const invitee = await seedUser(); + const authed = await ctx.asUser(invitee.discordUserId); + + const unrelatedEmail = `unrelated-${randomUUID()}@example.com`; + const res = await authed.fetch( + `/api/v1/workspace-invites/${inviteId}/verification`, + { method: "POST", body: JSON.stringify({ email: unrelatedEmail }) }, + ); + assert.strictEqual(res.status, 200); + + // No mail at all — the unrelated address never receives a code. + assert.strictEqual(sent.length, 0); + assert.ok(!sent.some((s) => s.to === unrelatedEmail.toLowerCase())); + }); + + it("returns an identical response for a match, a mismatch, and an unknown invite (no harvesting oracle)", async () => { + const invitedEmail = `invitee-${randomUUID()}@example.com`; + const { inviteId } = await seedInvite(invitedEmail); + + const invitee = await seedUser(); + const authed = await ctx.asUser(invitee.discordUserId); + + const matchRes = await authed.fetch( + `/api/v1/workspace-invites/${inviteId}/verification`, + { method: "POST", body: JSON.stringify({ email: invitedEmail }) }, + ); + const mismatchRes = await authed.fetch( + `/api/v1/workspace-invites/${inviteId}/verification`, + { + method: "POST", + body: JSON.stringify({ email: `nope-${randomUUID()}@example.com` }), + }, + ); + const unknownRes = await authed.fetch( + `/api/v1/workspace-invites/${new Types.ObjectId().toString()}/verification`, + { + method: "POST", + body: JSON.stringify({ email: `nope-${randomUUID()}@example.com` }), + }, + ); + + // Status and body must be byte-identical across all three: the response can + // never reveal whether the address matched or whether the invite exists. + assert.strictEqual(matchRes.status, 200); + assert.strictEqual(mismatchRes.status, 200); + assert.strictEqual(unknownRes.status, 200); + + const matchBody = await matchRes.text(); + const mismatchBody = await mismatchRes.text(); + const unknownBody = await unknownRes.text(); + assert.strictEqual(mismatchBody, matchBody); + assert.strictEqual(unknownBody, matchBody); + }); + + it("requires the workspaces feature flag", async () => { + const invitedEmail = `invitee-${randomUUID()}@example.com`; + const { inviteId } = await seedInvite(invitedEmail); + + // A user WITHOUT the workspaces feature flag. + const discordUserId = randomUUID(); + await ctx.container.userRepository.create({ discordUserId }); + const authed = await ctx.asUser(discordUserId); + + const res = await authed.fetch( + `/api/v1/workspace-invites/${inviteId}/verification`, + { method: "POST", body: JSON.stringify({ email: invitedEmail }) }, + ); + assert.notStrictEqual(res.status, 200); + assert.strictEqual(sent.length, 0); + }); +}); diff --git a/services/backend-api/test/api/workspace-members.test.ts b/services/backend-api/test/api/workspace-members.test.ts new file mode 100644 index 000000000..6efc93091 --- /dev/null +++ b/services/backend-api/test/api/workspace-members.test.ts @@ -0,0 +1,280 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert"; +import { randomUUID } from "node:crypto"; +import { Types } from "mongoose"; +import { + createAppTestContext, + type AppTestContext, +} from "../helpers/test-context"; + +async function readJson(res: Response): Promise { + return (await res.json()) as T; +} + +interface ErrorResult { + code: string; +} + +describe("Workspace members API", () => { + let ctx: AppTestContext; + + before(async () => { + ctx = await createAppTestContext(); + }); + + after(async () => { + await ctx.teardown(); + }); + + async function seedUser(): Promise<{ + discordUserId: string; + internalId: string; + }> { + const discordUserId = randomUUID(); + await ctx.container.userRepository.create({ + discordUserId, + email: `${discordUserId}@example.com`, + }); + + await ctx.connection + .collection("users") + .updateOne( + { discordUserId }, + { $set: { "featureFlags.workspaces": true } }, + ); + + const internalId = + await ctx.container.userRepository.findIdByDiscordId(discordUserId); + return { discordUserId, internalId: internalId as string }; + } + + async function seedWorkspace( + ownerUserId: string, + ): Promise<{ id: string; slug: string }> { + const slug = `ws-${randomUUID().slice(0, 8)}`; + const workspace = + await ctx.container.workspaceRepository.createWorkspaceWithOwner({ + name: "Test Workspace", + slug, + ownerUserId, + }); + return { id: workspace.id, slug: workspace.slug }; + } + + async function addMembership( + workspaceId: string, + userId: string, + role: "owner" | "admin", + ): Promise { + await ctx.connection.collection("workspacememberships").insertOne({ + workspaceId: new Types.ObjectId(workspaceId), + userId: new Types.ObjectId(userId), + role, + createdAt: new Date(), + updatedAt: new Date(), + }); + } + + interface MembersListResult { + result: Array<{ userId: string; role: string; discordUserId: string }>; + } + + it("exposes can() as a pure (action, role) function for removal and leaving", () => { + const { workspacesService } = ctx.container; + + assert.strictEqual(workspacesService.can("removeMember", "owner"), true); + assert.strictEqual(workspacesService.can("removeMember", "admin"), false); + assert.strictEqual(workspacesService.can("leaveWorkspace", "owner"), true); + assert.strictEqual(workspacesService.can("leaveWorkspace", "admin"), true); + }); + + it("lets an owner remove another member; the member is deleted and loses access", async () => { + const owner = await seedUser(); + const workspace = await seedWorkspace(owner.internalId); + + const member = await seedUser(); + await addMembership(workspace.id, member.internalId, "admin"); + + const ownerAuthed = await ctx.asUser(owner.discordUserId); + const removeRes = await ownerAuthed.fetch( + `/api/v1/workspaces/${workspace.slug}/members/${member.internalId}`, + { method: "DELETE" }, + ); + assert.strictEqual(removeRes.status, 200); + + const listRes = await ownerAuthed.fetch( + `/api/v1/workspaces/${workspace.slug}/members`, + ); + assert.strictEqual(listRes.status, 200); + const list = await readJson(listRes); + assert.ok( + !list.result.some((m) => m.userId === member.internalId), + "removed member should no longer appear in the members list", + ); + + const memberAuthed = await ctx.asUser(member.discordUserId); + const accessRes = await memberAuthed.fetch( + `/api/v1/workspaces/${workspace.slug}`, + ); + assert.strictEqual(accessRes.status, 404); + }); + + it("forbids an admin from removing another member", async () => { + const owner = await seedUser(); + const workspace = await seedWorkspace(owner.internalId); + + const admin = await seedUser(); + await addMembership(workspace.id, admin.internalId, "admin"); + + const target = await seedUser(); + await addMembership(workspace.id, target.internalId, "admin"); + + const adminAuthed = await ctx.asUser(admin.discordUserId); + const res = await adminAuthed.fetch( + `/api/v1/workspaces/${workspace.slug}/members/${target.internalId}`, + { method: "DELETE" }, + ); + assert.strictEqual(res.status, 403); + assert.strictEqual( + (await readJson(res)).code, + "WORKSPACE_INSUFFICIENT_ROLE", + ); + + // The target must still be a member. + const ownerAuthed = await ctx.asUser(owner.discordUserId); + const listRes = await ownerAuthed.fetch( + `/api/v1/workspaces/${workspace.slug}/members`, + ); + const list = await readJson(listRes); + assert.ok(list.result.some((m) => m.userId === target.internalId)); + }); + + it("lets a member leave via @me; their membership is gone", async () => { + const owner = await seedUser(); + const workspace = await seedWorkspace(owner.internalId); + + const admin = await seedUser(); + await addMembership(workspace.id, admin.internalId, "admin"); + + const adminAuthed = await ctx.asUser(admin.discordUserId); + const leaveRes = await adminAuthed.fetch( + `/api/v1/workspaces/${workspace.slug}/members/@me`, + { method: "DELETE" }, + ); + assert.strictEqual(leaveRes.status, 200); + + // The leaver loses access to the workspace. + const accessRes = await adminAuthed.fetch( + `/api/v1/workspaces/${workspace.slug}`, + ); + assert.strictEqual(accessRes.status, 404); + + const ownerAuthed = await ctx.asUser(owner.discordUserId); + const listRes = await ownerAuthed.fetch( + `/api/v1/workspaces/${workspace.slug}/members`, + ); + const list = await readJson(listRes); + assert.ok(!list.result.some((m) => m.userId === admin.internalId)); + }); + + it("rejects the sole owner of a populated workspace leaving; no auto-promotion", async () => { + const owner = await seedUser(); + const workspace = await seedWorkspace(owner.internalId); + + const admin = await seedUser(); + await addMembership(workspace.id, admin.internalId, "admin"); + + const ownerAuthed = await ctx.asUser(owner.discordUserId); + const res = await ownerAuthed.fetch( + `/api/v1/workspaces/${workspace.slug}/members/@me`, + { method: "DELETE" }, + ); + assert.strictEqual(res.status, 409); + assert.strictEqual( + (await readJson(res)).code, + "CANNOT_REMOVE_LAST_OWNER", + ); + + // The owner is still a member, and no member was promoted to owner. + const listRes = await ownerAuthed.fetch( + `/api/v1/workspaces/${workspace.slug}/members`, + ); + const list = await readJson(listRes); + const ownerEntry = list.result.find((m) => m.userId === owner.internalId); + assert.ok(ownerEntry, "owner should still be a member"); + assert.strictEqual(ownerEntry?.role, "owner"); + + const adminEntry = list.result.find((m) => m.userId === admin.internalId); + assert.strictEqual( + adminEntry?.role, + "admin", + "the other member should not have been auto-promoted to owner", + ); + assert.strictEqual( + list.result.filter((m) => m.role === "owner").length, + 1, + ); + }); + + it("rejects the sole owner of an empty workspace leaving; the workspace is not deleted", async () => { + const owner = await seedUser(); + const workspace = await seedWorkspace(owner.internalId); + + const ownerAuthed = await ctx.asUser(owner.discordUserId); + const res = await ownerAuthed.fetch( + `/api/v1/workspaces/${workspace.slug}/members/@me`, + { method: "DELETE" }, + ); + assert.strictEqual(res.status, 409); + assert.strictEqual( + (await readJson(res)).code, + "CANNOT_REMOVE_LAST_OWNER", + ); + + // Leaving never deletes a workspace: it still resolves for the owner. + const getRes = await ownerAuthed.fetch( + `/api/v1/workspaces/${workspace.slug}`, + ); + assert.strictEqual(getRes.status, 200); + }); + + it("lists members with their roles and a user identifier", async () => { + const owner = await seedUser(); + const workspace = await seedWorkspace(owner.internalId); + + const admin = await seedUser(); + await addMembership(workspace.id, admin.internalId, "admin"); + + const ownerAuthed = await ctx.asUser(owner.discordUserId); + const listRes = await ownerAuthed.fetch( + `/api/v1/workspaces/${workspace.slug}/members`, + ); + assert.strictEqual(listRes.status, 200); + const list = await readJson(listRes); + assert.strictEqual(list.result.length, 2); + + const ownerEntry = list.result.find((m) => m.userId === owner.internalId); + assert.strictEqual(ownerEntry?.role, "owner"); + assert.strictEqual(ownerEntry?.discordUserId, owner.discordUserId); + + const adminEntry = list.result.find((m) => m.userId === admin.internalId); + assert.strictEqual(adminEntry?.role, "admin"); + assert.strictEqual(adminEntry?.discordUserId, admin.discordUserId); + }); + + it("forbids a non-member from listing members", async () => { + const owner = await seedUser(); + const workspace = await seedWorkspace(owner.internalId); + + const outsider = await seedUser(); + const outsiderAuthed = await ctx.asUser(outsider.discordUserId); + const res = await outsiderAuthed.fetch( + `/api/v1/workspaces/${workspace.slug}/members`, + ); + assert.strictEqual(res.status, 404); + assert.strictEqual( + (await readJson(res)).code, + "WORKSPACE_NOT_FOUND", + ); + }); +}); diff --git a/services/backend-api/test/api/workspaces.test.ts b/services/backend-api/test/api/workspaces.test.ts new file mode 100644 index 000000000..c94ee8d3c --- /dev/null +++ b/services/backend-api/test/api/workspaces.test.ts @@ -0,0 +1,449 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert"; +import { randomUUID } from "node:crypto"; +import { Types } from "mongoose"; +import { + createAppTestContext, + type AppTestContext, +} from "../helpers/test-context"; + +async function readJson(res: Response): Promise { + return (await res.json()) as T; +} + +interface WorkspaceResult { + result: { id: string; name: string; slug: string; role?: string }; +} +interface WorkspaceListResult { + result: Array<{ id: string; name: string; slug: string; role: string }>; +} +interface ErrorResult { + code: string; +} + +async function seedWorkspaceUser( + ctx: AppTestContext, + discordUserId: string, + opts: { verified?: boolean; withFlag?: boolean } = {}, +): Promise { + await ctx.container.userRepository.create({ + discordUserId, + email: `${discordUserId}@example.com`, + }); + + const set: Record = {}; + if (opts.withFlag !== false) { + set["featureFlags.workspaces"] = true; + } + if (opts.verified !== false) { + set.verifiedEmail = `verified-${discordUserId}@example.com`; + set.verifiedEmailVerifiedAt = new Date(); + } + + if (Object.keys(set).length) { + await ctx.connection + .collection("users") + .updateOne({ discordUserId }, { $set: set }); + } + + const id = + await ctx.container.userRepository.findIdByDiscordId(discordUserId); + return id as string; +} + +describe("Workspaces API", { concurrency: true }, () => { + let ctx: AppTestContext; + + before(async () => { + ctx = await createAppTestContext(); + }); + + after(async () => { + await ctx.teardown(); + }); + + it("creates a workspace and lists it with the creator as owner", async () => { + const discordId = randomUUID(); + await seedWorkspaceUser(ctx, discordId); + const user = await ctx.asUser(discordId); + + const slug = `my-workspace-${discordId.slice(0, 8)}`; + const createRes = await user.fetch("/api/v1/workspaces", { + method: "POST", + body: JSON.stringify({ name: "My Workspace", slug }), + }); + assert.strictEqual(createRes.status, 201); + const created = await readJson(createRes); + assert.strictEqual(created.result.name, "My Workspace"); + assert.strictEqual(created.result.slug, slug); + assert.ok(created.result.id); + + const listRes = await user.fetch("/api/v1/workspaces"); + assert.strictEqual(listRes.status, 200); + const list = await readJson(listRes); + assert.strictEqual(list.result.length, 1); + assert.strictEqual(list.result[0]?.slug, slug); + assert.strictEqual(list.result[0]?.role, "owner"); + }); + + it("rejects workspace creation without a verified email", async () => { + const discordId = randomUUID(); + await seedWorkspaceUser(ctx, discordId, { verified: false }); + const user = await ctx.asUser(discordId); + + const res = await user.fetch("/api/v1/workspaces", { + method: "POST", + body: JSON.stringify({ name: "X", slug: "valid-slug" }), + }); + assert.strictEqual(res.status, 403); + assert.strictEqual( + (await readJson(res)).code, + "EMAIL_NOT_VERIFIED", + ); + }); + + it("returns 404 when the user lacks the workspaces feature flag", async () => { + const discordId = randomUUID(); + await seedWorkspaceUser(ctx, discordId, { withFlag: false }); + const user = await ctx.asUser(discordId); + + const res = await user.fetch("/api/v1/workspaces"); + assert.strictEqual(res.status, 404); + }); + + it("returns 400 for an invalid slug format", async () => { + const discordId = randomUUID(); + await seedWorkspaceUser(ctx, discordId); + const user = await ctx.asUser(discordId); + + for (const badSlug of [ + "-starts-with-hyphen", + "ends-with-hyphen-", + "double--hyphen", + "HAS_CAPITALS", + "x", + ]) { + const res = await user.fetch("/api/v1/workspaces", { + method: "POST", + body: JSON.stringify({ name: "Test", slug: badSlug }), + }); + assert.strictEqual(res.status, 400, `Expected 400 for slug: ${badSlug}`); + } + }); + + it("rejects reserved slugs", async () => { + const discordId = randomUUID(); + await seedWorkspaceUser(ctx, discordId); + const user = await ctx.asUser(discordId); + + // Reserved slugs pass the format regex but are rejected by the denylist, + // surfacing as a 409 WORKSPACE_SLUG_RESERVED so the client can show a + // friendly slug-field error. + for (const reserved of ["new", "settings", "api", "workspaces"]) { + const res = await user.fetch("/api/v1/workspaces", { + method: "POST", + body: JSON.stringify({ name: "Test", slug: reserved }), + }); + assert.strictEqual( + res.status, + 409, + `Expected 409 for reserved slug: ${reserved}`, + ); + assert.strictEqual( + (await readJson(res)).code, + "WORKSPACE_SLUG_RESERVED", + `Expected WORKSPACE_SLUG_RESERVED for reserved slug: ${reserved}`, + ); + } + }); + + it("rejects a duplicate slug with 409 WORKSPACE_SLUG_TAKEN", async () => { + const id1 = randomUUID(); + const id2 = randomUUID(); + await seedWorkspaceUser(ctx, id1); + await seedWorkspaceUser(ctx, id2); + const user1 = await ctx.asUser(id1); + const user2 = await ctx.asUser(id2); + + const slug = `shared-slug-${id1.slice(0, 8)}`; + + const first = await user1.fetch("/api/v1/workspaces", { + method: "POST", + body: JSON.stringify({ name: "First", slug }), + }); + assert.strictEqual(first.status, 201); + + const second = await user2.fetch("/api/v1/workspaces", { + method: "POST", + body: JSON.stringify({ name: "Second", slug }), + }); + assert.strictEqual(second.status, 409); + assert.strictEqual( + (await readJson(second)).code, + "WORKSPACE_SLUG_TAKEN", + ); + }); + + // The HTTP test above is caught by the service's pre-check. This drives the + // repository directly to exercise the unique-index race path that the + // pre-check cannot cover, asserting it surfaces as WorkspaceSlugTakenError + // (which the service maps to 409) rather than a raw Mongo 11000 (500). + it("maps a unique-index slug collision to WorkspaceSlugTakenError", async () => { + const ownerId = new Types.ObjectId().toHexString(); + const slug = `race-slug-${randomUUID().slice(0, 8)}`; + + await ctx.container.workspaceRepository.createWorkspaceWithOwner({ + name: "First", + slug, + ownerUserId: ownerId, + }); + + await assert.rejects( + () => + ctx.container.workspaceRepository.createWorkspaceWithOwner({ + name: "Second", + slug, + ownerUserId: new Types.ObjectId().toHexString(), + }), + (err: Error) => err.name === "WorkspaceSlugTakenError", + ); + }); + + it("maps a unique-index slug collision on rename to WorkspaceSlugTakenError", async () => { + const ownerId = new Types.ObjectId().toHexString(); + const takenSlug = `taken-${randomUUID().slice(0, 8)}`; + const otherSlug = `other-${randomUUID().slice(0, 8)}`; + + await ctx.container.workspaceRepository.createWorkspaceWithOwner({ + name: "Taken", + slug: takenSlug, + ownerUserId: ownerId, + }); + const toRename = + await ctx.container.workspaceRepository.createWorkspaceWithOwner({ + name: "ToRename", + slug: otherSlug, + ownerUserId: new Types.ObjectId().toHexString(), + }); + + await assert.rejects( + () => + ctx.container.workspaceRepository.updateSlug(toRename.id, takenSlug), + (err: Error) => err.name === "WorkspaceSlugTakenError", + ); + }); + + it("scopes access to members; both owner and admin members can rename", async () => { + const ownerId = randomUUID(); + await seedWorkspaceUser(ctx, ownerId); + const owner = await ctx.asUser(ownerId); + + const slug = `scope-test-${ownerId.slice(0, 8)}`; + const created = await readJson( + await owner.fetch("/api/v1/workspaces", { + method: "POST", + body: JSON.stringify({ name: "T", slug }), + }), + ); + const workspaceSlug = created.result.slug; + + const getOwner = await owner.fetch(`/api/v1/workspaces/${workspaceSlug}`); + assert.strictEqual(getOwner.status, 200); + assert.strictEqual( + (await readJson(getOwner)).result.role, + "owner", + ); + + const outsiderId = randomUUID(); + await seedWorkspaceUser(ctx, outsiderId); + const outsider = await ctx.asUser(outsiderId); + const getOutsider = await outsider.fetch( + `/api/v1/workspaces/${workspaceSlug}`, + ); + assert.strictEqual(getOutsider.status, 404); + + const ownerRename = await owner.fetch( + `/api/v1/workspaces/${workspaceSlug}`, + { + method: "PATCH", + body: JSON.stringify({ name: "T2" }), + }, + ); + assert.strictEqual(ownerRename.status, 200); + assert.strictEqual( + (await readJson(ownerRename)).result.name, + "T2", + ); + + // A non-owner admin member can also change settings (no read-only tier). + const adminId = randomUUID(); + const adminInternalId = await seedWorkspaceUser(ctx, adminId); + await ctx.connection.collection("workspacememberships").insertOne({ + workspaceId: new Types.ObjectId(created.result.id), + userId: new Types.ObjectId(adminInternalId), + role: "admin", + createdAt: new Date(), + updatedAt: new Date(), + }); + const adminMember = await ctx.asUser(adminId); + + const getAdmin = await adminMember.fetch( + `/api/v1/workspaces/${workspaceSlug}`, + ); + assert.strictEqual(getAdmin.status, 200); + assert.strictEqual( + (await readJson(getAdmin)).result.role, + "admin", + ); + + const adminRename = await adminMember.fetch( + `/api/v1/workspaces/${workspaceSlug}`, + { + method: "PATCH", + body: JSON.stringify({ name: "T3" }), + }, + ); + assert.strictEqual(adminRename.status, 200); + assert.strictEqual( + (await readJson(adminRename)).result.name, + "T3", + ); + }); + + it("returns 404 for an unknown slug", async () => { + const discordId = randomUUID(); + await seedWorkspaceUser(ctx, discordId); + const user = await ctx.asUser(discordId); + + const res = await user.fetch( + "/api/v1/workspaces/slug-that-does-not-exist", + ); + assert.strictEqual(res.status, 404); + assert.strictEqual( + (await readJson(res)).code, + "WORKSPACE_NOT_FOUND", + ); + }); + + it("owner can update slug; old slug returns 404 and new slug returns 200", async () => { + const discordId = randomUUID(); + await seedWorkspaceUser(ctx, discordId); + const user = await ctx.asUser(discordId); + + const oldSlug = `old-slug-${discordId.slice(0, 8)}`; + const newSlug = `new-slug-${discordId.slice(0, 8)}`; + + await user.fetch("/api/v1/workspaces", { + method: "POST", + body: JSON.stringify({ name: "Slug Rename", slug: oldSlug }), + }); + + const renameRes = await user.fetch(`/api/v1/workspaces/${oldSlug}`, { + method: "PATCH", + body: JSON.stringify({ slug: newSlug }), + }); + assert.strictEqual(renameRes.status, 200); + const renamed = await readJson(renameRes); + assert.strictEqual(renamed.result.slug, newSlug); + + const oldRes = await user.fetch(`/api/v1/workspaces/${oldSlug}`); + assert.strictEqual(oldRes.status, 404); + + const newRes = await user.fetch(`/api/v1/workspaces/${newSlug}`); + assert.strictEqual(newRes.status, 200); + }); + + it("returns 409 WORKSPACE_SLUG_TAKEN when patching to an existing slug", async () => { + const id1 = randomUUID(); + const id2 = randomUUID(); + await seedWorkspaceUser(ctx, id1); + await seedWorkspaceUser(ctx, id2); + const user1 = await ctx.asUser(id1); + const user2 = await ctx.asUser(id2); + + const slug1 = `patch-slug1-${id1.slice(0, 8)}`; + const slug2 = `patch-slug2-${id1.slice(0, 8)}`; + + await user1.fetch("/api/v1/workspaces", { + method: "POST", + body: JSON.stringify({ name: "Workspace1", slug: slug1 }), + }); + await user2.fetch("/api/v1/workspaces", { + method: "POST", + body: JSON.stringify({ name: "Workspace2", slug: slug2 }), + }); + + const res = await user2.fetch(`/api/v1/workspaces/${slug2}`, { + method: "PATCH", + body: JSON.stringify({ slug: slug1 }), + }); + assert.strictEqual(res.status, 409); + assert.strictEqual( + (await readJson(res)).code, + "WORKSPACE_SLUG_TAKEN", + ); + }); + + it("returns 404 when a non-member tries to rename a workspace", async () => { + const ownerId = randomUUID(); + await seedWorkspaceUser(ctx, ownerId); + const owner = await ctx.asUser(ownerId); + + const slug = `outsider-test-${ownerId.slice(0, 8)}`; + await owner.fetch("/api/v1/workspaces", { + method: "POST", + body: JSON.stringify({ name: "T", slug }), + }); + + const outsiderId = randomUUID(); + await seedWorkspaceUser(ctx, outsiderId); + const outsider = await ctx.asUser(outsiderId); + const res = await outsider.fetch(`/api/v1/workspaces/${slug}`, { + method: "PATCH", + body: JSON.stringify({ name: "hijack" }), + }); + assert.strictEqual(res.status, 404); + assert.strictEqual( + (await readJson(res)).code, + "WORKSPACE_NOT_FOUND", + ); + }); + + it("rejects an oversized workspace name with 400", async () => { + const discordId = randomUUID(); + await seedWorkspaceUser(ctx, discordId); + const user = await ctx.asUser(discordId); + + const res = await user.fetch("/api/v1/workspaces", { + method: "POST", + body: JSON.stringify({ name: "x".repeat(101), slug: "valid-slug" }), + }); + assert.strictEqual(res.status, 400); + }); + + it("404s workspace + email-verification routes when the user lacks the feature flag", async () => { + const discordId = randomUUID(); + await seedWorkspaceUser(ctx, discordId, { withFlag: false }); + const user = await ctx.asUser(discordId); + + assert.strictEqual((await user.fetch("/api/v1/workspaces")).status, 404); + assert.strictEqual( + ( + await user.fetch("/api/v1/workspaces", { + method: "POST", + body: JSON.stringify({ name: "X", slug: "valid-slug" }), + }) + ).status, + 404, + ); + assert.strictEqual( + ( + await user.fetch("/api/v1/users/@me/email-verification", { + method: "POST", + body: JSON.stringify({ email: "a@b.com" }), + }) + ).status, + 404, + ); + }); +}); diff --git a/services/backend-api/test/helpers/test-context.ts b/services/backend-api/test/helpers/test-context.ts index bff7ea11d..81802e842 100644 --- a/services/backend-api/test/helpers/test-context.ts +++ b/services/backend-api/test/helpers/test-context.ts @@ -103,6 +103,7 @@ function createTestConfig(overrides?: Partial): Config { BACKEND_API_DEFAULT_REFRESH_RATE_MINUTES: 10, BACKEND_API_DEFAULT_MAX_FEEDS: 5, BACKEND_API_DEFAULT_MAX_USER_FEEDS: 5, + BACKEND_API_DEFAULT_MAX_WORKSPACE_FEEDS: 140, BACKEND_API_DEFAULT_DATE_FORMAT: "ddd, D MMMM YYYY, h:mm A z", BACKEND_API_DEFAULT_TIMEZONE: "UTC", BACKEND_API_DEFAULT_DATE_LANGUAGE: "en", @@ -125,6 +126,9 @@ function createTestConfig(overrides?: Partial): Config { BACKEND_API_SMTP_USERNAME: undefined, BACKEND_API_SMTP_PASSWORD: undefined, BACKEND_API_SMTP_FROM: undefined, + BACKEND_API_SMTP_FROM_DOMAIN: undefined, + BACKEND_API_SMTP_PORT: undefined, + BACKEND_API_SMTP_SECURE: true, BACKEND_API_PADDLE_KEY: undefined, BACKEND_API_PADDLE_URL: undefined, @@ -167,34 +171,56 @@ export async function createAppTestContext( const connection = await setupDatabase(); const discordMockServer = createTestHttpServer({ pathPrefix: "/api/v10" }); - const mockApiOverrides: Partial = {}; + // If construction throws after this point (e.g. createContainer/createApp), + // the connection and mock server are already open with no teardown path — + // their live handles keep Node's event loop alive, so the runner hangs + // instead of surfacing the setup error. Close them before rethrowing so the + // failure is fast and loud. const mockApis = options.mockApis ?? {}; - for (const api of Object.values(mockApis)) { - (mockApiOverrides as Record)[api.configKey] = - api.server.host; + let app: FastifyInstance; + let container: Container; + try { + const mockApiOverrides: Partial = {}; + + for (const api of Object.values(mockApis)) { + (mockApiOverrides as Record)[api.configKey] = + api.server.host; + } + + const config = createTestConfig({ + BACKEND_API_DISCORD_API_BASE_URL: discordMockServer.host, + ...mockApiOverrides, + ...options.configOverrides, + }); + + container = createContainer({ + config, + mongoConnection: connection, + rabbitmq: createMockRabbitConnection(), + }); + + app = await createApp(container); + + // Build all registered models' indexes before any test runs. Production + // connects with autoIndex:true, but autoIndex builds lazily and unawaited; + // tests that assert on unique-index enforcement (e.g. slug collisions) can + // otherwise race a not-yet-built index when many models register at once. + await Promise.all( + Object.values(connection.models).map((model) => model.syncIndexes()), + ); + + if (options.beforeListen) { + await options.beforeListen(app); + } + + await app.listen({ port: 0 }); + } catch (err) { + await discordMockServer.stop().catch(() => {}); + await connection.close().catch(() => {}); + throw err; } - const config = createTestConfig({ - BACKEND_API_DISCORD_API_BASE_URL: discordMockServer.host, - ...mockApiOverrides, - ...options.configOverrides, - }); - - const container = createContainer({ - config, - mongoConnection: connection, - rabbitmq: createMockRabbitConnection(), - }); - - const app = await createApp(container); - - if (options.beforeListen) { - await options.beforeListen(app); - } - - await app.listen({ port: 0 }); - const address = app.server.address(); const port = typeof address === "object" ? address?.port : 0; const baseUrl = `http://localhost:${port}`; diff --git a/services/backend-api/test/helpers/test-http-server.ts b/services/backend-api/test/helpers/test-http-server.ts index 7925f2894..a92fb0ab4 100644 --- a/services/backend-api/test/helpers/test-http-server.ts +++ b/services/backend-api/test/helpers/test-http-server.ts @@ -133,6 +133,10 @@ export function createTestHttpServer( }); server.listen(0); + // Don't let the listening socket alone keep Node's event loop alive: if a + // test setup throws before teardown is wired up, the runner should still be + // able to exit rather than hang on this handle. + server.unref(); const address = server.address(); if (!address || typeof address === "string") { @@ -232,6 +236,7 @@ function createTestConfig( BACKEND_API_DEFAULT_REFRESH_RATE_MINUTES: 10, BACKEND_API_DEFAULT_MAX_FEEDS: 5, BACKEND_API_DEFAULT_MAX_USER_FEEDS: 5, + BACKEND_API_DEFAULT_MAX_WORKSPACE_FEEDS: 140, BACKEND_API_DEFAULT_DATE_FORMAT: "ddd, D MMMM YYYY, h:mm A z", BACKEND_API_DEFAULT_TIMEZONE: "UTC", BACKEND_API_DEFAULT_DATE_LANGUAGE: "en", @@ -250,6 +255,9 @@ function createTestConfig( BACKEND_API_SMTP_USERNAME: undefined, BACKEND_API_SMTP_PASSWORD: undefined, BACKEND_API_SMTP_FROM: undefined, + BACKEND_API_SMTP_FROM_DOMAIN: undefined, + BACKEND_API_SMTP_PORT: undefined, + BACKEND_API_SMTP_SECURE: true, BACKEND_API_PADDLE_KEY: undefined, BACKEND_API_PADDLE_URL: undefined, BACKEND_API_PADDLE_WEBHOOK_SECRET: undefined, diff --git a/services/backend-api/test/helpers/user-feed-management-invites.harness.ts b/services/backend-api/test/helpers/user-feed-management-invites.harness.ts index 0613108f8..b22b884d2 100644 --- a/services/backend-api/test/helpers/user-feed-management-invites.harness.ts +++ b/services/backend-api/test/helpers/user-feed-management-invites.harness.ts @@ -37,7 +37,11 @@ export interface TestContext { discordUserId: string; service: UserFeedManagementInvitesService; repository: UserFeedMongooseRepository; - createFeed(overrides?: { title?: string; url?: string }): Promise; + createFeed(overrides?: { + title?: string; + url?: string; + workspaceId?: string; + }): Promise; createFeedForUser( discordUserId: string, overrides?: { title?: string; url?: string }, @@ -115,6 +119,7 @@ export function createUserFeedManagementInvitesHarness(): UserFeedManagementInvi title: overrides.title ?? "Test Feed", url: overrides.url ?? `https://example.com/${generateTestId()}.xml`, user: { id: generateTestId(), discordUserId }, + workspaceId: overrides.workspaceId, }); }, diff --git a/services/backend-api/test/helpers/user-feeds.harness.ts b/services/backend-api/test/helpers/user-feeds.harness.ts index 359633cb0..5ee392b98 100644 --- a/services/backend-api/test/helpers/user-feeds.harness.ts +++ b/services/backend-api/test/helpers/user-feeds.harness.ts @@ -1,4 +1,5 @@ import type { Connection } from "mongoose"; +import type { Config } from "../../src/config"; import type { IUserFeed, WebhookEnforcementTarget, @@ -6,6 +7,9 @@ import type { import type { IDiscordChannelConnection } from "../../src/repositories/interfaces/feed-connection.types"; import { UserFeedMongooseRepository } from "../../src/repositories/mongoose/user-feed.mongoose.repository"; import { UserMongooseRepository } from "../../src/repositories/mongoose/user.mongoose.repository"; +import { WorkspaceMongooseRepository } from "../../src/repositories/mongoose/workspace.mongoose.repository"; +import { WorkspacesService } from "../../src/features/workspaces/workspaces.service"; +import type { EmailVerificationService } from "../../src/features/users/email-verification.service"; import { UserFeedDisabledCode, UserFeedManagerStatus, @@ -103,6 +107,7 @@ export function createUserFeedsHarness(): UserFeedsHarness { let testContext: ServiceTestContext; let userFeedRepository: UserFeedMongooseRepository; let userRepository: UserMongooseRepository; + let workspacesService: WorkspacesService; return { async setup() { @@ -111,6 +116,15 @@ export function createUserFeedsHarness(): UserFeedsHarness { testContext.connection, ); userRepository = new UserMongooseRepository(testContext.connection); + workspacesService = new WorkspacesService({ + // Invitations are not exercised by this feed-authorization harness, so a + // minimal config and no transport suffice. + config: {} as Config, + smtpTransport: null, + workspaceRepository: new WorkspaceMongooseRepository(testContext.connection), + userRepository, + emailVerificationService: {} as EmailVerificationService, + }); }, async teardown() { @@ -155,6 +169,7 @@ export function createUserFeedsHarness(): UserFeedsHarness { ), feedHandlerService: createMockFeedHandlerService(options.feedHandler), usersService: createMockUsersService(userId, discordUserId), + workspacesService, publishMessage: options.publishMessage ?? (async () => {}), feedConnectionsDiscordChannelsService, }; diff --git a/services/backend-api/test/infra/email-from.test.ts b/services/backend-api/test/infra/email-from.test.ts new file mode 100644 index 000000000..37e06f554 --- /dev/null +++ b/services/backend-api/test/infra/email-from.test.ts @@ -0,0 +1,56 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { createFromFormatter } from "../../src/infra/email-from"; +import type { Config } from "../../src/config"; + +function makeConfig(overrides: Partial = {}): Config { + return { + BACKEND_API_SMTP_FROM: undefined, + BACKEND_API_SMTP_FROM_DOMAIN: undefined, + ...overrides, + } as Config; +} + +describe("createFromFormatter", () => { + it("uses the override verbatim for every purpose when BACKEND_API_SMTP_FROM is set", () => { + const formatFrom = createFromFormatter( + makeConfig({ + BACKEND_API_SMTP_FROM: '"Only Sender" ', + BACKEND_API_SMTP_FROM_DOMAIN: "ignored.com", + }), + ); + + assert.strictEqual( + formatFrom("MonitoRSS Alerts", "alerts"), + '"Only Sender" ', + ); + assert.strictEqual( + formatFrom("MonitoRSS", "noreply"), + '"Only Sender" ', + ); + }); + + it("uses per-purpose senders on the configured domain", () => { + const formatFrom = createFromFormatter( + makeConfig({ BACKEND_API_SMTP_FROM_DOMAIN: "mydomain.com" }), + ); + + assert.strictEqual( + formatFrom("MonitoRSS Alerts", "alerts"), + '"MonitoRSS Alerts" ', + ); + assert.strictEqual( + formatFrom("MonitoRSS", "noreply"), + '"MonitoRSS" ', + ); + }); + + it("throws when invoked with neither override nor domain configured", () => { + const formatFrom = createFromFormatter(makeConfig()); + + assert.throws( + () => formatFrom("MonitoRSS Alerts", "alerts"), + /BACKEND_API_SMTP_FROM/, + ); + }); +}); diff --git a/services/backend-api/test/services/notifications.service.test.ts b/services/backend-api/test/services/notifications.service.test.ts index fb6c9bc96..58f0b0fac 100644 --- a/services/backend-api/test/services/notifications.service.test.ts +++ b/services/backend-api/test/services/notifications.service.test.ts @@ -203,9 +203,12 @@ describe("NotificationsService", { concurrency: true }, () => { assert.ok(sendMailCall.html.includes("Exceeded feed limit")); }); - it("uses default from address when not configured", async () => { + it("derives the alerts sender from BACKEND_API_SMTP_FROM_DOMAIN when no full SMTP_FROM is set", async () => { const ctx = harness.createContext({ - config: { BACKEND_API_SMTP_FROM: undefined }, + config: { + BACKEND_API_SMTP_FROM: undefined, + BACKEND_API_SMTP_FROM_DOMAIN: "test.com", + }, }); await ctx.service.sendDisabledFeedsAlert(["feed-id"], { @@ -216,7 +219,7 @@ describe("NotificationsService", { concurrency: true }, () => { ?.arguments[0] as { from: string }; assert.strictEqual( sendMailCall.from, - '"MonitoRSS Alerts" ', + '"MonitoRSS Alerts" ', ); }); diff --git a/services/backend-api/test/services/user-feed-management-invites.service.test.ts b/services/backend-api/test/services/user-feed-management-invites.service.test.ts index 11a44a064..d9f244588 100644 --- a/services/backend-api/test/services/user-feed-management-invites.service.test.ts +++ b/services/backend-api/test/services/user-feed-management-invites.service.test.ts @@ -8,6 +8,7 @@ import { FeedLimitReachedException } from "../../src/shared/exceptions/user-feed import { UserManagerAlreadyInvitedException, UserFeedTransferRequestExistsException, + WorkspaceFeedSharingDisabledException, } from "../../src/shared/exceptions/user-feed-management-invites.exceptions"; import { createUserFeedManagementInvitesHarness } from "../helpers/user-feed-management-invites.harness"; @@ -61,6 +62,25 @@ describe("UserFeedManagementInvitesService", { concurrency: true }, () => { ); }); + it("throws WorkspaceFeedSharingDisabledException for workspace feeds", async () => { + const ctx = harness.createContext(); + const feed = await ctx.createFeed({ workspaceId: ctx.generateId() }); + const targetDiscordUserId = ctx.generateId(); + + await assert.rejects( + () => + ctx.service.createInvite({ + feed, + targetDiscordUserId, + type: UserFeedManagerInviteType.CoManage, + }), + WorkspaceFeedSharingDisabledException, + ); + + const updatedFeed = await ctx.findById(feed.id); + assert.ok(!updatedFeed?.shareManageOptions?.invites?.length); + }); + it("throws UserManagerAlreadyInvitedException when inviting self", async () => { const ctx = harness.createContext(); const feed = await ctx.createFeed({}); From 19a6a93151128a32ebfc924c239a5b1d333bec2d Mon Sep 17 00:00:00 2001 From: gintil Date: Sun, 7 Jun 2026 18:44:26 -0400 Subject: [PATCH 02/13] Fix tests --- e2e/fixtures/test-fixtures.ts | 44 +++++++++++++- .../workspace-invitation-roundtrip.spec.ts | 60 +++++++++++++++---- 2 files changed, 92 insertions(+), 12 deletions(-) diff --git a/e2e/fixtures/test-fixtures.ts b/e2e/fixtures/test-fixtures.ts index e605376ac..2c467b50f 100644 --- a/e2e/fixtures/test-fixtures.ts +++ b/e2e/fixtures/test-fixtures.ts @@ -6,6 +6,7 @@ import { type Response, type BrowserContext, type Browser, + type TestInfo, } from "@playwright/test"; import { createFeed, @@ -68,6 +69,45 @@ async function createMockSessionCookies(): Promise { ]; } +// A timed-out `toBeVisible` on an authenticated page is usually a symptom: the +// real cause (an auth/session failure, an unhandled fetch error) only ever +// reached the browser console, which Playwright does not echo into the report. +// This promotes those signals to test annotations so the next failure is legible +// from the report alone, without downloading and parsing a trace. Attaching at the +// context level covers every page the context opens — including ones created after +// this call — so callers never wire individual pages. +function surfaceBrowserErrors(context: BrowserContext, testInfo: TestInfo): void { + const note = (kind: string, detail: string) => { + testInfo.annotations.push({ type: kind, description: detail }); + }; + + context.on("console", (msg) => { + if (msg.type() === "error" && /unauthorized|401|ApiAdapterError/i.test(msg.text())) { + note("browser-auth-error", msg.text().slice(0, 300)); + } + }); + context.on("weberror", (err) => note("browser-page-error", err.error().message.slice(0, 300))); + context.on("response", (res) => { + const url = res.url(); + if (res.status() === 401 && url.includes("/api/v1/")) { + note("http-401", `401 on ${url.replace(/^https?:\/\/[^/]+/, "")}`); + } + }); +} + +// Creates a fresh browser context with browser-error surfacing already attached. +// Both the default `page` fixture and any spec that needs its own context (e.g. a +// second logged-out actor) go through this, so error visibility is uniform and no +// call site touches the listener wiring directly. +export async function newInstrumentedContext( + browser: Browser, + testInfo: TestInfo, +): Promise { + const context = await browser.newContext(); + surfaceBrowserErrors(context, testInfo); + return context; +} + type TestFixtures = { testFeed: Feed; testFeedWithConnection: FeedWithConnection; @@ -104,9 +144,9 @@ export const test = base.extend({ { scope: "worker" }, ], - page: async ({ browser }, use) => { + page: async ({ browser }, use, testInfo) => { const cookies = await createMockSessionCookies(); - const context = await browser.newContext(); + const context = await newInstrumentedContext(browser, testInfo); await context.addCookies(cookies); const page = await context.newPage(); await use(page); diff --git a/e2e/tests/workspaces/workspace-invitation-roundtrip.spec.ts b/e2e/tests/workspaces/workspace-invitation-roundtrip.spec.ts index 6db5efec1..10c5f7fc9 100644 --- a/e2e/tests/workspaces/workspace-invitation-roundtrip.spec.ts +++ b/e2e/tests/workspaces/workspace-invitation-roundtrip.spec.ts @@ -1,4 +1,4 @@ -import { test, expect, type Page } from "../../fixtures/test-fixtures"; +import { test, expect, type Page, newInstrumentedContext } from "../../fixtures/test-fixtures"; import type { Browser } from "@playwright/test"; import { getDiscordUserIdFromPage } from "../../helpers/paddle-db"; import { @@ -21,10 +21,12 @@ import { // member. Every assertion goes through the rendered UI; the only thing read out // of band is the email the invitee would have received (via the mock mailer). -async function waitForAuthenticatedApp(page: Page): Promise { - await expect(page.getByRole("button", { name: "Account settings" })).toBeVisible({ - timeout: 20000, - }); +async function waitForAuthenticatedApp(page: Page, context = "app"): Promise { + await expect( + page.getByRole("button", { name: "Account settings" }), + `${context}: authenticated shell never rendered (the "Account settings" button is ` + + `absent, usually because the session/auth check failed)`, + ).toBeVisible({ timeout: 20000 }); } async function gotoMembers(page: Page, workspaceName: string): Promise { @@ -41,26 +43,64 @@ async function gotoMembers(page: Page, workspaceName: string): Promise { // bootstraps via Discord OAuth, enrols in the feature, verifies the invited // email via the real one-time-code flow, accepts, and confirms they can see the // workspace they joined in their own switcher. +// Opening the invite link logged-out kicks off a multi-redirect OAuth bootstrap: +// `RequireAuth` sees the 401, sets `window.location.href` to `/discord/login-v2`, +// which round-trips through the mock authorize and `callback-v2` (the backend +// validates an OAuth `state` it stored in the session cookie) before the app +// re-renders authenticated. That client-side redirect needs a beat to fire, so we +// must WAIT for the authenticated shell after each open rather than re-navigating +// in a tight loop (re-`goto`ing immediately stomps the redirect before it runs). +// On the rare run where the session is lost mid-chain we re-open the link, and if +// it never authenticates we fail with a legible message instead of a bare timeout. +async function bootstrapInviteeSession(page: Page, inviteLink: string): Promise { + const attempts = 3; + + for (let attempt = 1; attempt <= attempts; attempt += 1) { + await page.goto(inviteLink); + + try { + // Give the RequireAuth redirect + OAuth chain room to complete; on success + // the authenticated shell renders and @me resolves. + await waitForAuthenticatedApp(page, `invitee OAuth bootstrap (attempt ${attempt})`); + const res = await page.request.get("/api/v1/discord-users/@me"); + + if (res.status() === 200) { + const { id } = (await res.json()) as { id: string }; + return id; + } + } catch { + // Fall through to retry: a lost session mid-chain leaves the logged-out + // shell, which never renders "Account settings". + } + } + + throw new Error( + `Invitee OAuth bootstrap never authenticated after ${attempts} attempts: the logged-out ` + + `OAuth redirect chain (login-v2 -> authorize -> callback-v2) failed to establish a session ` + + `(/discord-users/@me kept returning 401). See attached browser-auth-error/http-401 annotations.`, + ); +} + async function inviteeAcceptsViaLink( browser: Browser, inviteLink: string, invitedEmail: string, workspaceName: string, ): Promise { - const context = await browser.newContext(); + const context = await newInstrumentedContext(browser, test.info()); const page = await context.newPage(); try { // Open the invitation while logged out -> OAuth bootstrap (a brand-new user). - await page.goto(inviteLink); - await waitForAuthenticatedApp(page); + // This retries the redirect chain and fails fast with a clear message if the + // session never lands, instead of hanging on the authenticated-UI assertion. + const discordUserId = await bootstrapInviteeSession(page, inviteLink); // The new user lacks the per-user workspaces flag, so the invite endpoints // 404 until it's enabled; enable it for the minted user and reload the link. - const discordUserId = await getDiscordUserIdFromPage(page); await enableWorkspacesFeatureInDb(discordUserId); await page.goto(inviteLink); - await waitForAuthenticatedApp(page); + await waitForAuthenticatedApp(page, "invitee after enabling workspaces flag"); // Verify the invited email through the real one-time-code flow. await expect(page.getByRole("button", { name: /send code/i })).toBeVisible({ From 908bdfc575d78b1c7b3183ffcf53f3275f701d79 Mon Sep 17 00:00:00 2001 From: gintil Date: Sun, 7 Jun 2026 18:55:08 -0400 Subject: [PATCH 03/13] Upload combined logs --- .github/workflows/e2e.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 6a2863071..9f9fb6b1c 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -47,6 +47,7 @@ jobs: path: | e2e/playwright-report/ e2e/test-results/ + e2e/logs/ retention-days: 30 e2e-paddle: @@ -84,4 +85,5 @@ jobs: path: | e2e/playwright-report/ e2e/test-results/ + e2e/logs/ retention-days: 30 From 27dee0345c1d135bf2c1a7470ecc3b4ee9f38b84 Mon Sep 17 00:00:00 2001 From: gintil Date: Sun, 7 Jun 2026 19:09:20 -0400 Subject: [PATCH 04/13] Extend timeout for workspace invitation round-trip E2E test The round-trip spec drives two real browser sessions through a multi-redirect OAuth bootstrap, the one-time-code email verification, and accept in a single test. That exceeds Playwright's 30s default budget, so the invitee bootstrap's authenticated-shell wait would blow the timeout and Playwright would tear down the context mid-goto ("Target page ... has been closed"). Mark it test.slow() to match the sibling self-accept-guard spec, which does the same two-session dance. --- e2e/tests/workspaces/workspace-invitation-roundtrip.spec.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/e2e/tests/workspaces/workspace-invitation-roundtrip.spec.ts b/e2e/tests/workspaces/workspace-invitation-roundtrip.spec.ts index 10c5f7fc9..e33c47acf 100644 --- a/e2e/tests/workspaces/workspace-invitation-roundtrip.spec.ts +++ b/e2e/tests/workspaces/workspace-invitation-roundtrip.spec.ts @@ -140,6 +140,12 @@ test.describe("Workspace invitations (inviter -> invitee round-trip)", () => { page, browser, }) => { + // Two real sessions (owner + invitee), a multi-redirect OAuth bootstrap, the + // real one-time-code email verification, and accept — far more than the 30s + // default budget. Give it room rather than racing the budget (the bootstrap's + // 20s authenticated-shell wait alone can blow 30s and tear the context down). + test.slow(); + // --- Session A: the owner sets up a workspace and invites by email. --- await page.goto("/feeds"); await waitForAuthenticatedApp(page); From d2125ac0f2b76abc4ee98818090b78d538d1402e Mon Sep 17 00:00:00 2001 From: gintil Date: Tue, 9 Jun 2026 19:17:39 -0400 Subject: [PATCH 05/13] Fix post-merge fallout: stale 'getStandardErrorCodeMessage copy' imports, typed invite-claim response body --- .../BrowseFeedsModal/BrowseFeedsModal.redditRetry.test.tsx | 2 +- .../EditUserFeedDialog/EditUserFeedDialog.redditGate.test.tsx | 2 +- .../src/features/feed/components/EditUserFeedDialog/index.tsx | 2 +- services/backend-api/test/api/workspace-invite-claim.test.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/services/backend-api/client/src/features/feed/components/BrowseFeedsModal/BrowseFeedsModal.redditRetry.test.tsx b/services/backend-api/client/src/features/feed/components/BrowseFeedsModal/BrowseFeedsModal.redditRetry.test.tsx index 99cfd88ec..53224c98f 100644 --- a/services/backend-api/client/src/features/feed/components/BrowseFeedsModal/BrowseFeedsModal.redditRetry.test.tsx +++ b/services/backend-api/client/src/features/feed/components/BrowseFeedsModal/BrowseFeedsModal.redditRetry.test.tsx @@ -8,7 +8,7 @@ import { useState } from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { system } from "@/utils/theme"; import ApiAdapterError from "@/utils/ApiAdapterError"; -import { ApiErrorCode } from "@/utils/getStandardErrorCodeMessage copy"; +import { ApiErrorCode } from "@/utils/getStandardErrorCodeMessage"; import { BrowseFeedsModal } from "./index"; // Regression test for the Reddit connect retry in the discovery modal: pasting a subreddit URL diff --git a/services/backend-api/client/src/features/feed/components/EditUserFeedDialog/EditUserFeedDialog.redditGate.test.tsx b/services/backend-api/client/src/features/feed/components/EditUserFeedDialog/EditUserFeedDialog.redditGate.test.tsx index d6c029be7..03a38bbc0 100644 --- a/services/backend-api/client/src/features/feed/components/EditUserFeedDialog/EditUserFeedDialog.redditGate.test.tsx +++ b/services/backend-api/client/src/features/feed/components/EditUserFeedDialog/EditUserFeedDialog.redditGate.test.tsx @@ -7,7 +7,7 @@ import { useState } from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { system } from "@/utils/theme"; import ApiAdapterError from "@/utils/ApiAdapterError"; -import { ApiErrorCode } from "@/utils/getStandardErrorCodeMessage copy"; +import { ApiErrorCode } from "@/utils/getStandardErrorCodeMessage"; import { EditUserFeedDialog } from "./index"; // Regression test for the missing Reddit connect gate when EDITING an existing feed's URL. diff --git a/services/backend-api/client/src/features/feed/components/EditUserFeedDialog/index.tsx b/services/backend-api/client/src/features/feed/components/EditUserFeedDialog/index.tsx index 74ae6edce..d45b2b655 100644 --- a/services/backend-api/client/src/features/feed/components/EditUserFeedDialog/index.tsx +++ b/services/backend-api/client/src/features/feed/components/EditUserFeedDialog/index.tsx @@ -12,7 +12,7 @@ import { } from "../../../../components/InlineErrorAlert"; import { useCreateUserFeedUrlValidation } from "../../hooks/useCreateUserFeedUrlValidation"; import { FixFeedRequestsCTA } from "../FixFeedRequestsCTA"; -import { ApiErrorCode } from "../../../../utils/getStandardErrorCodeMessage copy"; +import { ApiErrorCode } from "../../../../utils/getStandardErrorCodeMessage"; import type ApiAdapterError from "@/utils/ApiAdapterError"; import { useUserMe } from "../../../discordUser"; import { diff --git a/services/backend-api/test/api/workspace-invite-claim.test.ts b/services/backend-api/test/api/workspace-invite-claim.test.ts index 73fe8c04d..a74f887bb 100644 --- a/services/backend-api/test/api/workspace-invite-claim.test.ts +++ b/services/backend-api/test/api/workspace-invite-claim.test.ts @@ -237,7 +237,7 @@ describe("Workspace invite claim API", () => { // The accept response returns the joined workspace's slug so the client // can redirect the invitee straight into it. - const body = await res.json(); + const body = await readJson<{ result: { workspaceSlug: string } }>(res); assert.strictEqual(body.result.workspaceSlug, workspace.slug); // Membership row created with role admin. From 9368d1574b0b4317e3d706a4ef638f975b2bcb5f Mon Sep 17 00:00:00 2001 From: gintil Date: Tue, 9 Jun 2026 20:11:30 -0400 Subject: [PATCH 06/13] Add workspace-level Reddit connections One member's personal Reddit grant backs every Reddit feed in a workspace (Zapier/Buffer model): workspace feeds resolve ONLY the workspace connection and personal feeds ONLY the creator's, with no fallback in either direction. - Workspace.externalCredentials with connectedByUserId attribution; surfaced on the workspace detail endpoint as redditConnection ("Connected by X") - Reddit OAuth login accepts ?workspaceId, carried via a session-stored state nonce (also fixes the previously hardcoded state="state" CSRF gap); the callback verifies workspace membership before exchanging the code - Credential resolution by feed scope at every chokepoint: lookup-detail construction, the mandatory-connection gate, lookup-key sync (user-keyed sync now excludes workspace feeds), scheduled-fetch pipelines, delivery events, previews/test sends, and the credential refresh sweep - Revoke-on-exit: when the connecting member leaves or is removed, the grant is revoked at Reddit, the connection is marked REVOKED, workspace feeds stop fetching with it, and every member is emailed (any member can reconnect with their own account) - DELETE /workspaces/:slug/reddit-connection (any member) + workspace settings Integrations UI; reddit gates (UrlValidationResult, BrowseFeeds, EditUserFeedDialog, FixFeedRequestsCTA) are workspace-aware via FeedScope --- .../workspace-reddit-connection.spec.ts | 99 ++++ .../RedditLoginButton.test.tsx | 72 ++- .../RedditLoginButton/RedditLoginButton.tsx | 38 +- .../feed/api/createUserFeedUrlValidation.ts | 3 + .../components/EditUserFeedDialog/index.tsx | 17 +- .../UrlValidationResult.tsx | 73 +-- .../FixFeedRequestsCTA.test.tsx | 92 +++- .../components/FixFeedRequestsCTA/index.tsx | 59 ++- .../feed/contexts/FeedScopeContext.tsx | 12 + .../hooks/useCreateUserFeedUrlValidation.tsx | 11 +- .../api/disconnectWorkspaceReddit.ts | 10 + .../src/features/workspaces/api/index.ts | 1 + .../WorkspaceRedditConnectionSetting.test.tsx | 119 +++++ .../index.tsx | 108 ++++ .../components/WorkspaceScopeLayout/index.tsx | 23 +- .../components/WorkspaceSettings/index.tsx | 7 + .../src/features/workspaces/hooks/index.ts | 1 + .../hooks/useDisconnectWorkspaceReddit.tsx | 24 + .../src/features/workspaces/types/index.ts | 12 + .../client/src/utils/openRedditLogin.ts | 8 +- services/backend-api/src/container.ts | 3 + .../reddit-auth/reddit-auth.handlers.ts | 84 ++- .../user-feeds/user-feeds.handlers.ts | 4 +- .../features/user-feeds/user-feeds.schemas.ts | 4 + ...rkspace-reddit-connection-lost.template.ts | 14 + .../workspaces/workspaces.handlers.ts | 39 +- .../features/workspaces/workspaces.routes.ts | 9 + .../features/workspaces/workspaces.service.ts | 255 +++++++++ .../interfaces/user-feed.types.ts | 16 + .../mongoose/user-feed.mongoose.repository.ts | 21 +- .../mongoose/user.mongoose.repository.ts | 6 + .../mongoose/workspace.mongoose.repository.ts | 422 +++++++++++++++ .../src/scripts/schedule-emitter.ts | 92 ++++ ...ed-connections-discord-channels.service.ts | 29 + .../types.ts | 2 + .../message-broker-events.service.ts | 13 +- .../services/message-broker-events/types.ts | 5 +- .../schedule-handler.service.ts | 4 + .../services/user-feeds/user-feeds.service.ts | 136 ++++- .../utils/get-common-feed-aggregate-stages.ts | 12 + .../utils/get-feed-request-lookup-details.ts | 16 +- .../backend-api/test/api/reddit-auth.test.ts | 66 ++- .../workspace-reddit-connection.test.ts | 499 ++++++++++++++++++ .../api/workspace-invite-controls.test.ts | 2 + .../test/api/workspace-invites.test.ts | 4 + .../test/helpers/user-feeds.harness.ts | 3 + 46 files changed, 2376 insertions(+), 173 deletions(-) create mode 100644 e2e/tests/workspaces/workspace-reddit-connection.spec.ts create mode 100644 services/backend-api/client/src/features/workspaces/api/disconnectWorkspaceReddit.ts create mode 100644 services/backend-api/client/src/features/workspaces/components/WorkspaceRedditConnectionSetting/WorkspaceRedditConnectionSetting.test.tsx create mode 100644 services/backend-api/client/src/features/workspaces/components/WorkspaceRedditConnectionSetting/index.tsx create mode 100644 services/backend-api/client/src/features/workspaces/hooks/useDisconnectWorkspaceReddit.tsx create mode 100644 services/backend-api/src/features/workspaces/workspace-reddit-connection-lost.template.ts create mode 100644 services/backend-api/test/api/user-feeds/workspace-reddit-connection.test.ts diff --git a/e2e/tests/workspaces/workspace-reddit-connection.spec.ts b/e2e/tests/workspaces/workspace-reddit-connection.spec.ts new file mode 100644 index 000000000..0ffc94c26 --- /dev/null +++ b/e2e/tests/workspaces/workspace-reddit-connection.spec.ts @@ -0,0 +1,99 @@ +import { test, expect, type Page } from "../../fixtures/test-fixtures"; +import { getDiscordUserIdFromPage } from "../../helpers/paddle-db"; +import { enableWorkspacesFeatureInDb, setVerifiedEmailInDb } from "../../helpers/workspaces-db"; + +// Workspace Reddit connections: workspace feeds resolve the WORKSPACE's Reddit +// connection (one member's grant backing the whole workspace), never anyone's +// personal connection. The reddit gate in workspace scope therefore prompts for a +// workspace connection, and the workspace settings page exposes the connection +// with attribution and any-member connect/disconnect. + +async function waitForAuthenticatedApp(page: Page): Promise { + await expect(page.getByRole("button", { name: "Account settings" })).toBeVisible({ + timeout: 15000, + }); +} + +async function enableWorkspacesForCurrentUser(page: Page): Promise { + const discordUserId = await getDiscordUserIdFromPage(page); + await enableWorkspacesFeatureInDb(discordUserId); + await setVerifiedEmailInDb(discordUserId, `verified-${discordUserId}@example.com`); + await page.reload(); + await waitForAuthenticatedApp(page); +} + +async function createWorkspace(page: Page, workspaceName: string): Promise { + await page.getByRole("button", { name: "Account settings" }).click(); + await page.getByRole("menuitem", { name: /create a team/i }).click(); + const dialog = page.getByRole("dialog"); + await dialog.getByLabel("Team name").fill(workspaceName); + await dialog.getByRole("button", { name: "Create team" }).click(); + await expect(page).toHaveURL(/\/workspaces\/[^/]+\/feeds$/, { timeout: 15000 }); + const slug = page.url().match(/\/workspaces\/([^/]+)\/feeds/)?.[1]; + expect(slug).toBeTruthy(); + return slug as string; +} + +test.describe("Workspace Reddit connection", () => { + test("pasting a subreddit URL in workspace scope shows the workspace connect gate", async ({ + page, + }) => { + test.setTimeout(60000); + await page.goto("/feeds"); + await waitForAuthenticatedApp(page); + await enableWorkspacesForCurrentUser(page); + await createWorkspace(page, `E2E Reddit Workspace ${Date.now()}`); + + // 0 workspace feeds -> the discovery UI renders directly on the feeds page. + await expect( + page.getByRole("heading", { name: "Get news delivered to your Discord" }), + ).toBeVisible({ timeout: 15000 }); + + const searchInput = page.getByRole("textbox", { + name: "Search popular feeds or paste a URL", + }); + await searchInput.fill("https://www.reddit.com/r/gaming/.rss"); + await page.getByRole("button", { name: "Go", exact: true }).click(); + + // The gate prompts for a WORKSPACE connection ("a Reddit account", not "your"), + // proving the workspace scope reached the validation endpoint and the CTA. + await expect(page.getByText("Connect a Reddit account to continue")).toBeVisible({ + timeout: 30000, + }); + await expect( + page.getByRole("button", { name: "Connect Reddit in popup window" }), + ).toBeVisible(); + + // Gate short-circuits before any fetch: no Add button appears. + await expect(page.getByRole("button", { name: /^Add .+ feed$/i })).toHaveCount(0); + }); + + test("workspace settings exposes the Reddit connection as Not Connected with a connect action", async ({ + page, + }) => { + test.setTimeout(60000); + await page.goto("/feeds"); + await waitForAuthenticatedApp(page); + await enableWorkspacesForCurrentUser(page); + const slug = await createWorkspace(page, `E2E Reddit Settings ${Date.now()}`); + + // Navigate to the workspace's settings page via Account Settings. + await page.getByRole("button", { name: "Account settings" }).click(); + await page.getByRole("menuitem", { name: /account settings/i }).click(); + await page.getByRole("link", { name: /settings$/i }).first().click(); + await expect(page).toHaveURL(new RegExp(`/workspaces/${slug}/settings$`), { + timeout: 15000, + }); + + // The integrations section shows the unconnected Reddit state with a connect + // button (any member can connect on behalf of the workspace). + await expect(page.getByRole("heading", { name: "Integrations" })).toBeVisible(); + await expect(page.getByText("Not Connected")).toBeVisible(); + await expect( + page.getByText(/One member connects their Reddit account on behalf of the whole workspace/), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Connect Reddit in popup window" }), + ).toBeVisible(); + }); +}); diff --git a/services/backend-api/client/src/features/discordUser/components/RedditLoginButton/RedditLoginButton.test.tsx b/services/backend-api/client/src/features/discordUser/components/RedditLoginButton/RedditLoginButton.test.tsx index 961b940e7..cbfc4a7e0 100644 --- a/services/backend-api/client/src/features/discordUser/components/RedditLoginButton/RedditLoginButton.test.tsx +++ b/services/backend-api/client/src/features/discordUser/components/RedditLoginButton/RedditLoginButton.test.tsx @@ -1,8 +1,9 @@ import "@testing-library/jest-dom"; -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { ChakraProvider } from "@chakra-ui/react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { system } from "@/utils/theme"; +import { openRedditLogin } from "@/utils/openRedditLogin"; import { RedditLoginButton } from "./RedditLoginButton"; let mockExternalAccounts: Array<{ type: string; status: string }> | undefined; @@ -83,4 +84,73 @@ describe("RedditLoginButton", () => { ).toBeInTheDocument(); }); }); + + describe("workspace mode", () => { + const renderWorkspaceButton = ({ + connectionStatus, + onConnected, + refresh = vi.fn(), + }: { + connectionStatus: "ACTIVE" | "REVOKED" | null; + onConnected?: () => void; + refresh?: () => void; + }) => + render( + + + , + ); + + it("derives state from the WORKSPACE connection, not the personal account", () => { + // Personal account is ACTIVE, but the workspace has no connection: the button must + // offer Connect and must NOT fire onConnected (which would drive a stale retry). + mockExternalAccounts = [{ type: "reddit", status: "ACTIVE" }]; + const onConnected = vi.fn(); + renderWorkspaceButton({ connectionStatus: null, onConnected }); + + expect( + screen.getByRole("button", { name: "Connect Reddit in popup window" }), + ).toBeInTheDocument(); + expect(onConnected).not.toHaveBeenCalled(); + }); + + it("fires onConnected when the workspace connection is ACTIVE", () => { + mockExternalAccounts = undefined; + const onConnected = vi.fn(); + renderWorkspaceButton({ connectionStatus: "ACTIVE", onConnected }); + + expect(onConnected).toHaveBeenCalledTimes(1); + }); + + it("shows Reconnect when the workspace connection is REVOKED", () => { + renderWorkspaceButton({ connectionStatus: "REVOKED" }); + + expect( + screen.getByRole("button", { + name: "Reconnect Reddit in popup window", + }), + ).toBeInTheDocument(); + }); + + it("opens the login popup scoped to the workspace", () => { + renderWorkspaceButton({ connectionStatus: null }); + + fireEvent.click(screen.getByRole("button", { name: "Connect Reddit in popup window" })); + + expect(openRedditLogin).toHaveBeenCalledWith("workspace-1"); + }); + + it("refreshes the workspace connection (not the personal account) when the popup completes", () => { + const refresh = vi.fn(); + renderWorkspaceButton({ connectionStatus: null, refresh }); + + fireEvent(window, new MessageEvent("message", { data: "reddit" })); + + expect(refresh).toHaveBeenCalledTimes(1); + expect(mockRefetch).not.toHaveBeenCalled(); + }); + }); }); diff --git a/services/backend-api/client/src/features/discordUser/components/RedditLoginButton/RedditLoginButton.tsx b/services/backend-api/client/src/features/discordUser/components/RedditLoginButton/RedditLoginButton.tsx index fb5ea578b..cf17b720b 100644 --- a/services/backend-api/client/src/features/discordUser/components/RedditLoginButton/RedditLoginButton.tsx +++ b/services/backend-api/client/src/features/discordUser/components/RedditLoginButton/RedditLoginButton.tsx @@ -13,6 +13,18 @@ interface Props { */ emphasis?: "primary"; onConnected?: () => void; + /** + * Connect on behalf of a workspace instead of the caller's personal account. The grant is + * stored on the workspace, so the connected/reconnect state comes from the workspace's + * connection (passed in by the caller — this component cannot read workspace state itself), + * and `refresh` re-fetches it after the popup completes. + */ + workspace?: { + id: string; + /** null = the workspace has no connection record. */ + connectionStatus: "ACTIVE" | "REVOKED" | null; + refresh: () => void; + }; } export const RedditLoginButton = ({ @@ -20,21 +32,27 @@ export const RedditLoginButton = ({ colorPalette, emphasis, onConnected, + workspace, }: Props) => { const { data, refetch, fetchStatus } = useUserMe(); - const redditAccount = data?.result.externalAccounts?.find( - (e) => e.type === "reddit", - ); + const redditAccount = data?.result.externalAccounts?.find((e) => e.type === "reddit"); // A revoked/expired account record still exists, so "is there a record" is the wrong signal for a // successful connection - it would fire onConnected (and any retry it drives) while the account is // still unusable, re-hitting the server-side gate. Only an ACTIVE account is actually connected. - const isRedditActive = redditAccount?.status === "ACTIVE"; + const hasConnectionRecord = workspace ? workspace.connectionStatus !== null : !!redditAccount; + const isRedditActive = workspace + ? workspace.connectionStatus === "ACTIVE" + : redditAccount?.status === "ACTIVE"; useEffect(() => { const messageListener = (e: MessageEvent) => { if (e.data === "reddit") { - refetch(); + if (workspace) { + workspace.refresh(); + } else { + refetch(); + } } }; @@ -43,7 +61,7 @@ export const RedditLoginButton = ({ return () => { window.removeEventListener("message", messageListener); }; - }, []); + }, [workspace?.id]); useEffect(() => { if (isRedditActive) { @@ -61,16 +79,14 @@ export const RedditLoginButton = ({ return; } - openRedditLogin(); + openRedditLogin(workspace?.id); }} colorPalette={emphasis === "primary" ? "brand" : colorPalette} aria-label={ - redditAccount - ? "Reconnect Reddit in popup window" - : "Connect Reddit in popup window" + hasConnectionRecord ? "Reconnect Reddit in popup window" : "Connect Reddit in popup window" } > - {redditAccount ? "Reconnect" : "Connect"} + {hasConnectionRecord ? "Reconnect" : "Connect"} ); diff --git a/services/backend-api/client/src/features/feed/api/createUserFeedUrlValidation.ts b/services/backend-api/client/src/features/feed/api/createUserFeedUrlValidation.ts index 8fe1c0aaf..cc20f9a77 100644 --- a/services/backend-api/client/src/features/feed/api/createUserFeedUrlValidation.ts +++ b/services/backend-api/client/src/features/feed/api/createUserFeedUrlValidation.ts @@ -4,6 +4,9 @@ import fetchRest from "../../../utils/fetchRest"; export interface CreateUserFeedUrlValidationInput { details: { url: string; + // In workspace scope, reddit-connection checks resolve against the workspace's + // connection instead of the caller's personal one. + workspaceId?: string; }; } diff --git a/services/backend-api/client/src/features/feed/components/EditUserFeedDialog/index.tsx b/services/backend-api/client/src/features/feed/components/EditUserFeedDialog/index.tsx index d45b2b655..bcc47e9b1 100644 --- a/services/backend-api/client/src/features/feed/components/EditUserFeedDialog/index.tsx +++ b/services/backend-api/client/src/features/feed/components/EditUserFeedDialog/index.tsx @@ -15,6 +15,7 @@ import { FixFeedRequestsCTA } from "../FixFeedRequestsCTA"; import { ApiErrorCode } from "../../../../utils/getStandardErrorCodeMessage"; import type ApiAdapterError from "@/utils/ApiAdapterError"; import { useUserMe } from "../../../discordUser"; +import { useFeedScope } from "../../contexts/FeedScopeContext"; import { DialogRoot, DialogContent, @@ -80,8 +81,7 @@ export const EditUserFeedDialog: React.FC = ({ const isConfirming = !!feedUrlValidationData?.result.resolvedToUrl; const isLoading = isSubmitting || validationStatus === "loading"; const canResolveError = !!error?.errorCode && RESOLVABLE_ERRORS.includes(error.errorCode); - const isRedditConnectionRequired = - error?.errorCode === ApiErrorCode.REDDIT_CONNECTION_REQUIRED; + const isRedditConnectionRequired = error?.errorCode === ApiErrorCode.REDDIT_CONNECTION_REQUIRED; const showCta = canResolveError || isRedditConnectionRequired; const onSubmit = async ({ title, url }: FormData) => { @@ -113,10 +113,12 @@ export const EditUserFeedDialog: React.FC = ({ // FixFeedRequestsCTA, which unmounts the instant the account becomes active - so the retry can't // be driven from its onConnected callback (it would race its own unmount). This dialog survives the // transition, so it owns the retry: on the not-connected -> connected edge while a Reddit gate is - // showing, re-submit the form. + // showing, re-submit the form. In workspace scope the watched connection is the workspace's. const { data: userMe } = useUserMe(); - const hasRedditConnected = - userMe?.result.externalAccounts?.find((e) => e.type === "reddit")?.status === "ACTIVE"; + const feedScope = useFeedScope(); + const hasRedditConnected = feedScope.workspaceId + ? feedScope.redditConnection?.status === "ACTIVE" + : userMe?.result.externalAccounts?.find((e) => e.type === "reddit")?.status === "ACTIVE"; const prevHasRedditConnectedRef = useRef(hasRedditConnected); useEffect(() => { @@ -241,7 +243,10 @@ export const EditUserFeedDialog: React.FC = ({ )} {error && !showCta && ( - + )} {showCta && ( e.type === "reddit") - ?.status === "ACTIVE"; + const feedScope = useFeedScope(); + // In workspace scope the gate clears when the WORKSPACE's connection becomes active. + const hasRedditConnected = feedScope.workspaceId + ? feedScope.redditConnection?.status === "ACTIVE" + : userMeData?.result.externalAccounts?.find((e) => e.type === "reddit")?.status === "ACTIVE"; // Re-run the blocked action once Reddit connects. The connect button lives inside // FixFeedRequestsCTA, which unmounts the instant the account becomes active - so the retry can't @@ -125,8 +119,7 @@ export const UrlValidationResult = ({ const prevHasRedditConnectedRef = useRef(hasRedditConnected); useEffect(() => { - const justConnected = - hasRedditConnected && !prevHasRedditConnectedRef.current; + const justConnected = hasRedditConnected && !prevHasRedditConnectedRef.current; prevHasRedditConnectedRef.current = hasRedditConnected; if (!justConnected) return; @@ -205,22 +198,15 @@ export const UrlValidationResult = ({ state={feedCardState} onAdd={() => handleAdd(displayTitle)} onRemove={onFeedRemoved ? handleRemove : undefined} - feedSettingsUrl={ - addedFeedId ? pages.userFeed(addedFeedId) : undefined - } + feedSettingsUrl={addedFeedId ? pages.userFeed(addedFeedId) : undefined} fullWidthAction redirectedFrom={redirectedFrom} /> - {addError && - !isLimitReachedError && - !isRedditConnectionRequiredAddError && ( - - - - )} + {addError && !isLimitReachedError && !isRedditConnectionRequiredAddError && ( + + + + )} {isRedditConnectionRequiredAddError && ( - + )} @@ -249,18 +232,13 @@ export const UrlValidationResult = ({ if (validationError.errorCode === ApiErrorCode.REDDIT_CONNECTION_REQUIRED) { return ( - + ); } const isNoFeedFound = - validationError.errorCode && - NO_FEED_FOUND_CODES.includes(validationError.errorCode); + validationError.errorCode && NO_FEED_FOUND_CODES.includes(validationError.errorCode); if (isNoFeedFound) { return ( @@ -271,8 +249,7 @@ export const UrlValidationResult = ({ Couldn't find a feed - We couldn't detect a news feed at this URL. The site may - not publish one. + We couldn't detect a news feed at this URL. The site may not publish one. ), })); -const renderCTA = ( - props: Partial>, -) => +const renderCTA = (props: Partial>) => render( - + , ); @@ -45,9 +47,7 @@ describe("FixFeedRequestsCTA", () => { variant: "required", }); - expect( - screen.getByText("Connect your Reddit account to continue"), - ).toBeInTheDocument(); + expect(screen.getByText("Connect your Reddit account to continue")).toBeInTheDocument(); }); describe("variant=required", () => { @@ -55,21 +55,15 @@ describe("FixFeedRequestsCTA", () => { mockExternalAccounts = undefined; renderCTA({ variant: "required" }); - expect( - screen.getByText("Connect your Reddit account to continue"), - ).toBeInTheDocument(); - expect( - screen.getByText(/heavily rate-limits unauthenticated requests/i), - ).toBeInTheDocument(); + expect(screen.getByText("Connect your Reddit account to continue")).toBeInTheDocument(); + expect(screen.getByText(/heavily rate-limits unauthenticated requests/i)).toBeInTheDocument(); }); it("shows reconnect copy when reddit account exists but is revoked", () => { mockExternalAccounts = [{ type: "reddit", status: "REVOKED" }]; renderCTA({ variant: "required" }); - expect( - screen.getByText("Reconnect your Reddit account"), - ).toBeInTheDocument(); + expect(screen.getByText("Reconnect your Reddit account")).toBeInTheDocument(); expect(screen.getByText(/no longer active/i)).toBeInTheDocument(); }); @@ -86,12 +80,8 @@ describe("FixFeedRequestsCTA", () => { mockExternalAccounts = undefined; renderCTA({}); - expect( - screen.getByText("Connect your Reddit account"), - ).toBeInTheDocument(); - expect( - screen.getByText(/heavily rate-limits unauthenticated requests/i), - ).toBeInTheDocument(); + expect(screen.getByText("Connect your Reddit account")).toBeInTheDocument(); + expect(screen.getByText(/heavily rate-limits unauthenticated requests/i)).toBeInTheDocument(); }); it("renders nothing when reddit is already active", () => { @@ -101,4 +91,52 @@ describe("FixFeedRequestsCTA", () => { expect(container).toBeEmptyDOMElement(); }); }); + + describe("workspace scope", () => { + const renderInWorkspace = ( + scope: FeedScope, + props: Partial> = {}, + ) => + render( + + + + + , + ); + + it("gates on the WORKSPACE connection even when the personal account is active", () => { + // No fallback: the member's personal connection never powers workspace feeds. + mockExternalAccounts = [{ type: "reddit", status: "ACTIVE" }]; + renderInWorkspace({ workspaceId: "ws-1", redditConnection: null }); + + expect(screen.getByText("Connect a Reddit account to continue")).toBeInTheDocument(); + expect(screen.getByText("reddit-login-workspace-ws-1")).toBeInTheDocument(); + }); + + it("renders nothing when the workspace connection is active, even without a personal one", () => { + mockExternalAccounts = undefined; + const { container } = renderInWorkspace({ + workspaceId: "ws-1", + redditConnection: { status: "ACTIVE" }, + }); + + expect(container).toBeEmptyDOMElement(); + }); + + it("shows workspace reconnect copy when the workspace connection is revoked", () => { + mockExternalAccounts = undefined; + renderInWorkspace({ + workspaceId: "ws-1", + redditConnection: { status: "REVOKED" }, + }); + + expect(screen.getByText("Reconnect this workspace's Reddit account")).toBeInTheDocument(); + expect(screen.getByText(/Any member can reconnect/i)).toBeInTheDocument(); + }); + }); }); diff --git a/services/backend-api/client/src/features/feed/components/FixFeedRequestsCTA/index.tsx b/services/backend-api/client/src/features/feed/components/FixFeedRequestsCTA/index.tsx index 6698a4872..323db0532 100644 --- a/services/backend-api/client/src/features/feed/components/FixFeedRequestsCTA/index.tsx +++ b/services/backend-api/client/src/features/feed/components/FixFeedRequestsCTA/index.tsx @@ -1,5 +1,6 @@ import { Alert, Box, Stack, Text } from "@chakra-ui/react"; import { RedditLoginButton, useUserMe } from "../../../discordUser"; +import { useFeedScope } from "../../contexts/FeedScopeContext"; type Variant = "rate-limited" | "required"; @@ -11,21 +12,22 @@ interface Props { const REDDIT_URL_REGEX = /^http(s?):\/\/(www.)?(\w+\.)?reddit\.com\//i; -export const FixFeedRequestsCTA = ({ - url, - variant = "rate-limited", - onCorrected, -}: Props) => { +export const FixFeedRequestsCTA = ({ url, variant = "rate-limited", onCorrected }: Props) => { const { data } = useUserMe(); + const { workspaceId, redditConnection, refreshRedditConnection } = useFeedScope(); const isReddit = REDDIT_URL_REGEX.test(url); + const isWorkspaceScope = !!workspaceId; if (!isReddit) { return null; } - const hasRedditConnected = - data?.result.externalAccounts?.find((e) => e.type === "reddit")?.status === - "ACTIVE"; + // In workspace scope the gate resolves against the WORKSPACE's connection — a member's + // personal connection never powers workspace feeds (and vice versa). + const personalAccount = data?.result.externalAccounts?.find((e) => e.type === "reddit"); + const hasRedditConnected = isWorkspaceScope + ? redditConnection?.status === "ACTIVE" + : personalAccount?.status === "ACTIVE"; // For the rate-limited variant, an active connection means there's nothing to // prompt for - the request failed for another reason. @@ -39,35 +41,39 @@ export const FixFeedRequestsCTA = ({ return null; } - // A reddit account record exists but is no longer active (revoked/expired). - const needsReconnect = - !!data?.result.externalAccounts?.find((e) => e.type === "reddit") && - !hasRedditConnected; + // A reddit connection record exists but is no longer active (revoked/expired). + const needsReconnect = isWorkspaceScope + ? !!redditConnection && !hasRedditConnected + : !!personalAccount && !hasRedditConnected; const title = variant === "required" - ? "Connect your Reddit account to continue" - : "Connect your Reddit account"; + ? `Connect ${isWorkspaceScope ? "a" : "your"} Reddit account to continue` + : `Connect ${isWorkspaceScope ? "a" : "your"} Reddit account`; + + const accountNoun = isWorkspaceScope ? "a Reddit account for this workspace" : "your account"; const description = variant === "required" - ? "Reddit heavily rate-limits unauthenticated requests, so Reddit feeds need a connected account to fetch reliably. Connect your account to add this feed." - : "Reddit heavily rate-limits unauthenticated requests. Connecting your account gives Reddit feeds the higher quota they need to fetch reliably."; + ? `Reddit heavily rate-limits unauthenticated requests, so Reddit feeds need a connected account to fetch reliably. Connect ${accountNoun} to add this feed.` + : `Reddit heavily rate-limits unauthenticated requests. Connecting ${accountNoun} gives Reddit feeds the higher quota they need to fetch reliably.`; + + const reconnectDescription = isWorkspaceScope + ? "This workspace's Reddit connection is no longer active. Any member can reconnect with their own Reddit account to add this feed." + : "Your Reddit connection is no longer active. Reconnect your account to add this feed."; return ( - {needsReconnect ? "Reconnect your Reddit account" : title} + {needsReconnect + ? `Reconnect ${isWorkspaceScope ? "this workspace's" : "your"} Reddit account` + : title} - - {needsReconnect - ? "Your Reddit connection is no longer active. Reconnect your account to add this feed." - : description} - + {needsReconnect ? reconnectDescription : description} refreshRedditConnection?.(), + } + : undefined + } /> diff --git a/services/backend-api/client/src/features/feed/contexts/FeedScopeContext.tsx b/services/backend-api/client/src/features/feed/contexts/FeedScopeContext.tsx index 60fe5cfbf..0ca5be571 100644 --- a/services/backend-api/client/src/features/feed/contexts/FeedScopeContext.tsx +++ b/services/backend-api/client/src/features/feed/contexts/FeedScopeContext.tsx @@ -18,6 +18,18 @@ export interface FeedScope { workspaceSlug?: string; /** The current workspace's feed limit, for the feed-limit bar. */ maxFeeds?: number; + /** + * The workspace's Reddit connection state. In workspace scope, Reddit gates resolve + * against this (the workspace's connection) instead of the caller's personal account; + * null means the workspace has no connection record. + */ + redditConnection?: { + status: "ACTIVE" | "REVOKED"; + connectedByUserId?: string; + connectedByDiscordUserId?: string | null; + } | null; + /** Re-fetches the workspace so a just-completed connect/disconnect is reflected. */ + refreshRedditConnection?: () => void; } const FeedScopeContext = createContext({}); diff --git a/services/backend-api/client/src/features/feed/hooks/useCreateUserFeedUrlValidation.tsx b/services/backend-api/client/src/features/feed/hooks/useCreateUserFeedUrlValidation.tsx index a874750a2..db01d3151 100644 --- a/services/backend-api/client/src/features/feed/hooks/useCreateUserFeedUrlValidation.tsx +++ b/services/backend-api/client/src/features/feed/hooks/useCreateUserFeedUrlValidation.tsx @@ -5,13 +5,22 @@ import { CreateUserFeedUrlValidationInput, CreateUserFeedUrlValidationOutput, } from "../api/createUserFeedUrlValidation"; +import { useFeedScope } from "../contexts/FeedScopeContext"; export const useCreateUserFeedUrlValidation = () => { + const { workspaceId } = useFeedScope(); + const { mutateAsync, status, error, reset, data } = useMutation< CreateUserFeedUrlValidationOutput, ApiAdapterError, CreateUserFeedUrlValidationInput - >((details) => createUserFeedUrlValidation(details)); + >( + // In workspace scope, validation (and its reddit gate) runs against the workspace. + (input) => + createUserFeedUrlValidation({ + details: { ...input.details, workspaceId: input.details.workspaceId ?? workspaceId }, + }), + ); return { mutateAsync, diff --git a/services/backend-api/client/src/features/workspaces/api/disconnectWorkspaceReddit.ts b/services/backend-api/client/src/features/workspaces/api/disconnectWorkspaceReddit.ts new file mode 100644 index 000000000..026cc9bb0 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/api/disconnectWorkspaceReddit.ts @@ -0,0 +1,10 @@ +import fetchRest from "@/utils/fetchRest"; + +export const disconnectWorkspaceReddit = async (workspaceSlug: string): Promise => { + await fetchRest(`/api/v1/workspaces/${workspaceSlug}/reddit-connection`, { + requestOptions: { + method: "DELETE", + }, + skipJsonParse: true, + }); +}; diff --git a/services/backend-api/client/src/features/workspaces/api/index.ts b/services/backend-api/client/src/features/workspaces/api/index.ts index c706c22f9..71d208525 100644 --- a/services/backend-api/client/src/features/workspaces/api/index.ts +++ b/services/backend-api/client/src/features/workspaces/api/index.ts @@ -16,3 +16,4 @@ export * from "./resendWorkspaceInvite"; export * from "./revokeWorkspaceInvite"; export * from "./removeWorkspaceMember"; export * from "./leaveWorkspace"; +export * from "./disconnectWorkspaceReddit"; diff --git a/services/backend-api/client/src/features/workspaces/components/WorkspaceRedditConnectionSetting/WorkspaceRedditConnectionSetting.test.tsx b/services/backend-api/client/src/features/workspaces/components/WorkspaceRedditConnectionSetting/WorkspaceRedditConnectionSetting.test.tsx new file mode 100644 index 000000000..4ebf63dae --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/components/WorkspaceRedditConnectionSetting/WorkspaceRedditConnectionSetting.test.tsx @@ -0,0 +1,119 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { ChakraProvider } from "@chakra-ui/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { system } from "@/utils/theme"; +import { WorkspaceRedditConnectionSetting } from "./index"; + +interface MockRedditConnection { + status: "ACTIVE" | "REVOKED"; + connectedBy: { userId: string; discordUserId: string | null }; +} + +let mockRedditConnection: MockRedditConnection | null = null; +const mockDisconnect = vi.fn(); + +vi.mock("@/features/discordUser", () => ({ + useUserMe: () => ({ + data: { result: { id: "self-user-id", externalAccounts: [] } }, + refetch: vi.fn(), + fetchStatus: "idle", + }), + DiscordUsername: ({ userId }: { userId: string }) => {`user:${userId}`}, + RedditLoginButton: ({ workspace }: { workspace?: { id: string } }) => ( + + ), +})); + +vi.mock("../../hooks/useWorkspace", () => ({ + useWorkspace: () => ({ + workspace: { + id: "ws-1", + name: "Workspace", + slug: "my-workspace", + role: "owner", + redditConnection: mockRedditConnection, + }, + refetch: vi.fn(), + }), +})); + +vi.mock("../../hooks/useDisconnectWorkspaceReddit", () => ({ + useDisconnectWorkspaceReddit: () => ({ + mutateAsync: mockDisconnect, + status: "idle", + error: null, + reset: vi.fn(), + }), +})); + +const renderSetting = () => + render( + + + , + ); + +describe("WorkspaceRedditConnectionSetting", () => { + beforeEach(() => { + mockRedditConnection = null; + mockDisconnect.mockReset(); + mockDisconnect.mockResolvedValue(undefined); + }); + + it("shows Not Connected with a workspace-scoped connect button when no connection exists", () => { + renderSetting(); + + expect(screen.getByText("Not Connected")).toBeInTheDocument(); + expect(screen.getByText("connect-workspace-ws-1")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /disconnect/i })).not.toBeInTheDocument(); + }); + + it("shows Connected with attribution to the connecting member", () => { + mockRedditConnection = { + status: "ACTIVE", + connectedBy: { userId: "other-user-id", discordUserId: "discord-123" }, + }; + renderSetting(); + + expect(screen.getByText("Connected")).toBeInTheDocument(); + expect(screen.getByText(/Connected by/)).toBeInTheDocument(); + expect(screen.getByText("user:discord-123")).toBeInTheDocument(); + expect(screen.queryByText(/\(you\)/)).not.toBeInTheDocument(); + }); + + it("marks the connection as yours when you connected it", () => { + mockRedditConnection = { + status: "ACTIVE", + connectedBy: { userId: "self-user-id", discordUserId: "discord-self" }, + }; + renderSetting(); + + expect(screen.getByText(/\(you\)/)).toBeInTheDocument(); + }); + + it("shows a Disconnected state with reconnect guidance when the connection is revoked", () => { + mockRedditConnection = { + status: "REVOKED", + connectedBy: { userId: "other-user-id", discordUserId: "discord-123" }, + }; + renderSetting(); + + expect(screen.getByText("Disconnected")).toBeInTheDocument(); + expect(screen.getByText(/Any member can reconnect/i)).toBeInTheDocument(); + }); + + it("disconnects via the workspace endpoint", async () => { + mockRedditConnection = { + status: "ACTIVE", + connectedBy: { userId: "other-user-id", discordUserId: "discord-123" }, + }; + renderSetting(); + + fireEvent.click(screen.getByRole("button", { name: /disconnect/i })); + + await waitFor(() => { + expect(mockDisconnect).toHaveBeenCalledWith({ workspaceSlug: "my-workspace" }); + }); + }); +}); diff --git a/services/backend-api/client/src/features/workspaces/components/WorkspaceRedditConnectionSetting/index.tsx b/services/backend-api/client/src/features/workspaces/components/WorkspaceRedditConnectionSetting/index.tsx new file mode 100644 index 000000000..22590f945 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/components/WorkspaceRedditConnectionSetting/index.tsx @@ -0,0 +1,108 @@ +import { Badge, HStack, Stack, Text } from "@chakra-ui/react"; +import { SafeLoadingButton } from "@/components/SafeLoadingButton"; +import { InlineErrorAlert } from "@/components/InlineErrorAlert"; +import { DiscordUsername, RedditLoginButton, useUserMe } from "@/features/discordUser"; +import { useWorkspace } from "../../hooks/useWorkspace"; +import { useDisconnectWorkspaceReddit } from "../../hooks/useDisconnectWorkspaceReddit"; + +interface Props { + workspaceSlug: string; +} + +/** + * The workspace's Reddit connection: one member's personal Reddit grant backs every + * Reddit feed in the workspace. Shows who connected it ("Connected by X") and lets ANY + * member connect, replace, or disconnect it — when the connection breaks, the widest + * possible set of people can fix it with their own account. + */ +export const WorkspaceRedditConnectionSetting = ({ workspaceSlug }: Props) => { + const { workspace, refetch } = useWorkspace({ workspaceSlug }); + const { data: userMe } = useUserMe(); + const { + mutateAsync: disconnect, + status: disconnectStatus, + error: disconnectError, + } = useDisconnectWorkspaceReddit(); + + if (!workspace) { + return null; + } + + const connection = workspace.redditConnection; + const isActive = connection?.status === "ACTIVE"; + const isRevoked = !!connection && !isActive; + const connectedBySelf = !!connection && connection.connectedBy.userId === userMe?.result.id; + + return ( + + + + + + Reddit + {isActive && Connected} + {isRevoked && Disconnected} + {!connection && Not Connected} + + {connection && ( + + Connected by{" "} + {connection.connectedBy.discordUserId ? ( + + ) : ( + "a former member" + )} + {connectedBySelf ? " (you)" : ""} + + )} + + {isRevoked + ? "This workspace's Reddit connection is no longer active. Any member can reconnect with their own Reddit account so the workspace's Reddit feeds keep updating." + : "Reddit feeds in this workspace fetch using this connection's rate limit quotas, which are much higher than the global limits. One member connects their Reddit account on behalf of the whole workspace, and any member can replace or remove it."} + + + + + + {connection && ( + { + disconnect({ workspaceSlug }).catch(() => { + // Surfaced via disconnectError below + }); + }} + > + Disconnect + + )} + + + {disconnectError && ( + + )} + + ); +}; diff --git a/services/backend-api/client/src/features/workspaces/components/WorkspaceScopeLayout/index.tsx b/services/backend-api/client/src/features/workspaces/components/WorkspaceScopeLayout/index.tsx index 8ee8b6ccd..0fbbcea69 100644 --- a/services/backend-api/client/src/features/workspaces/components/WorkspaceScopeLayout/index.tsx +++ b/services/backend-api/client/src/features/workspaces/components/WorkspaceScopeLayout/index.tsx @@ -22,7 +22,12 @@ import { useIsWorkspacesEnabled, useWorkspace } from "../../hooks"; export const WorkspaceScopeLayout = () => { const { workspaceSlug } = useParams(); const { enabled, status: flagStatus } = useIsWorkspacesEnabled(); - const { workspace, status: workspaceStatus, error } = useWorkspace({ workspaceSlug: enabled ? workspaceSlug : undefined }); + const { + workspace, + status: workspaceStatus, + error, + refetch, + } = useWorkspace({ workspaceSlug: enabled ? workspaceSlug : undefined }); if (flagStatus === "loading") { return ; @@ -45,7 +50,21 @@ export const WorkspaceScopeLayout = () => { {/* All feed queries, mutations, and links under a workspace route are workspace-scoped via this provider, so the personal feeds UI is reused verbatim. */} - + }> diff --git a/services/backend-api/client/src/features/workspaces/components/WorkspaceSettings/index.tsx b/services/backend-api/client/src/features/workspaces/components/WorkspaceSettings/index.tsx index 06d5b89fd..738b1c952 100644 --- a/services/backend-api/client/src/features/workspaces/components/WorkspaceSettings/index.tsx +++ b/services/backend-api/client/src/features/workspaces/components/WorkspaceSettings/index.tsx @@ -15,6 +15,7 @@ import { ApiErrorCode, getStandardErrorCodeMessage } from "@/utils/getStandardEr import { Field } from "@/components/ui/field"; import { useCurrentWorkspace } from "../../contexts"; import { useUpdateWorkspace } from "../../hooks"; +import { WorkspaceRedditConnectionSetting } from "../WorkspaceRedditConnectionSetting"; const formSchema = object({ name: string().required("Team name is required").max(100, "Team name is too long"), @@ -175,6 +176,12 @@ export const WorkspaceSettings = () => { + + + Integrations + + + { + const queryClient = useQueryClient(); + + const { mutateAsync, status, error, reset } = useMutation< + void, + ApiAdapterError, + { workspaceSlug: string } + >(({ workspaceSlug }) => disconnectWorkspaceReddit(workspaceSlug), { + onSuccess: (_data, { workspaceSlug }) => { + queryClient.invalidateQueries({ queryKey: ["workspace", { workspaceSlug }] }); + }, + }); + + return { + mutateAsync, + status, + error, + reset, + }; +}; diff --git a/services/backend-api/client/src/features/workspaces/types/index.ts b/services/backend-api/client/src/features/workspaces/types/index.ts index 3b81767f0..9e15f3cb7 100644 --- a/services/backend-api/client/src/features/workspaces/types/index.ts +++ b/services/backend-api/client/src/features/workspaces/types/index.ts @@ -19,6 +19,18 @@ export const WorkspaceSchema = object({ // The workspace's feed limit. Present on the detail endpoint; the list endpoint // omits it, so it is optional. maxFeeds: number().optional(), + // The workspace's Reddit connection (detail endpoint only). One member's personal + // grant backs the whole workspace's Reddit feeds; connectedBy names that member. + redditConnection: object({ + status: string().oneOf(["ACTIVE", "REVOKED"]).required(), + connectedBy: object({ + userId: string().required(), + discordUserId: string().nullable(), + }).required(), + }) + .nullable() + .optional() + .default(undefined), }).required(); export type Workspace = InferType; diff --git a/services/backend-api/client/src/utils/openRedditLogin.ts b/services/backend-api/client/src/utils/openRedditLogin.ts index 3f60850a8..6ab9b8706 100644 --- a/services/backend-api/client/src/utils/openRedditLogin.ts +++ b/services/backend-api/client/src/utils/openRedditLogin.ts @@ -1,5 +1,9 @@ import { pages } from "../constants"; -export const openRedditLogin = () => { - window.open(pages.loginReddit(), "_blank", `popup=true,width=600,height=600`); +export const openRedditLogin = (workspaceId?: string) => { + const url = workspaceId + ? `${pages.loginReddit()}?workspaceId=${encodeURIComponent(workspaceId)}` + : pages.loginReddit(); + + window.open(url, "_blank", `popup=true,width=600,height=600`); }; diff --git a/services/backend-api/src/container.ts b/services/backend-api/src/container.ts index 65222c8b7..adec591de 100644 --- a/services/backend-api/src/container.ts +++ b/services/backend-api/src/container.ts @@ -279,7 +279,9 @@ export function createContainer(deps: { smtpTransport, workspaceRepository, userRepository, + userFeedRepository, emailVerificationService, + redditApiService, }); const notificationsService = new NotificationsService({ @@ -320,6 +322,7 @@ export function createContainer(deps: { discordAuthService, connectionEventsService: userFeedConnectionEventsService, usersService, + workspacesService, }); const userFeedsService = new UserFeedsService({ diff --git a/services/backend-api/src/features/reddit-auth/reddit-auth.handlers.ts b/services/backend-api/src/features/reddit-auth/reddit-auth.handlers.ts index 27ecf2fb4..6affbebd9 100644 --- a/services/backend-api/src/features/reddit-auth/reddit-auth.handlers.ts +++ b/services/backend-api/src/features/reddit-auth/reddit-auth.handlers.ts @@ -1,17 +1,44 @@ +import { randomUUID } from "node:crypto"; import type { FastifyReply, FastifyRequest } from "fastify"; import { decrypt } from "../../shared/utils/decrypt"; +import logger from "../../infra/logger"; + +declare module "@fastify/secure-session" { + interface SessionData { + // Pending reddit OAuth attempt: the nonce is echoed back as the OAuth + // `state` (CSRF protection); the optional workspaceId scopes the grant to a + // workspace connection instead of the user's personal one. Kept server-side + // so neither can be tampered with via the callback URL. + redditAuthState: { + nonce: string; + workspaceId?: string; + }; + } +} + +interface LoginQuery { + workspaceId?: string; +} interface CallbackQuery { code?: string; error?: string; + state?: string; } +const CLOSE_WINDOW_HTML = ``; + export async function loginHandler( - request: FastifyRequest, + request: FastifyRequest<{ Querystring: LoginQuery }>, reply: FastifyReply, ): Promise { const { redditApiService } = request.container; - const authorizationUrl = redditApiService.getAuthorizeUrl(); + const { workspaceId } = request.query; + + const nonce = randomUUID(); + request.session.set("redditAuthState", { nonce, workspaceId }); + + const authorizationUrl = redditApiService.getAuthorizeUrl("read", nonce); reply.header("Cache-Control", "no-store"); return reply.redirect(authorizationUrl, 303); @@ -53,36 +80,67 @@ export async function callbackHandler( request: FastifyRequest<{ Querystring: CallbackQuery }>, reply: FastifyReply, ): Promise { - const { code, error } = request.query; - const { usersService, redditApiService } = request.container; + const { code, error, state } = request.query; + const { usersService, workspacesService, redditApiService } = + request.container; const discordUserId = request.discordUserId; reply.header("Cache-Control", "no-store"); + const pendingAuth = request.session.get("redditAuthState"); + request.session.set("redditAuthState", undefined); + if (error) { - return reply.type("text/html").send(``); + return reply.type("text/html").send(CLOSE_WINDOW_HTML); } if (!code) { return reply.send("No code available"); } + if (!pendingAuth || !state || state !== pendingAuth.nonce) { + logger.warn("Reddit OAuth callback state mismatch, discarding grant", { + discordUserId, + }); + + return reply.type("text/html").send(CLOSE_WINDOW_HTML); + } + const user = await usersService.getOrCreateUserByDiscordId(discordUserId); + // Membership is verified BEFORE the code is exchanged so a non-member's + // grant is never minted, let alone stored. + if (pendingAuth.workspaceId) { + await workspacesService.getWorkspaceForMember( + pendingAuth.workspaceId, + user.id, + ); + } + const { access_token: accessToken, refresh_token: refreshToken, expires_in: expiresIn, } = await redditApiService.getAccessToken(code); - await usersService.setRedditCredentials({ - userId: user.id, - accessToken, - refreshToken, - expiresIn, - }); - - await usersService.syncLookupKeys({ userIds: [user.id] }); + if (pendingAuth.workspaceId) { + await workspacesService.setRedditCredentials({ + workspaceId: pendingAuth.workspaceId, + connectedByUserId: user.id, + accessToken, + refreshToken, + expiresIn, + }); + } else { + await usersService.setRedditCredentials({ + userId: user.id, + accessToken, + refreshToken, + expiresIn, + }); + + await usersService.syncLookupKeys({ userIds: [user.id] }); + } return reply.type("text/html").send(`