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 37e660685..dbb7a3d3f 100644 --- a/docker-compose.e2e.yml +++ b/docker-compose.e2e.yml @@ -11,6 +11,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"] # The replica set is initiated via the healthcheck rather than a @@ -242,6 +247,14 @@ services: context: services/backend-api dockerfile: Dockerfile.dev restart: "no" + # Source is bind-mounted (narrowly: src only, node_modules stays in the + # image) so a fresh `up` always runs current backend code — image rebuilds + # are only needed for dependency changes, and the stale-COPY-layer trap (a + # cached build silently running old source) is structurally impossible. + # Cheap here because Node reads modules once at boot; web-client stays + # in-image (see its comment). + volumes: + - ./services/backend-api/src:/usr/src/app/src extra_hosts: - "host.docker.internal:host-gateway" depends_on: @@ -274,11 +287,32 @@ 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:-} + # Reddit OAuth + authenticated feed fetches are served by the host-side mock + # reddit server (e2e/mock-reddit-server.ts), reached via host.docker.internal + # by both the backend (token exchange) and the browser popup (authorize). - BACKEND_API_REDDIT_CLIENT_ID=${BACKEND_API_REDDIT_CLIENT_ID:-} + - BACKEND_API_REDDIT_CLIENT_SECRET=fake-reddit-client-secret + - BACKEND_API_REDDIT_REDIRECT_URI=http://localhost:${E2E_BACKEND_PORT:-8100}/api/v1/reddit/callback + - BACKEND_API_REDDIT_API_BASE_URL=http://host.docker.internal:${E2E_MOCK_REDDIT_PORT:-3006}/api/v1 + - BACKEND_API_REDDIT_AUTHENTICATED_FEED_BASE_URL=http://host.docker.internal:${E2E_MOCK_REDDIT_PORT:-3006} + # Reddit tokens are encrypted at rest; any fixed 64-char hex key works for e2e. + - BACKEND_API_ENCRYPTION_KEY_HEX=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef - BACKEND_API_ENABLE_SUPPORTERS=true + # Small workspace feed limit so the over-limit enforcement spec can exercise + # disable/re-enable with a handful of seeded feeds. Other specs create at + # most a couple of feeds per workspace. + - BACKEND_API_DEFAULT_MAX_WORKSPACE_FEEDS=5 - LOG_LEVEL=debug - NODE_ENV=local command: npm run dev @@ -291,6 +325,12 @@ services: dockerfile: Dockerfile.dev working_dir: /usr/src/app/client restart: "no" + # Deliberately NOT bind-mounted (unlike web-api): vite transforms modules per + # request, and on a Windows host that I/O through the Docker file bridge made + # parallel-worker app loads exceed their timeouts (measured: 6/29 workspace + # specs failed on load). Client source changes therefore still require an + # image rebuild — use `build --no-cache web-client` (a cached `up --build` + # can silently reuse a stale COPY layer). depends_on: web-api: condition: service_healthy 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 67be4b7a5..65d4507a1 100644 --- a/e2e/e2e-mock.sh +++ b/e2e/e2e-mock.sh @@ -28,7 +28,7 @@ port_in_use() { # already held 3001/3002. instance_ports() { local off=$(($1 * STRIDE)) - echo "$((8100 + off)) $((3100 + off)) $((27019 + off)) $((3001 + off)) $((3002 + off))" + echo "$((8100 + off)) $((3100 + off)) $((27019 + off)) $((3001 + off)) $((3002 + off)) $((3006 + off))" } instance_is_free() { @@ -68,10 +68,12 @@ export E2E_FRONTEND_PORT=$((3100 + OFF)) export E2E_MONGO_PORT=$((27019 + OFF)) export E2E_MOCK_RSS_PORT=$((3001 + OFF)) export E2E_MOCK_DISCORD_PORT=$((3002 + OFF)) +export E2E_MOCK_REDDIT_PORT=$((3006 + OFF)) -# Enable the mandatory-Reddit-connection gate for the mocked suite so the -# gate-render flow can be exercised. Reddit OAuth itself is never hit (the gate -# short-circuits before any outbound request), so any non-empty id will do. +# Enable the mandatory-Reddit-connection gate for the mocked suite. Reddit OAuth +# and authenticated feed fetches are served by the host-side mock reddit server +# (see docker-compose.e2e.yml BACKEND_API_REDDIT_* env vars), so the full +# connect -> fetch flow can be exercised; any non-empty id will do. export BACKEND_API_REDDIT_CLIENT_ID="${BACKEND_API_REDDIT_CLIENT_ID:-e2e-reddit-client-id}" if [ "$E2E_INSTANCE" = 0 ]; then @@ -97,6 +99,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 @@ -104,6 +109,15 @@ mkdir -p "$LOG_DIR" # Its signing secret must be known before the backend boots (the backend reads # BACKEND_API_PADDLE_WEBHOOK_SECRET once at startup), so create it here, not in Playwright. # +# Only runs that include the e2e-paddle project (or target a billing spec directly) +# receive real Paddle webhooks; the mocked e2e-web project never does, so creating a +# setting for every CI shard only consumed Paddle's cap on active notification +# settings ("notification_maximum_active_settings_reached" killed shards at setup). +RUN_NEEDS_PADDLE_SETTING="" +case "$PLAYWRIGHT_ARGS" in + *e2e-paddle*|*billing*) RUN_NEEDS_PADDLE_SETTING=1 ;; +esac + # To use your OWN notification setting instead, set E2E_PADDLE_NOTIFICATION_SETTING_ID # (and the matching BACKEND_API_PADDLE_WEBHOOK_SECRET) in e2e/.env: the script then skips # create/delete and leaves your setting in place (only repointing its destination to the @@ -111,7 +125,7 @@ mkdir -p "$LOG_DIR" PADDLE_SETTING_EPHEMERAL="" if [ -n "${E2E_PADDLE_NOTIFICATION_SETTING_ID:-}" ]; then echo "Using provided Paddle notification setting: $E2E_PADDLE_NOTIFICATION_SETTING_ID" -elif [ -n "${BACKEND_API_PADDLE_KEY:-}" ]; then +elif [ -n "${BACKEND_API_PADDLE_KEY:-}" ] && [ -n "$RUN_NEEDS_PADDLE_SETTING" ]; then echo "Creating ephemeral Paddle notification setting..." created="$(npx tsx "$SCRIPT_DIR/scripts/paddle-notification-setting.ts" create)" PADDLE_SETTING_EPHEMERAL="$(echo "$created" | sed -n '1p')" @@ -126,11 +140,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 reddit; 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 @@ -141,10 +176,11 @@ else fi echo "Starting E2E Docker stack (instance: $E2E_INSTANCE, project: $COMPOSE_PROJECT_NAME)..." -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" +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 reddit-mock=$E2E_MOCK_REDDIT_PORT" # On startup failure, dump container status + logs to stdout before the EXIT trap -# tears the stack down — in CI this output is the only diagnosable record of why a -# container exited (e.g. web-api crashing before becoming healthy). +# tears the stack down — the live log follower below hasn't started yet, so this +# output is the only diagnosable record of why a container exited (e.g. web-api +# crashing before becoming healthy). if ! docker compose -f "$COMPOSE_FILE" -p "$COMPOSE_PROJECT_NAME" up -d --build --wait; then echo "E2E stack failed to start; container status and recent logs follow:" docker compose -f "$COMPOSE_FILE" -p "$COMPOSE_PROJECT_NAME" ps -a || true @@ -152,6 +188,21 @@ if ! docker compose -f "$COMPOSE_FILE" -p "$COMPOSE_PROJECT_NAME" up -d --build exit 1 fi +# 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/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/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..06ef5e50a 100644 --- a/e2e/helpers/constants.ts +++ b/e2e/helpers/constants.ts @@ -6,8 +6,34 @@ export const MOCK_RSS_FEED_500_URL = `http://${MOCK_RSS_HOST}:${MOCK_RSS_SERVER_ export const MOCK_RSS_FEED_403_URL = `http://${MOCK_RSS_HOST}:${MOCK_RSS_SERVER_PORT}/feed-403?v=${Date.now()}`; export const MOCK_RSS_FEED_404_URL = `http://${MOCK_RSS_HOST}:${MOCK_RSS_SERVER_PORT}/feed-404?v=${Date.now()}`; export const MOCK_RSS_HTML_PAGE_URL = `http://${MOCK_RSS_HOST}:${MOCK_RSS_SERVER_PORT}/html-with-feed?v=${Date.now()}`; +// A feed that starts healthy and can be flipped to failing mid-test (see the /flaky +// routes in mock-rss-server.ts). The feed URL is what the backend fetches (Docker -> +// host); the fail toggle is POSTed by the spec itself (host -> host). +export const mockRssFlakyFeedUrl = (key: string) => + `http://${MOCK_RSS_HOST}:${MOCK_RSS_SERVER_PORT}/flaky/${key}.xml`; +export const mockRssFlakyFeedFailUrl = (key: string) => + `http://localhost:${MOCK_RSS_SERVER_PORT}/flaky/${key}/fail`; 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; + +// Mock Reddit OAuth + authenticated-feed server. The backend (in Docker) and the +// browser popup both reach it via host.docker.internal (see docker-compose.e2e.yml +// BACKEND_API_REDDIT_* env vars). +export const MOCK_REDDIT_SERVER_PORT = + Number(process.env.E2E_MOCK_REDDIT_PORT) || 3006; + +// 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/paddle-api.ts b/e2e/helpers/paddle-api.ts index 068004df5..f77417750 100644 --- a/e2e/helpers/paddle-api.ts +++ b/e2e/helpers/paddle-api.ts @@ -72,6 +72,36 @@ const E2E_SUBSCRIBED_EVENTS = [ "subscription.canceled", ]; +const EPHEMERAL_DESCRIPTION_PREFIX = "MonitoRSS E2E (ephemeral)"; + +// A hard-killed CI runner never runs the teardown trap, so ephemeral settings leak +// until Paddle's cap on active notification settings blocks every future run. CI +// jobs time out at 30 minutes, so any ephemeral setting older than 2 hours (or one +// without a created= timestamp, predating it) belongs to a dead run. +export async function deleteStaleEphemeralNotificationSettings(): Promise { + const result = await paddleRequest< + PaddleListResponse<{ id: string; description?: string }> + >("/notification-settings"); + + const now = Date.now(); + for (const setting of result.data) { + const description = setting.description ?? ""; + if (!description.startsWith(EPHEMERAL_DESCRIPTION_PREFIX)) continue; + + const createdAt = description.match(/created=(\d+)/); + const ageMs = createdAt ? now - Number(createdAt[1]) : Infinity; + if (ageMs < 2 * 60 * 60 * 1000) continue; + + try { + await deleteNotificationSetting(setting.id); + // stderr: the create script's stdout is parsed (id/secret lines) by e2e-mock.sh. + console.error(`Deleted stale ephemeral notification setting: ${setting.id}`); + } catch (err) { + console.error(`Failed to delete stale notification setting ${setting.id}:`, err); + } + } +} + export async function createNotificationSetting(): Promise<{ id: string; secret: string; @@ -81,7 +111,7 @@ export async function createNotificationSetting(): Promise<{ }>("/notification-settings", { method: "POST", body: JSON.stringify({ - description: "MonitoRSS E2E (ephemeral)", + description: `${EPHEMERAL_DESCRIPTION_PREFIX} created=${Date.now()}`, type: "url", destination: "https://placeholder.invalid/paddle-webhook", subscribed_events: E2E_SUBSCRIBED_EVENTS, diff --git a/e2e/helpers/reddit-db.ts b/e2e/helpers/reddit-db.ts new file mode 100644 index 000000000..4babf19f1 --- /dev/null +++ b/e2e/helpers/reddit-db.ts @@ -0,0 +1,30 @@ +import { MongoClient, ObjectId } from "mongodb"; +import { MONGO_URI } from "./constants"; + +/** + * Seed a REVOKED personal Reddit credential directly, simulating a previously connected + * account whose grant has since died (revoked at Reddit / failed refresh). The reddit + * gate then renders its "Reconnect" state instead of the first-time connect copy. + */ +export async function seedRevokedRedditCredentialInDb(discordUserId: string): Promise { + const client = new MongoClient(MONGO_URI, { directConnection: true }); + + try { + await client.connect(); + await client + .db() + .collection("users") + .updateOne( + { discordUserId }, + { + $set: { + externalCredentials: [ + { _id: new ObjectId(), type: "reddit", status: "REVOKED", data: {} }, + ], + }, + }, + ); + } finally { + await client.close(); + } +} diff --git a/e2e/helpers/reddit-oauth.ts b/e2e/helpers/reddit-oauth.ts new file mode 100644 index 000000000..fc185fe75 --- /dev/null +++ b/e2e/helpers/reddit-oauth.ts @@ -0,0 +1,39 @@ +import type { Locator, Page } from "@playwright/test"; + +let subredditCounter = 0; + +/** + * A unique subreddit per call so parallel tests never collide on feed URLs. The mock reddit + * server serves RSS for ANY /r//.rss path with the channel title "r/", so the + * created feed's title is predictable for table assertions. + */ +export function uniqueSubreddit(): { url: string; title: string } { + const name = `e2e${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}${subredditCounter++}`; + return { url: `https://www.reddit.com/r/${name}/.rss`, title: `r/${name}` }; +} + +/** + * Complete the Reddit OAuth popup as a real user: click the connect button, then let the + * popup run /api/v1/reddit/login -> the mock reddit authorize endpoint (no consent screen) + * -> the backend callback (state validation + token exchange). The callback posts back to + * the opener and closes the popup, so the popup's self-close is the completion signal. + */ +export async function connectRedditViaPopup(page: Page, connectButton: Locator): Promise { + const popupPromise = page.waitForEvent("popup"); + await connectButton.click(); + const popup = await popupPromise; + + try { + // Generous timeout: the redirect chain spans the backend container and the + // host-side mock reddit server, which is slow under parallel-worker CI load. + await popup.waitForEvent("close", { timeout: 45000 }); + } catch (err) { + // The popup can finish its redirect chain and close before the listener attaches. + if (!popup.isClosed()) { + throw new Error( + `Reddit OAuth popup never closed (stuck on ${popup.url()})`, + { cause: err }, + ); + } + } +} diff --git a/e2e/helpers/smtp.ts b/e2e/helpers/smtp.ts new file mode 100644 index 000000000..929fb60e1 --- /dev/null +++ b/e2e/helpers/smtp.ts @@ -0,0 +1,155 @@ +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 = 15000, 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 = 15000, 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; +} + +export interface CapturedMailMessage { + subject: string | null; + body: string; +} + +/** + * Poll the mock mailer for the latest full message (subject + decoded body) + * captured for `email`. Used to assert on alert/digest emails whose content + * isn't a code or invite link. + */ +export async function waitForMail( + email: string, + { timeoutMs = 15000, 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}/message?to=${to}`); + if (res.ok) { + const { subject, body } = (await res.json()) as { + subject: string | null; + body: string | null; + }; + if (body !== null) return { subject, body }; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + throw new Error(`No mail captured for ${email} within ${timeoutMs}ms`); +} + +/** + * Non-throwing counterpart to waitForMail: polls for a short, fixed window and + * returns the captured message if one arrives, or null if none does. Used to + * ASSERT NON-DELIVERY without paying a long timeout for the expected-empty case. + */ +export async function peekMail( + 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}/message?to=${to}`); + if (res.ok) { + const { subject, body } = (await res.json()) as { + subject: string | null; + body: string | null; + }; + if (body !== null) return { subject, body }; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + return null; +} + +/** + * Clear captured mail before triggering a fresh send. Always pass the addresses + * the test is about to assert on: specs run in parallel workers against ONE + * shared catcher, so a global reset deletes mail that sibling specs are still + * polling for. The no-argument global form exists only for single-spec debugging. + */ +export async function resetCapturedMail(emails?: string | string[]): Promise { + if (emails === undefined) { + await fetch(`${SMTP_HTTP_URL}/reset`, { method: "POST" }); + return; + } + const list = Array.isArray(emails) ? emails : [emails]; + for (const email of list) { + const to = encodeURIComponent(email.trim().toLowerCase()); + await fetch(`${SMTP_HTTP_URL}/reset?to=${to}`, { method: "POST" }); + } +} diff --git a/e2e/helpers/workspaces-db.ts b/e2e/helpers/workspaces-db.ts new file mode 100644 index 000000000..870689daf --- /dev/null +++ b/e2e/helpers/workspaces-db.ts @@ -0,0 +1,302 @@ +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() }; + }); +} + +/** + * Opt a user into disabled-feed alert emails (the same preference the alerting + * settings page writes). Alert/digest recipients are filtered on this flag. + */ +export async function setDisabledFeedAlertPreferenceInDb(discordUserId: string): Promise { + await withUsersCollection((users) => + users.updateOne( + { discordUserId }, + { $set: { "preferences.alertOnDisabledFeeds": true } }, + { upsert: true }, + ), + ); +} + +/** + * Seed a co-member who can RECEIVE alert emails: a freshly minted user document + * with a verified email and the disabled-feed alert preference, plus an admin + * membership in the workspace. The member has no browser session — they exist to + * assert email fan-out, not to drive the UI. + */ +export async function seedAlertableWorkspaceMemberInDb(input: { + workspaceId: string; + email: string; +}): Promise { + await withDb(async (db) => { + const now = new Date(); + const memberUserId = new ObjectId(); + + await db.collection("users").insertOne({ + _id: memberUserId, + discordUserId: `member-${memberUserId.toHexString()}`, + verifiedEmail: input.email.trim().toLowerCase(), + verifiedEmailVerifiedAt: now, + preferences: { alertOnDisabledFeeds: true }, + }); + + await db.collection("workspacememberships").insertOne({ + workspaceId: new ObjectId(input.workspaceId), + userId: memberUserId, + role: "admin", + createdAt: now, + updatedAt: now, + }); + }); +} + +/** + * Seed workspace feeds directly. Creating feeds through the API can never exceed + * the workspace feed limit (creation is atomically gated), so tests that need an + * OVER-limit workspace — the state a billing-driven limit decrease produces — + * must write the feed documents themselves. `createdAt` is staggered in array + * order so "oldest first" enforcement is deterministic. + */ +export async function seedWorkspaceFeedsInDb(input: { + workspaceId: string; + userId: ObjectId; + discordUserId: string; + feeds: Array<{ title: string; url: string; disabledCode?: string }>; +}): Promise { + await withDb(async (db) => { + const base = Date.now() - input.feeds.length * 60_000; + + await db.collection("userfeeds").insertMany( + input.feeds.map((feed, index) => ({ + title: feed.title, + url: feed.url, + ...(feed.disabledCode ? { disabledCode: feed.disabledCode } : {}), + healthStatus: "OK", + connections: { discordChannels: [] }, + user: { id: input.userId, discordUserId: input.discordUserId }, + workspaceId: new ObjectId(input.workspaceId), + createdAt: new Date(base + index * 60_000), + updatedAt: new Date(base + index * 60_000), + })), + ); + }); +} + +/** + * 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; + }); +} + +/** + * Add a membership to an EXISTING workspace for an EXISTING real user — one with a live + * browser session who must drive the UI themselves. (The co-members created by + * `seedWorkspaceWithMembershipsInDb` get freshly minted user documents with no session, + * so they can never click anything.) + */ +export async function seedMembershipInDb(input: { + workspaceId: string; + userId: ObjectId; + role: "owner" | "admin"; +}): Promise { + await withDb(async (db) => { + const now = new Date(); + await db.collection("workspacememberships").insertOne({ + workspaceId: new ObjectId(input.workspaceId), + userId: input.userId, + role: input.role, + createdAt: now, + updatedAt: now, + }); + }); +} + +/** + * 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-reddit-server.ts b/e2e/mock-reddit-server.ts new file mode 100644 index 000000000..61831fb08 --- /dev/null +++ b/e2e/mock-reddit-server.ts @@ -0,0 +1,196 @@ +import { createServer } from "http"; +import { URL } from "url"; +import { MOCK_REDDIT_SERVER_PORT } from "./helpers/constants"; +import { teeConsoleToFile } from "./helpers/log-to-file"; + +teeConsoleToFile("mock-reddit"); + +// Stands in for BOTH reddit hosts the backend talks to: +// - www.reddit.com/api/v1/* (OAuth: authorize, access_token, revoke_token) +// - oauth.reddit.com/r/* (authenticated feed fetches with a Bearer token) +// +// Like the mock Discord server, the authorize endpoint skips the consent screen: +// it mints a code and immediately 302s back to redirect_uri with the state echoed +// byte-for-byte (the backend validates state against its session-stored nonce). +// Tokens are tracked so revocation has real consequences: a revoked refresh token +// fails the refresh grant with 400 (what the backend maps to "app revoked"), and +// its access tokens stop authenticating feed fetches. + +let grantCounter = 0; + +const activeAccessTokens = new Set(); +// refresh token -> access tokens minted under it (so revoke kills them all) +const refreshGrants = new Map>(); +const revokedRefreshTokens = new Set(); + +function mintGrant(): { accessToken: string; refreshToken: string } { + grantCounter += 1; + const accessToken = `mock-reddit-access-${grantCounter}`; + const refreshToken = `mock-reddit-refresh-${grantCounter}`; + activeAccessTokens.add(accessToken); + refreshGrants.set(refreshToken, new Set([accessToken])); + return { accessToken, refreshToken }; +} + +function parseForm(body: string): Record { + return Object.fromEntries(new URLSearchParams(body)); +} + +function json( + res: import("http").ServerResponse, + status: number, + body: unknown, +): void { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body)); +} + +function subredditRss(pathname: string): string { + const subreddit = pathname.match(/^\/r\/([^/]+)/)?.[1] ?? "unknown"; + return ` + + + r/${subreddit} + https://www.reddit.com/r/${subreddit}/ + Mock subreddit feed for E2E tests + + ${subreddit} post one + https://www.reddit.com/r/${subreddit}/comments/1/post-one/ + First mock post in r/${subreddit} + Thu, 04 Jan 2024 00:00:00 GMT + + + ${subreddit} post two + https://www.reddit.com/r/${subreddit}/comments/2/post-two/ + Second mock post in r/${subreddit} + Wed, 03 Jan 2024 00:00:00 GMT + + +`; +} + +const server = createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + + req.on("end", () => { + const url = new URL( + req.url || "/", + `http://localhost:${MOCK_REDDIT_SERVER_PORT}`, + ); + const method = req.method || "GET"; + const rawBody = Buffer.concat(chunks).toString(); + + // Log every request: this is the audit trail proving reddit traffic (OAuth AND + // authenticated feed fetches) hit the mock rather than reddit.com. + const auth = req.headers.authorization; + const authSummary = !auth + ? "none" + : auth.startsWith("Bearer ") + ? `bearer=${auth.slice(7)}` + : "basic"; + console.log(`[mock-reddit] ${method} ${url.pathname} auth=${authSummary}`); + + if (method === "GET" && url.pathname === "/api/v1/authorize") { + const redirectUri = url.searchParams.get("redirect_uri"); + const state = url.searchParams.get("state"); + + if (!redirectUri) { + return json(res, 400, { error: "missing redirect_uri" }); + } + + grantCounter += 1; + const location = new URL(redirectUri); + location.searchParams.set("code", `mock-reddit-code-${grantCounter}`); + if (state !== null) { + location.searchParams.set("state", state); + } + + res.writeHead(302, { Location: location.toString() }); + return res.end(); + } + + if (method === "POST" && url.pathname === "/api/v1/access_token") { + if (!req.headers.authorization?.startsWith("Basic ")) { + return json(res, 401, { error: "missing basic auth" }); + } + + const form = parseForm(rawBody); + + if (form.grant_type === "authorization_code") { + if (!/^mock-reddit-code-\d+$/.test(form.code ?? "")) { + return json(res, 400, { error: "invalid_grant" }); + } + + const { accessToken, refreshToken } = mintGrant(); + return json(res, 200, { + access_token: accessToken, + token_type: "bearer", + expires_in: 3600, + refresh_token: refreshToken, + scope: "read", + }); + } + + if (form.grant_type === "refresh_token") { + const refreshToken = form.refresh_token ?? ""; + const grant = refreshGrants.get(refreshToken); + + // Reddit responds 400 to refresh attempts on a revoked/unknown grant; + // the backend maps that to RedditAppRevokedException. + if (!grant || revokedRefreshTokens.has(refreshToken)) { + return json(res, 400, { error: "invalid_grant" }); + } + + grantCounter += 1; + const accessToken = `mock-reddit-access-${grantCounter}`; + activeAccessTokens.add(accessToken); + grant.add(accessToken); + return json(res, 200, { + access_token: accessToken, + token_type: "bearer", + expires_in: 3600, + refresh_token: refreshToken, + scope: "read", + }); + } + + return json(res, 400, { error: "unsupported_grant_type" }); + } + + if (method === "POST" && url.pathname === "/api/v1/revoke_token") { + const form = parseForm(rawBody); + const token = form.token ?? ""; + + revokedRefreshTokens.add(token); + for (const accessToken of refreshGrants.get(token) ?? []) { + activeAccessTokens.delete(accessToken); + } + + res.writeHead(204); + return res.end(); + } + + // oauth.reddit.com stand-in: authenticated subreddit feed fetches. + if (method === "GET" && url.pathname.startsWith("/r/")) { + const bearer = req.headers.authorization?.match(/^Bearer (.+)$/)?.[1]; + + if (!bearer || !activeAccessTokens.has(bearer)) { + res.writeHead(403); + return res.end("Forbidden"); + } + + res.writeHead(200, { "Content-Type": "application/rss+xml" }); + return res.end(subredditRss(url.pathname)); + } + + console.error(`[mock-reddit] Unmatched: ${method} ${url.pathname}`); + return json(res, 404, { error: "not found (mock)" }); + }); +}); + +server.listen(MOCK_REDDIT_SERVER_PORT, () => + console.log( + `Mock Reddit server on http://localhost:${MOCK_REDDIT_SERVER_PORT}`, + ), +); diff --git a/e2e/mock-rss-server.ts b/e2e/mock-rss-server.ts index d94c6f2dc..05895b3ce 100644 --- a/e2e/mock-rss-server.ts +++ b/e2e/mock-rss-server.ts @@ -3,6 +3,16 @@ 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"); + +// Feeds that must START healthy and LATER fail: a feed cannot be created against a +// failing URL (creation validates it), so specs that need an established-then-broken +// feed create it against /flaky/.xml and then POST /flaky//fail to flip +// that key to 500 for the rest of the run. Keys are per-spec-generated, so parallel +// workers never interfere with each other's feeds. +const failedFlakyKeys = new Set(); const server = createServer((req, res) => { const parsedUrl = new URL( @@ -16,6 +26,26 @@ const server = createServer((req, res) => { ); res.writeHead(200, { "Content-Type": "application/rss+xml" }); res.end(rss); + } else if (/^\/flaky\/[^/]+\.xml$/.test(parsedUrl.pathname)) { + const key = parsedUrl.pathname.slice("/flaky/".length, -".xml".length); + if (failedFlakyKeys.has(key)) { + res.writeHead(500); + res.end("Internal Server Error"); + } else { + const rss = readFileSync( + join(__dirname, "fixtures", "test-feed.xml"), + "utf-8", + ); + res.writeHead(200, { "Content-Type": "application/rss+xml" }); + res.end(rss); + } + } else if ( + req.method === "POST" && + /^\/flaky\/[^/]+\/fail$/.test(parsedUrl.pathname) + ) { + failedFlakyKeys.add(parsedUrl.pathname.split("/")[2]); + res.writeHead(200); + res.end("OK"); } else if (parsedUrl.pathname === "/feed-500") { res.writeHead(500); res.end("Internal Server Error"); diff --git a/e2e/mock-smtp-server.ts b/e2e/mock-smtp-server.ts new file mode 100644 index 000000000..56ef67022 --- /dev/null +++ b/e2e/mock-smtp-server.ts @@ -0,0 +1,278 @@ +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; + subject: string | null; + // All decoded candidate renderings of the body joined together, so tests can + // substring-match content regardless of transfer encoding. + body: string; + 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; +} + +// MIME-decode a Subject header. Handles the common nodemailer encodings: plain +// ASCII, RFC 2047 B (base64) and Q (quoted-printable-ish) encoded words. +function decodeSubjectValue(value: string): string { + return value.replace( + /=\?[^?]+\?([BQbq])\?([^?]*)\?=/g, + (_m, enc: string, text: string) => { + if (enc.toUpperCase() === "B") { + try { + return Buffer.from(text, "base64").toString("utf8"); + } catch { + return text; + } + } + return text + .replace(/_/g, " ") + .replace(/=([0-9A-Fa-f]{2})/g, (_m2, hex) => + String.fromCharCode(parseInt(hex, 16)), + ); + }, + ); +} + +function extractSubject(raw: string): string | null { + const headers = raw.slice(0, raw.search(/\r?\n\r?\n/) + 1 || raw.length); + // Unfold header continuation lines before matching. Folding can leave double + // spaces at the fold points; collapse them so tests see the logical subject. + const unfolded = headers.replace(/\r?\n[ \t]+/g, " "); + const match = /^Subject:[ \t]*(.+)$/im.exec(unfolded); + return match + ? decodeSubjectValue(match[1].trim()).replace(/\s+/g, " ") + : 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); + const subject = extractSubject(message); + const body = decodeBodyCandidates(message).join("\n"); + // eslint-disable-next-line no-console + console.log( + `[mock-smtp] captured for ${recipients.join(",")}: subject=${subject ?? "NONE"} code=${code ?? "NONE"} link=${inviteLink ?? "NONE"} (raw ${message.length} bytes)`, + ); + for (const to of recipients) { + mailboxes.set(to, { to, code, inviteLink, subject, body, 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 === "/message") { + const to = (url.searchParams.get("to") || "").toLowerCase(); + const captured = mailboxes.get(to); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify( + captured + ? { subject: captured.subject, body: captured.body, receivedAt: captured.receivedAt } + : { subject: null, body: null, receivedAt: null }, + ), + ); + return; + } + + if (url.pathname === "/reset") { + // Scoped reset (?to=) so parallel specs can clear their own recipient + // without wiping mail that other workers are still polling for. + const to = url.searchParams.get("to"); + if (to) { + mailboxes.delete(to.toLowerCase()); + } else { + 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 9d2899c01..6c5bfd485 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -2,6 +2,8 @@ import { defineConfig, devices } from "@playwright/test"; import { MOCK_RSS_SERVER_PORT, MOCK_DISCORD_SERVER_PORT, + MOCK_SMTP_HTTP_PORT, + MOCK_REDDIT_SERVER_PORT, } from "./helpers/constants"; const INSTANCE = process.env.E2E_INSTANCE || "0"; @@ -41,6 +43,19 @@ export default defineConfig({ baseURL: process.env.E2E_BASE_URL || "http://localhost:3000", trace: "on-first-retry", headless: !!process.env.CI, + // Backend-issued OAuth redirects (mock Discord login, mock Reddit authorize) + // point the BROWSER at host.docker.internal so the same base URL also works + // for containers reaching the host-side mock servers. Docker Desktop adds + // that hostname to the host's hosts file, but plain Linux (CI runners) does + // not, so without this the popup dies on chrome-error://chromewebdata/. + // Resolve it inside the browser ONLY: a host-level /etc/hosts entry is not + // equivalent, because Docker's embedded DNS forwards container queries to + // the host resolver, and the backend's mailer resolves via dns.resolve4 + // (bypassing the container's own hosts file) — a host entry of 127.0.0.1 + // leaks into the container and breaks SMTP delivery to the mock mailer. + launchOptions: { + args: ["--host-resolver-rules=MAP host.docker.internal 127.0.0.1"], + }, }, projects: [ { @@ -72,16 +87,39 @@ 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", + }, + { + command: "npx tsx mock-reddit-server.ts", + port: MOCK_REDDIT_SERVER_PORT, + reuseExistingServer: false, + stdout: "pipe", + stderr: "pipe", }, ], }); diff --git a/e2e/scripts/paddle-notification-setting.ts b/e2e/scripts/paddle-notification-setting.ts index 438a00aaa..06ee3f335 100644 --- a/e2e/scripts/paddle-notification-setting.ts +++ b/e2e/scripts/paddle-notification-setting.ts @@ -1,12 +1,18 @@ import { createNotificationSetting, deleteNotificationSetting, + deleteStaleEphemeralNotificationSettings, } from "../helpers/paddle-api"; async function main() { const command = process.argv[2]; if (command === "create") { + // Best-effort: reclaiming settings leaked by killed runs must not block this + // run, but skipping it when the cap is already reached would. + await deleteStaleEphemeralNotificationSettings().catch((err) => { + console.warn("Stale notification-setting cleanup failed:", err); + }); const { id, secret } = await createNotificationSetting(); process.stdout.write(`${id}\n${secret}\n`); return; diff --git a/e2e/tests/feeds/disabled-feed-health-alert.spec.ts b/e2e/tests/feeds/disabled-feed-health-alert.spec.ts new file mode 100644 index 000000000..ed41fbade --- /dev/null +++ b/e2e/tests/feeds/disabled-feed-health-alert.spec.ts @@ -0,0 +1,82 @@ +import { test, expect } from "../../fixtures/test-fixtures"; +import { + createFeed, + deleteFeed, + bulkDisableFeeds, + generateTestId, +} from "../../helpers/api"; +import { + mockRssFlakyFeedUrl, + mockRssFlakyFeedFailUrl, +} from "../../helpers/constants"; + +// A disabled feed is not polled, so the "Requests are currently failing" health alert +// (and its retry CTA) must not render alongside the disabled alert — the disabled +// alert is the sole banner explaining the feed's state. +test.describe("Disabled feed health alert suppression", () => { + test("hides the failing-requests alert once the feed is disabled", async ({ + page, + }) => { + const key = generateTestId(); + let feedId: string | undefined; + + try { + // The flaky URL validates healthy at creation, then every later fetch fails. + const feed = await createFeed(page, { + url: mockRssFlakyFeedUrl(key), + title: `Health Alert Feed ${key}`, + }); + feedId = feed.id; + + await page.request.post(mockRssFlakyFeedFailUrl(key)); + + // Record one failing attempt (same endpoint the UI's retry button uses), so the + // feed's latest request is a failure. + const manualRequest = await page.request.post( + `/api/v1/user-feeds/${feed.id}/manual-request`, + ); + expect(manualRequest.ok()).toBeTruthy(); + + // Positive control: while the feed is ENABLED, the health alert shows. + await page.goto("/feeds"); + await expect(page.getByRole("table")).toBeVisible({ timeout: 10000 }); + await page.getByRole("link", { name: feed.title, exact: true }).click(); + await expect( + page.getByText("Requests are currently failing"), + ).toBeVisible({ timeout: 15000 }); + await expect( + page.getByRole("button", { name: "Retry feed request" }), + ).toBeVisible(); + + // Disable the feed (API setup; the UI disable flow is covered by + // bulk-toggle-feeds.spec.ts) and revisit its page the way a user would. + await bulkDisableFeeds(page, [feed.id]); + await page.goto("/feeds"); + await expect(page.getByRole("table")).toBeVisible({ timeout: 10000 }); + await expect( + page + .getByRole("row") + .filter({ + has: page.getByRole("link", { name: feed.title, exact: true }), + }) + .getByLabel("Manually disabled"), + ).toBeVisible({ timeout: 10000 }); + await page.getByRole("link", { name: feed.title, exact: true }).click(); + + // The disabled alert is the only banner; the health alert and retry CTA are gone. + await expect( + page.getByText("This feed has been manually disabled"), + ).toBeVisible({ timeout: 15000 }); + await expect( + page.getByText("Requests are currently failing"), + ).not.toBeVisible(); + await expect( + page.getByRole("button", { name: "Retry feed request" }), + ).not.toBeVisible(); + } finally { + if (feedId) { + await deleteFeed(page, feedId).catch(() => {}); + } + } + }); +}); diff --git a/e2e/tests/feeds/reddit-oauth.spec.ts b/e2e/tests/feeds/reddit-oauth.spec.ts new file mode 100644 index 000000000..32e2c7d47 --- /dev/null +++ b/e2e/tests/feeds/reddit-oauth.spec.ts @@ -0,0 +1,149 @@ +import { test, expect } from "../../fixtures/test-fixtures"; +import { getDiscordUserIdFromPage } from "../../helpers/paddle-db"; +import { seedRevokedRedditCredentialInDb } from "../../helpers/reddit-db"; +import { connectRedditViaPopup, uniqueSubreddit } from "../../helpers/reddit-oauth"; + +// Personal-scope Reddit OAuth through the real (mocked) popup flow: the connect button +// opens /api/v1/reddit/login, which round-trips the mock reddit server's authorize +// endpoint and the backend callback (session-stored state validation, token exchange), +// then posts back to the opener. Adding the feed afterwards proves the stored grant +// actually authenticates fetches: the mock serves subreddit RSS only to requests +// carrying a Bearer token it issued, and 403s everything else. + +test.describe("Reddit OAuth (personal)", () => { + test("connecting through the gate popup auto-retries and adds the subreddit feed", async ({ + page, + }) => { + test.setTimeout(90000); + const { url, title } = uniqueSubreddit(); + + await page.goto("/feeds"); + 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(url); + await page.getByRole("button", { name: "Go", exact: true }).click(); + + await expect(page.getByText("Connect your Reddit account to continue")).toBeVisible({ + timeout: 30000, + }); + + await connectRedditViaPopup( + page, + page.getByRole("button", { name: "Connect Reddit in popup window" }), + ); + + // The popup's completion auto-retries the blocked validation with the new grant: + // the gate clears and the feed card appears without any further input. + const addButton = page.getByRole("button", { name: /^Add .+ feed$/i }).first(); + await expect(addButton).toBeVisible({ timeout: 30000 }); + await expect(page.getByText("Connect your Reddit account to continue")).toHaveCount(0); + + await addButton.click(); + await expect(page.getByText(/1 feed added/)).toBeVisible({ timeout: 10000 }); + + // The feed (titled from the authenticated fetch's channel title) is in the table. + await page.getByRole("button", { name: /View your feeds/ }).click(); + await expect(page.getByRole("table")).toBeVisible({ timeout: 15000 }); + // exact-named link: the row also renders the URL link, which contains the title. + await expect( + page.getByRole("table").getByRole("link", { name: title, exact: true }), + ).toBeVisible(); + }); + + test("a dead connection shows the reconnect prompt and reconnecting revives feed adds", async ({ + page, + }) => { + test.setTimeout(90000); + const { url, title } = uniqueSubreddit(); + + await page.goto("/feeds"); + await expect( + page.getByRole("heading", { name: "Get news delivered to your Discord" }), + ).toBeVisible({ timeout: 15000 }); + + // A previously connected account whose grant has died (e.g. revoked at Reddit). + const discordUserId = await getDiscordUserIdFromPage(page); + await seedRevokedRedditCredentialInDb(discordUserId); + await page.reload(); + 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(url); + await page.getByRole("button", { name: "Go", exact: true }).click(); + + // The gate distinguishes a dead connection from never-connected. + await expect(page.getByText("Reconnect your Reddit account")).toBeVisible({ + timeout: 30000, + }); + await expect(page.getByText(/no longer active/i)).toBeVisible(); + + await connectRedditViaPopup( + page, + page.getByRole("button", { name: "Reconnect Reddit in popup window" }), + ); + + const addButton = page.getByRole("button", { name: /^Add .+ feed$/i }).first(); + await expect(addButton).toBeVisible({ timeout: 30000 }); + + await addButton.click(); + await expect(page.getByText(/1 feed added/)).toBeVisible({ timeout: 10000 }); + + await page.getByRole("button", { name: /View your feeds/ }).click(); + await expect(page.getByRole("table")).toBeVisible({ timeout: 15000 }); + // exact-named link: the row also renders the URL link, which contains the title. + await expect( + page.getByRole("table").getByRole("link", { name: title, exact: true }), + ).toBeVisible(); + }); + + test("connecting from the edit-feed dialog auto-retries the blocked save", async ({ + page, + testFeed, + }) => { + test.setTimeout(90000); + const { url } = uniqueSubreddit(); + + await page.goto(`/feeds/${testFeed.id}`); + await expect(page.getByRole("heading", { name: testFeed.title })).toBeVisible({ + timeout: 10000, + }); + + await page.getByRole("button", { name: "Feed Actions" }).click(); + await page.getByRole("menuitem", { name: "Edit" }).click(); + + const editDialog = page.getByRole("dialog"); + const urlInput = editDialog.getByLabel("RSS Feed Link"); + await expect(urlInput).toBeVisible({ timeout: 10000 }); + await urlInput.fill(url); + await editDialog.getByRole("button", { name: "Save" }).click(); + + await expect(page.getByText("Connect your Reddit account to continue")).toBeVisible({ + timeout: 30000, + }); + + await connectRedditViaPopup( + page, + page.getByRole("button", { name: "Connect Reddit in popup window" }), + ); + + // The dialog owns the retry on the connected edge: the blocked save re-runs with the + // new grant and the dialog closes itself on success. + await expect(editDialog).not.toBeVisible({ timeout: 30000 }); + + // The URL change stuck: reopening the editor shows the subreddit URL. + await page.getByRole("button", { name: "Feed Actions" }).click(); + await page.getByRole("menuitem", { name: "Edit" }).click(); + await expect(page.getByRole("dialog").getByLabel("RSS Feed Link")).toHaveValue(url, { + timeout: 10000, + }); + }); +}); diff --git a/e2e/tests/message-builder/message-builder-v1.spec.ts b/e2e/tests/message-builder/message-builder-v1.spec.ts index d319500ec..1fab4428a 100644 --- a/e2e/tests/message-builder/message-builder-v1.spec.ts +++ b/e2e/tests/message-builder/message-builder-v1.spec.ts @@ -232,6 +232,7 @@ test.describe("Message Builder V1", () => { page, testFeedWithConnection, }) => { + test.setTimeout(120_000); const { feed, connection } = testFeedWithConnection; await page.goto( diff --git a/e2e/tests/message-builder/message-builder-v2.spec.ts b/e2e/tests/message-builder/message-builder-v2.spec.ts index 24863596e..6dfd9a3de 100644 --- a/e2e/tests/message-builder/message-builder-v2.spec.ts +++ b/e2e/tests/message-builder/message-builder-v2.spec.ts @@ -415,6 +415,7 @@ test.describe("Message Builder V2", () => { page, testFeedWithConnection, }) => { + test.setTimeout(120_000); const { feed, connection } = testFeedWithConnection; await page.goto( 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..5a5e84a4b --- /dev/null +++ b/e2e/tests/workspaces/email-verification-resend.spec.ts @@ -0,0 +1,87 @@ +import { test, expect, type Page } from "../../fixtures/test-fixtures"; +import { getDiscordUserIdFromPage } from "../../helpers/paddle-db"; +import { enableWorkspacesFeatureInDb } from "../../helpers/workspaces-db"; +import { + peekVerificationCode, + waitForVerificationCode, + 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); + + const email = `resend-${discordUserId}@example.com`; + await resetCapturedMail(email); + await openCreateTeamVerifyStep(page); + + const dialog = page.getByRole("dialog"); + const emailInput = dialog.getByLabel("Email address"); + + 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. + // (waitFor, not peek: this is a DELIVERY assertion and must tolerate slow CI.) + await waitForVerificationCode(email); + + // 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(email); + 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..365323e56 --- /dev/null +++ b/e2e/tests/workspaces/email-verification-target-cap.spec.ts @@ -0,0 +1,81 @@ +import { test, expect, type Page } from "../../fixtures/test-fixtures"; +import { getDiscordUserIdFromPage } from "../../helpers/paddle-db"; +import { enableWorkspacesFeatureInDb } from "../../helpers/workspaces-db"; +import { + peekVerificationCode, + waitForVerificationCode, + 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( + [0, 1, 2, 3, 4, 6].map((i) => `cap-${i}-${discordUserId}@example.com`), + ); + 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. + // (waitFor, not peek: this is a DELIVERY assertion and must tolerate slow CI.) + await waitForVerificationCode(email); + + 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-limit-enforcement.spec.ts b/e2e/tests/workspaces/workspace-feed-limit-enforcement.spec.ts new file mode 100644 index 000000000..08ab4a1d7 --- /dev/null +++ b/e2e/tests/workspaces/workspace-feed-limit-enforcement.spec.ts @@ -0,0 +1,225 @@ +import { test, expect, type Page } from "../../fixtures/test-fixtures"; +import type { Locator } from "@playwright/test"; +import { getDiscordUserIdFromPage } from "../../helpers/paddle-db"; +import { + enableWorkspacesFeatureInDb, + setVerifiedEmailInDb, + setDisabledFeedAlertPreferenceInDb, + getUserMongoIdFromDiscordId, + seedWorkspaceWithMembershipsInDb, + seedAlertableWorkspaceMemberInDb, + seedWorkspaceFeedsInDb, +} from "../../helpers/workspaces-db"; +import { waitForMail, peekMail, resetCapturedMail } from "../../helpers/smtp"; +import { MOCK_RSS_FEED_URL } from "../../helpers/constants"; + +// Workspace feed-limit enforcement: the over-limit state only arises when a +// workspace's limit DROPS below its existing feed count (the future billing +// downgrade), so the feeds are seeded directly — creation through the API is +// atomically gated and can never exceed the limit. The stack runs with +// BACKEND_API_DEFAULT_MAX_WORKSPACE_FEEDS=5 (docker-compose.e2e.yml). +// +// The user-reachable trigger exercised here is bulk-enable: enabling a paused +// feed pushes the enabled count over the limit, and post-mutation enforcement +// disables the OLDEST feed and emails a digest to all opted-in members. Freeing +// headroom (deleting a feed) silently re-enables the disabled feed. + +const WORKSPACE_FEED_LIMIT = 5; + +async function waitForAuthenticatedApp(page: Page): Promise { + await expect(page.getByRole("button", { name: "Account settings" })).toBeVisible({ + timeout: 15000, + }); +} + +async function clickFeedCheckbox(page: Page, feedTitle: string) { + // Chakra v3 renders a visual control over the checkbox input; force-click the + // input layer (same idiom as bulk-toggle-feeds.spec.ts). A table refetch can + // re-render the row and drop the selection, so verify it registered (an + // unselected feed silently disables the menu action under test). + const checkbox = page.getByRole("checkbox", { + name: `Check feed ${feedTitle} for bulk actions`, + }); + + for (let attempt = 0; attempt < 3; attempt += 1) { + await checkbox.scrollIntoViewIfNeeded(); + await checkbox.click({ force: true }); + try { + await expect(checkbox).toBeChecked({ timeout: 2000 }); + return; + } catch { + // re-click — the row re-rendered underneath the click + } + } + await expect(checkbox).toBeChecked(); +} + +function feedRow(page: Page, feedTitle: string): Locator { + return page.getByRole("row").filter({ + has: page.getByRole("link", { name: feedTitle, exact: true }), + }); +} + +// Pointer clicks on Ark menu items flake while the menu is animating/repositioning +// ("element is not stable"); keyboard navigation is the reliable idiom (see +// bulk-toggle-feeds.spec.ts keyboard test). Opens the Feed Actions menu, walks the +// highlight to the target item with ArrowDown, and activates it with Enter. +async function chooseFeedAction(page: Page, itemText: string) { + const trigger = page.getByRole("button", { name: "Feed Actions" }); + const menu = page.getByRole("menu"); + + // A just-opened menu can be closed underneath us by a page re-render (e.g. + // the feed-list refetch right after navigation), and until Ark moves focus + // onto the menu content, Enter hits the trigger and toggles it shut. Re-open + // until the menu is open AND focused, then it's safe to drive with keys. + for (let attempt = 0; attempt < 3; attempt += 1) { + await trigger.focus(); + await page.keyboard.press("Enter"); + try { + await expect(menu).toBeVisible({ timeout: 5000 }); + await expect(menu).toBeFocused({ timeout: 3000 }); + break; + } catch { + await page.keyboard.press("Escape"); + } + } + await expect(menu).toBeFocused(); + + // Ark highlights the first enabled item on open; wait for that before walking, + // or an early ArrowDown skips past it. + await expect(page.locator('[role="menuitem"][data-highlighted]')).toBeVisible({ + timeout: 10000, + }); + + const item = page.getByRole("menuitem").filter({ hasText: itemText }); + for (let i = 0; i < 12; i += 1) { + if ((await item.getAttribute("data-highlighted")) !== null) break; + await page.keyboard.press("ArrowDown"); + } + await expect(item).toHaveAttribute("data-highlighted", "", { timeout: 5000 }); + await page.keyboard.press("Enter"); +} + +test.describe("Workspace feed limit enforcement", () => { + test("disables the oldest feed with a member digest email when enabling pushes the workspace over its limit, and silently re-enables when headroom returns", async ({ + page, + }) => { + // Two UI phases plus email polling exceed the default 30s budget. + test.setTimeout(120_000); + + await page.goto("/feeds"); + await waitForAuthenticatedApp(page); + + const discordUserId = await getDiscordUserIdFromPage(page); + const selfEmail = `verified-${discordUserId}@example.com`; + const memberEmail = `member-${discordUserId}@example.com`; + + await enableWorkspacesFeatureInDb(discordUserId); + await setVerifiedEmailInDb(discordUserId, selfEmail); + await setDisabledFeedAlertPreferenceInDb(discordUserId); + + const selfUserId = await getUserMongoIdFromDiscordId(discordUserId); + const workspaceName = `E2E Limit WS ${Date.now()}`; + const { workspaceId, slug } = await seedWorkspaceWithMembershipsInDb({ + workspaceName, + selfUserId, + selfRole: "owner", + }); + await seedAlertableWorkspaceMemberInDb({ workspaceId, email: memberEmail }); + + // 5 enabled feeds (at the limit) plus a manually-paused 6th: the state a + // limit decrease leaves behind. "Oldest Feed" is created first. + await seedWorkspaceFeedsInDb({ + workspaceId, + userId: selfUserId, + discordUserId, + feeds: [ + { title: "Oldest Feed", url: MOCK_RSS_FEED_URL }, + ...Array.from({ length: WORKSPACE_FEED_LIMIT - 1 }, (_, i) => ({ + title: `Filler Feed ${i + 1}`, + url: MOCK_RSS_FEED_URL, + })), + { title: "Paused Feed", url: MOCK_RSS_FEED_URL, disabledCode: "MANUAL" }, + ], + }); + + await resetCapturedMail([selfEmail, memberEmail]); + + // Enter the workspace through the switcher and anchor on the committed scope. + await page.reload(); + await waitForAuthenticatedApp(page); + await page.getByRole("button", { name: /Switch team/ }).click(); + await page.getByRole("menuitemradio", { name: workspaceName }).click(); + await expect(page).toHaveURL(new RegExp(`/workspaces/${slug}/feeds$`), { timeout: 15000 }); + await expect( + page.getByRole("button", { name: `Switch team, current: ${workspaceName}` }), + ).toBeVisible({ timeout: 15000 }); + + await expect(page.getByRole("table")).toBeVisible({ timeout: 15000 }); + await expect(feedRow(page, "Paused Feed").getByLabel("Manually disabled")).toBeVisible({ + timeout: 10000, + }); + await expect(feedRow(page, "Oldest Feed").getByLabel("Ok")).toBeVisible(); + + // Enable the paused feed: the workspace is now one over its limit, so + // enforcement disables the oldest feed. + await clickFeedCheckbox(page, "Paused Feed"); + await chooseFeedAction(page, "Enable"); + + const confirmDialog = page.getByRole("alertdialog"); + await expect(confirmDialog).toBeVisible({ timeout: 10000 }); + await confirmDialog.getByRole("button", { name: "Confirm", exact: true }).click(); + + await expect( + page.getByRole("alert").filter({ hasText: "Successfully enabled feeds" }), + ).toBeVisible({ timeout: 30000 }); + + await expect(feedRow(page, "Paused Feed").getByLabel("Ok")).toBeVisible({ timeout: 10000 }); + await expect( + feedRow(page, "Oldest Feed").getByLabel("Disabled (feed limit exceeded)"), + ).toBeVisible({ + timeout: 10000, + }); + + // A limit-disabled feed is not a health problem: the requires-attention banner + // (driven by the computed-status count) must not appear for it. + await expect(page.getByText(/requires? your attention/)).not.toBeVisible(); + + // The feed's own page explains the workspace-scoped reason. (Chakra v3 + // Alert.Root renders without role=alert, so match the copy itself.) + await page.getByRole("link", { name: "Oldest Feed", exact: true }).click(); + await expect( + page.getByText("disabled because the workspace is over its feed limit"), + ).toBeVisible({ timeout: 15000 }); + + // Every opted-in member receives one digest naming the disabled feed. + for (const email of [selfEmail, memberEmail]) { + const mail = await waitForMail(email); + expect(mail.subject).toContain(workspaceName); + expect(mail.subject).toContain("feed limit exceeded"); + expect(mail.body).toContain("Oldest Feed"); + expect(mail.body).toContain(`/workspaces/${slug}/feeds`); + } + + // Free headroom by deleting a feed through the UI: the ExceededFeedLimit feed + // comes back automatically — and silently (no new email). + await resetCapturedMail([selfEmail, memberEmail]); + await page.goBack(); + await expect(page.getByRole("table")).toBeVisible({ timeout: 15000 }); + + await clickFeedCheckbox(page, "Paused Feed"); + await chooseFeedAction(page, "Delete"); + + const deleteDialog = page.getByRole("alertdialog"); + await expect(deleteDialog).toBeVisible({ timeout: 10000 }); + await deleteDialog.getByRole("button", { name: "Delete", exact: true }).click(); + + await expect( + page.getByRole("alert").filter({ hasText: "Successfully deleted feeds" }), + ).toBeVisible({ timeout: 30000 }); + + await expect(feedRow(page, "Oldest Feed").getByLabel("Ok")).toBeVisible({ timeout: 10000 }); + expect(await peekMail(selfEmail)).toBeNull(); + expect(await peekMail(memberEmail)).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..375c35d17 --- /dev/null +++ b/e2e/tests/workspaces/workspace-invitation-roundtrip.spec.ts @@ -0,0 +1,209 @@ +import { test, expect, type Page, newInstrumentedContext } 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, 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 { + 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. +// 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 newInstrumentedContext(browser, test.info()); + const page = await context.newPage(); + + try { + // Open the invitation while logged out -> OAuth bootstrap (a brand-new user). + // 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. + await enableWorkspacesFeatureInDb(discordUserId); + await page.goto(inviteLink); + 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({ + 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, + }) => { + // 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); + + 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(invitedEmail); + + 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..164057ee2 --- /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([ownerEmail, invitedEmail]); + + // --- 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(invitedEmail); + 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..7696a8e83 --- /dev/null +++ b/e2e/tests/workspaces/workspace-invite-verification-guard.spec.ts @@ -0,0 +1,67 @@ +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, + }); + + // Type a clearly-unrelated address and attempt to send the code. + const unrelatedEmail = `attacker-${discordUserId}@evil.example.net`; + await resetCapturedMail(unrelatedEmail); + + await page.goto(`/invites/${inviteId}`); + await expect( + page.getByRole("heading", { name: new RegExp(workspaceName) }), + ).toBeVisible({ timeout: 15000 }); + + 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..f71ef928d --- /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(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, 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/workspace-navigation.spec.ts b/e2e/tests/workspaces/workspace-navigation.spec.ts new file mode 100644 index 000000000..0341e53bb --- /dev/null +++ b/e2e/tests/workspaces/workspace-navigation.spec.ts @@ -0,0 +1,188 @@ +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 navigation: the "/" landing restores the last-active scope, the logo is +// scope-relative, workspace feeds pages expose settings on-page, and breadcrumb roots +// carry the scope name. + +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; +} + +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(); +} + +// The last-active scope is recorded with a fire-and-forget PATCH; start listening +// BEFORE the scope change that triggers it so a later "/" visit deterministically +// sees the recorded value. +function waitForScopeRecording(page: Page): Promise { + return page.waitForResponse( + (response) => + response.request().method() === "PATCH" && response.url().includes("/api/v1/users/@me"), + { timeout: 15000 }, + ); +} + +test.describe("Workspace navigation", () => { + test("the '/' landing restores the last-active scope", async ({ page }) => { + await page.goto("/feeds"); + await waitForAuthenticatedApp(page); + await enableWorkspacesForCurrentUser(page); + + const workspaceName = `E2E Nav Workspace ${Date.now()}`; + const workspaceRecorded = waitForScopeRecording(page); + const slug = await createWorkspace(page, workspaceName); + await workspaceRecorded; + + // Visiting "/" lands back in the workspace, verified through the rendered scope chip. + await page.goto("/"); + await expect(page).toHaveURL(new RegExp(`/workspaces/${slug}/feeds$`), { timeout: 15000 }); + await expect( + page.getByRole("button", { name: `Switch team, current: ${workspaceName}` }), + ).toBeVisible(); + + // Switching to personal updates the recorded scope; "/" now lands on personal feeds. + const personalRecorded = waitForScopeRecording(page); + await page.getByRole("button", { name: /Switch team/ }).click(); + await page.getByRole("menuitemradio", { name: /personal/i }).click(); + await expect( + page.getByRole("button", { name: "Switch team, current: Personal" }), + ).toBeVisible(); + await personalRecorded; + + await page.goto("/"); + await expect(page).toHaveURL(/\/feeds$/, { timeout: 15000 }); + expect(page.url()).not.toContain("/workspaces/"); + await expect( + page.getByRole("button", { name: "Switch team, current: Personal" }), + ).toBeVisible(); + }); + + test("workspace feeds page shows the team heading with an on-page settings link, and the logo stays in scope", async ({ + page, + }) => { + await page.goto("/feeds"); + await waitForAuthenticatedApp(page); + await enableWorkspacesForCurrentUser(page); + + const workspaceName = `E2E Nav Settings ${Date.now()}`; + const slug = await createWorkspace(page, workspaceName); + + await expect(page.getByRole("heading", { name: workspaceName })).toBeVisible(); + + // The logo is scope-relative: "home" inside a workspace is the workspace's feeds. + await page.getByRole("link", { name: "MonitoRSS Home" }).click(); + await expect(page).toHaveURL(new RegExp(`/workspaces/${slug}/feeds$`)); + + // Settings is one visible on-page click — no switcher menu required. + await page.getByRole("link", { name: "Team settings" }).click(); + await expect(page).toHaveURL(new RegExp(`/workspaces/${slug}/settings$`), { timeout: 15000 }); + await expect(page.getByRole("heading", { name: "Team settings" })).toBeVisible(); + }); + + test("switching scopes moves focus to the new page's heading", async ({ page }) => { + await page.goto("/feeds"); + await waitForAuthenticatedApp(page); + await enableWorkspacesForCurrentUser(page); + + // A personal feed keeps personal scope out of discovery mode, so it renders + // the "Feeds" h1 the announcer moves focus to. + await addFeedViaDiscovery(page); + + const workspaceName = `E2E Nav Focus ${Date.now()}`; + await createWorkspace(page, workspaceName); + + await page.getByRole("button", { name: /Switch team/ }).click(); + await page.getByRole("menuitemradio", { name: /personal/i }).click(); + await expect( + page.getByRole("button", { name: "Switch team, current: Personal" }), + ).toBeVisible(); + + // The navigation announcer moves focus to the new page's h1 (after its 500ms delay). + await expect(page.getByRole("heading", { name: /^Feeds/, level: 1 })).toBeFocused({ + timeout: 5000, + }); + + await page.getByRole("button", { name: /Switch team/ }).click(); + await page.getByRole("menuitemradio", { name: workspaceName }).click(); + await expect( + page.getByRole("button", { name: `Switch team, current: ${workspaceName}` }), + ).toBeVisible(); + + // In workspace scope the h1 is the workspace name, so focusing it announces the new scope. + await expect(page.getByRole("heading", { name: workspaceName, level: 1 })).toBeFocused({ + timeout: 5000, + }); + }); + + test("breadcrumb roots carry the scope name in both scopes", async ({ page }) => { + await page.goto("/feeds"); + await waitForAuthenticatedApp(page); + await enableWorkspacesForCurrentUser(page); + + const workspaceName = `E2E Nav Crumbs ${Date.now()}`; + const slug = await createWorkspace(page, workspaceName); + + await addFeedViaDiscovery(page); + await page.getByRole("link", { name: /^Configure/ }).click(); + + // The feed detail breadcrumb roots at the workspace and links back to its feeds. + const workspaceCrumb = page.getByRole("link", { name: workspaceName }); + await expect(workspaceCrumb).toBeVisible({ timeout: 15000 }); + await workspaceCrumb.click(); + await expect(page).toHaveURL(new RegExp(`/workspaces/${slug}/feeds$`)); + + // In personal scope the same crumb says "Personal" once the user has a workspace. + await page.getByRole("button", { name: /Switch team/ }).click(); + await page.getByRole("menuitemradio", { name: /personal/i }).click(); + await expect( + page.getByRole("button", { name: "Switch team, current: Personal" }), + ).toBeVisible(); + + await addFeedViaDiscovery(page); + await page.getByRole("link", { name: /^Configure/ }).click(); + + const personalCrumb = page.getByRole("link", { name: "Personal" }); + await expect(personalCrumb).toBeVisible({ timeout: 15000 }); + await personalCrumb.click(); + await expect(page).toHaveURL(/\/feeds$/); + expect(page.url()).not.toContain("/workspaces/"); + }); +}); 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..8865df751 --- /dev/null +++ b/e2e/tests/workspaces/workspace-reddit-connection.spec.ts @@ -0,0 +1,336 @@ +import { test, expect, type Page, createAuthenticatedContext } from "../../fixtures/test-fixtures"; +import { getDiscordUserIdFromPage } from "../../helpers/paddle-db"; +import { + enableWorkspacesFeatureInDb, + getUserMongoIdFromDiscordId, + seedMembershipInDb, + seedWorkspaceWithMembershipsInDb, + setVerifiedEmailInDb, +} from "../../helpers/workspaces-db"; +import { connectRedditViaPopup, uniqueSubreddit } from "../../helpers/reddit-oauth"; + +// 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. The OAuth popup runs the +// real flow against the mock reddit server (authorize -> callback -> token +// exchange), and feed adds prove the stored grant authenticates fetches. + +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); +} + +// Scope switches update the URL synchronously (history.pushState inside navigate()) but +// the React tree swaps on a later commit. Interacting with the discovery UI in between +// submits under the OLD scope — a workspace-credentialed validation rendered in personal +// scope (or vice versa). The switcher trigger's label is render-derived, so waiting for +// it guarantees the new scope has committed before the test types anything. +async function waitForCommittedScope(page: Page, scopeName: string | RegExp): Promise { + await expect( + page.getByRole("button", { + name: + typeof scopeName === "string" + ? `Switch team, current: ${scopeName}` + : scopeName, + }), + ).toBeVisible({ timeout: 15000 }); +} + +async function switchToPersonalScope(page: Page): Promise { + await page.getByRole("button", { name: /Switch team/ }).click(); + await page.getByRole("menuitemradio", { name: /personal/i }).click(); + await expect(page).toHaveURL(/\/feeds$/, { timeout: 15000 }); + await waitForCommittedScope(page, "Personal"); +} + +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 }); + // The switcher may briefly label the fresh workspace "Team" until the workspaces + // list refetches, so anchor on "any non-Personal scope" rather than the exact name. + await waitForCommittedScope(page, /Switch team, current: (?!Personal$)/); + const slug = page.url().match(/\/workspaces\/([^/]+)\/feeds/)?.[1]; + expect(slug).toBeTruthy(); + return slug as string; +} + +// In workspace scope, the switcher menu carries a direct " settings" entry. +async function gotoWorkspaceSettingsViaSwitcher(page: Page, workspaceName: string): Promise { + await page.getByRole("button", { name: /Switch team/ }).click(); + await page.getByRole("menuitem", { name: `${workspaceName} settings` }).click(); + await expect(page).toHaveURL(/\/workspaces\/[^/]+\/settings$/, { timeout: 15000 }); +} + +// Re-selecting the already-active workspace in the switcher's radio group may not fire a +// navigation, so hop through Personal and back to land on the workspace feeds page. +async function gotoWorkspaceFeedsViaSwitcher(page: Page, workspaceName: string): Promise { + await switchToPersonalScope(page); + await page.getByRole("button", { name: /Switch team/ }).click(); + await page.getByRole("menuitemradio", { name: workspaceName }).click(); + await expect(page).toHaveURL(/\/workspaces\/[^/]+\/feeds$/, { timeout: 15000 }); + await waitForCommittedScope(page, workspaceName); +} + +async function pasteUrlIntoInlineDiscovery(page: Page, url: string): Promise { + // 0 feeds in scope -> 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(url); + await page.getByRole("button", { name: "Go", exact: true }).click(); +} + +test.describe("Workspace Reddit connection", () => { + test("connecting through the workspace gate adds the feed; personal scope stays gated", async ({ + page, + }) => { + test.slow(); + const workspaceFeed = uniqueSubreddit(); + const personalFeed = uniqueSubreddit(); + + await page.goto("/feeds"); + await waitForAuthenticatedApp(page); + await enableWorkspacesForCurrentUser(page); + await createWorkspace(page, `E2E Reddit Workspace ${Date.now()}`); + + await pasteUrlIntoInlineDiscovery(page, workspaceFeed.url); + + // 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, + }); + // Gate short-circuits before any fetch: no Add button appears. + await expect(page.getByRole("button", { name: /^Add .+ feed$/i })).toHaveCount(0); + + await connectRedditViaPopup( + page, + page.getByRole("button", { name: "Connect Reddit in popup window" }), + ); + + // The popup's completion refreshes the workspace connection and auto-retries the + // blocked validation: the feed card appears and the add goes through. + const addButton = page.getByRole("button", { name: /^Add .+ feed$/i }).first(); + await expect(addButton).toBeVisible({ timeout: 30000 }); + await addButton.click(); + await expect(page.getByText(/1 feed added/)).toBeVisible({ timeout: 10000 }); + + // No fallback in reverse: the workspace's connection never unlocks PERSONAL feeds. + await switchToPersonalScope(page); + await pasteUrlIntoInlineDiscovery(page, personalFeed.url); + await expect(page.getByText("Connect your Reddit account to continue")).toBeVisible({ + timeout: 30000, + }); + await expect(page.getByRole("button", { name: /^Add .+ feed$/i })).toHaveCount(0); + }); + + test("connect via settings unlocks feed adds; disconnect restores the gate", async ({ + page, + }) => { + test.slow(); + const firstFeed = uniqueSubreddit(); + const secondFeed = uniqueSubreddit(); + + await page.goto("/feeds"); + await waitForAuthenticatedApp(page); + await enableWorkspacesForCurrentUser(page); + const workspaceName = `E2E Reddit Settings ${Date.now()}`; + await createWorkspace(page, workspaceName); + + await gotoWorkspaceSettingsViaSwitcher(page, workspaceName); + + // 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 connectRedditViaPopup( + page, + page.getByRole("button", { name: "Connect Reddit in popup window" }), + ); + + // Connected, attributed to the member who connected it. (The members list also + // renders " (you)", so anchor to the attribution sentence.) + await expect(page.getByText("Connected", { exact: true })).toBeVisible({ timeout: 20000 }); + await expect(page.getByText(/Connected by .*\(you\)/)).toBeVisible(); + + // With the workspace connected, a reddit feed adds with no gate at all. + await gotoWorkspaceFeedsViaSwitcher(page, workspaceName); + await pasteUrlIntoInlineDiscovery(page, firstFeed.url); + const addButton = page.getByRole("button", { name: /^Add .+ feed$/i }).first(); + await expect(addButton).toBeVisible({ timeout: 30000 }); + await expect(page.getByText("Connect a Reddit account to continue")).toHaveCount(0); + await addButton.click(); + await expect(page.getByText(/1 feed added/)).toBeVisible({ timeout: 10000 }); + await page.getByRole("button", { name: /View your feeds/ }).click(); + await expect(page.getByRole("table")).toBeVisible({ timeout: 15000 }); + // exact-named link: the row also renders the URL link, which contains the title. + await expect( + page.getByRole("table").getByRole("link", { name: firstFeed.title, exact: true }), + ).toBeVisible(); + + // Any member can disconnect; the record is removed outright. + await gotoWorkspaceSettingsViaSwitcher(page, workspaceName); + await page.getByRole("button", { name: "Disconnect" }).click(); + await expect(page.getByText("Not Connected")).toBeVisible({ timeout: 15000 }); + + // New reddit adds are gated again (via the Add a Feed dialog, since the + // workspace now has a feed and the inline discovery no longer renders). + await gotoWorkspaceFeedsViaSwitcher(page, workspaceName); + await expect(page.getByRole("table")).toBeVisible({ timeout: 15000 }); + await page.getByRole("button", { name: "Add Feed", exact: true }).click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("heading", { name: "Add a Feed" })).toBeVisible(); + await dialog + .getByRole("textbox", { name: "Search popular feeds or paste a URL" }) + .fill(secondFeed.url); + await dialog.getByRole("button", { name: "Go", exact: true }).click(); + await expect(dialog.getByText("Connect a Reddit account to continue")).toBeVisible({ + timeout: 30000, + }); + await expect(dialog.getByRole("button", { name: /^Add .+ feed$/i })).toHaveCount(0); + }); + + test("a personal Reddit connection never unlocks workspace feeds (no fallback)", async ({ + page, + }) => { + test.slow(); + const personalFeed = uniqueSubreddit(); + const workspaceFeed = uniqueSubreddit(); + + await page.goto("/feeds"); + await waitForAuthenticatedApp(page); + await enableWorkspacesForCurrentUser(page); + + // Establish a REAL personal connection first (popup completes, validation retries). + await pasteUrlIntoInlineDiscovery(page, personalFeed.url); + await expect(page.getByText("Connect your Reddit account to continue")).toBeVisible({ + timeout: 30000, + }); + await connectRedditViaPopup( + page, + page.getByRole("button", { name: "Connect Reddit in popup window" }), + ); + await expect(page.getByRole("button", { name: /^Add .+ feed$/i }).first()).toBeVisible({ + timeout: 30000, + }); + + // The workspace still demands its own connection. + await createWorkspace(page, `E2E Reddit NoFallback ${Date.now()}`); + await pasteUrlIntoInlineDiscovery(page, workspaceFeed.url); + await expect(page.getByText("Connect a Reddit account to continue")).toBeVisible({ + timeout: 30000, + }); + await expect(page.getByRole("button", { name: /^Add .+ feed$/i })).toHaveCount(0); + }); + + test("removing the connecting member revokes the connection and another member reconnects", async ({ + page, + browser, + }) => { + test.slow(); + + // --- Owner session: a workspace the owner belongs to. --- + 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 = `Reddit Revoke WS ${ownerDiscordId}`; + const { workspaceId } = await seedWorkspaceWithMembershipsInDb({ + workspaceName, + selfUserId: ownerUserId, + selfRole: "owner", + }); + + // --- Session B: a second REAL member connects the workspace's Reddit. --- + const contextB = await createAuthenticatedContext(browser); + try { + const pageB = await contextB.newPage(); + await pageB.goto("/feeds"); + await waitForAuthenticatedApp(pageB); + const memberDiscordId = await getDiscordUserIdFromPage(pageB); + await enableWorkspacesFeatureInDb(memberDiscordId); + const memberUserId = await getUserMongoIdFromDiscordId(memberDiscordId); + await seedMembershipInDb({ workspaceId, userId: memberUserId, role: "admin" }); + await pageB.reload(); + await waitForAuthenticatedApp(pageB); + + await pageB.getByRole("button", { name: /Switch team/ }).click(); + await pageB.getByRole("menuitemradio", { name: workspaceName }).click(); + await expect(pageB).toHaveURL(/\/workspaces\/[^/]+\/feeds$/, { timeout: 15000 }); + await gotoWorkspaceSettingsViaSwitcher(pageB, workspaceName); + + await connectRedditViaPopup( + pageB, + pageB.getByRole("button", { name: "Connect Reddit in popup window" }), + ); + await expect(pageB.getByText("Connected", { exact: true })).toBeVisible({ + timeout: 20000, + }); + await expect(pageB.getByText(/Connected by .*\(you\)/)).toBeVisible(); + } finally { + await contextB.close(); + } + + // --- The owner removes the connector; the exit revokes the grant. --- + await page.reload(); + await waitForAuthenticatedApp(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 }); + + const members = page.getByRole("region", { name: "Members" }); + const memberRow = members.getByRole("listitem").filter({ hasText: /admin/i }); + await expect(memberRow).toBeVisible({ timeout: 15000 }); + await memberRow.getByRole("button", { name: /^remove/i }).click(); + await page.getByRole("alertdialog").getByRole("button", { name: /remove/i }).click(); + await expect(members.getByRole("listitem").filter({ hasText: /admin/i })).toHaveCount(0, { + timeout: 15000, + }); + + // The connection died with the member's exit: the settings page shows the dead + // state with guidance that any member can reconnect. + await page.reload(); + await waitForAuthenticatedApp(page); + await expect(page.getByText("Disconnected", { exact: true })).toBeVisible({ + timeout: 15000, + }); + await expect(page.getByText(/no longer active/i)).toBeVisible(); + + // The remaining member revives it with their OWN account. + await connectRedditViaPopup( + page, + page.getByRole("button", { name: "Reconnect Reddit in popup window" }), + ); + await expect(page.getByText("Connected", { exact: true })).toBeVisible({ timeout: 20000 }); + await expect(page.getByText(/Connected by .*\(you\)/)).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..7c31c1d01 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, @@ -158,6 +159,9 @@ module.exports = { // raw hex/palette refs are sanctioned, not debt (ADR-007 § "the ONE exception that stays raw"). "**/DiscordMessageDisplay/**", "**/DiscordView/**", + // Vendored Chakra snippet: LightMode/DarkMode spans use colorPalette="gray" as a + // palette reset for a forced-theme subtree (upstream snippet semantics, not debt). + "src/components/ui/color-mode.tsx", ], rules: { "no-restricted-syntax": [ @@ -168,6 +172,17 @@ module.exports = { message: "Raw palette ref in a color prop. Name a semantic ROLE (fg/bg/border/text.* for text, brand/PrimaryActionButton for accent, explicit colorPalette for status), not a hue. See client/docs/adr/007-styling-roles-tiers-contrast.md.", }, + /* ADR-007 corollary: colorPalette="gray" at a call site is a hue spelling the neutral + * DEFAULT. The button recipe pins the neutral palette in theme.ts, so the prop is + * always redundant on buttons, and the global gray default covers everything else. + * Status palettes (red/green/orange) and brand stay explicit and allowed. color-mode.tsx + * (vendored Chakra snippet; its LightMode/DarkMode spans use gray as a palette reset) + * is exempted via excludedFiles above. */ + { + selector: 'JSXAttribute[name.name="colorPalette"] > Literal[value="gray"]', + message: + 'colorPalette="gray" is redundant: gray is the pinned neutral default (button recipe in theme.ts, global default elsewhere). Remove it, or name a meaningful palette (brand or an explicit status hue). See client/docs/adr/007-styling-roles-tiers-contrast.md.', + }, ], }, }, 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..7881ce6a1 --- /dev/null +++ b/services/backend-api/client/docs/adr/008-workspace-ui.md @@ -0,0 +1,149 @@ +# 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-10) — navigation clauses revised + +The navigation-freeze parts of this ADR no longer hold: §10's "permanent header order … the header layout never changes" (the switcher is now a path-connected chip after the logo), §8's outline-button trigger presentation (accessible name, keyboard pattern, and gating are reaffirmed), and the E2E plan's "default landing" assertion (#4 — landing is now sticky-scope via `preferences.lastActiveWorkspaceSlug`). Everything else here stands. + +## 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/App.tsx b/services/backend-api/client/src/App.tsx index 1603d60c0..cc043d485 100644 --- a/services/backend-api/client/src/App.tsx +++ b/services/backend-api/client/src/App.tsx @@ -10,6 +10,7 @@ import advancedFormat from "dayjs/plugin/advancedFormat"; import dayjs from "dayjs"; import { SendTestArticleProvider } from "./features/feedConnections/discordChannel/messageBuilder/contexts/SendTestArticleContext"; import Pages from "./pages"; +import { ScopeNavigationContainer } from "./pages/ScopeNavigationContainer"; import { AccessibleNavigationAnnouncer } from "./components/AccessibleNavigationAnnouncer"; dayjs.extend(utc); @@ -23,7 +24,9 @@ const App: React.FC = () => { - + + + ); diff --git a/services/backend-api/client/src/components/AccessibleNavigationAnnouncer/index.tsx b/services/backend-api/client/src/components/AccessibleNavigationAnnouncer/index.tsx index 50b31db97..ec5988beb 100644 --- a/services/backend-api/client/src/components/AccessibleNavigationAnnouncer/index.tsx +++ b/services/backend-api/client/src/components/AccessibleNavigationAnnouncer/index.tsx @@ -26,6 +26,19 @@ export const AccessibleNavigationAnnouncer = () => { return; } + /** + * If the user has already opened a popup (e.g. the header workspace switcher menu) + * by the time this delayed callback runs, stealing focus to the h1 would dismiss + * the popup out from under them. + */ + if ( + activeElement && + (activeElement.closest('[role="menu"], [role="dialog"], [role="listbox"]') || + activeElement.getAttribute("aria-expanded") === "true") + ) { + return; + } + const checkoutRootPath = pages.checkout(":id").split("/")[1]; if (locationPathname.includes(checkoutRootPath)) { 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..fa3ef8cb0 100644 --- a/services/backend-api/client/src/components/NewHeader/index.tsx +++ b/services/backend-api/client/src/components/NewHeader/index.tsx @@ -14,7 +14,11 @@ interface Props { isBotLoading?: boolean; botError?: { message: string } | null; user?: { iconUrl?: string; username?: string; id?: string }; + /** Logo target. Containers pass the current scope's feeds so "home" never switches scope. */ + logoHref?: string; + workspaceSlot?: React.ReactNode; searchSlot?: React.ReactNode; + accountMenuSlot?: React.ReactNode; logoutSlot?: React.ReactNode; } @@ -24,7 +28,10 @@ export const NewHeader = ({ isBotLoading, botError, user, + logoHref, + workspaceSlot, searchSlot, + accountMenuSlot, logoutSlot, }: Props) => { const navigate = useNavigate(); @@ -44,37 +51,52 @@ export const NewHeader = ({ paddingX={{ base: 4, lg: 12 }} > - - {bot && ( - - - - - MonitoRSS - - - + {/* Logo and scope switcher read as one path (brand / scope), so they share + a tight cluster; the slash renders only when a scope slot exists, keeping + the zero-workspace header unchanged. */} + {/* minW=0 (not overflow=hidden, which would clip the chip's focus ring) + lets the logo's own ellipsis engage when space is tight. */} + + + {bot && ( + + + + + MonitoRSS + + + + )} + {isBotLoading && ( + + + + )} + {botError && } + + {workspaceSlot && ( + <> + + {workspaceSlot} + )} - {isBotLoading && ( - - - - )} - {botError && } - + {searchSlot} @@ -120,6 +142,7 @@ export const NewHeader = ({ Discord Support Server + {accountMenuSlot} {logoutSlot} diff --git a/services/backend-api/client/src/components/ui/toggle-tip.tsx b/services/backend-api/client/src/components/ui/toggle-tip.tsx index 2a7b14b0a..82661f0d0 100644 --- a/services/backend-api/client/src/components/ui/toggle-tip.tsx +++ b/services/backend-api/client/src/components/ui/toggle-tip.tsx @@ -64,7 +64,7 @@ export const InfoTip = React.forwardRef(function I return ( - + 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/contexts/ScopeLabelContext.tsx b/services/backend-api/client/src/contexts/ScopeLabelContext.tsx new file mode 100644 index 000000000..1b83ce51f --- /dev/null +++ b/services/backend-api/client/src/contexts/ScopeLabelContext.tsx @@ -0,0 +1,16 @@ +import { createContext, useContext } from "react"; + +/** + * Breadcrumb root label for the current scope: the workspace's name in workspace + * scope, "Personal" otherwise — but only once the user belongs to at least one + * workspace (the same count-gate as the header switcher). Undefined means no scope + * labeling applies, and crumbs fall back to "Feeds" so zero-workspace users see no + * change. The value is computed in the pages layer (ScopeNavigationContainer); this + * context stays feature-free so feature components can consume it without + * cross-feature imports. + */ +const ScopeLabelContext = createContext(undefined); + +export const ScopeLabelProvider = ScopeLabelContext.Provider; + +export const useScopeCrumbLabel = (): string => useContext(ScopeLabelContext) ?? "Feeds"; diff --git a/services/backend-api/client/src/features/discordUser/api/updateUserMe.ts b/services/backend-api/client/src/features/discordUser/api/updateUserMe.ts index fe44f1654..104d02473 100644 --- a/services/backend-api/client/src/features/discordUser/api/updateUserMe.ts +++ b/services/backend-api/client/src/features/discordUser/api/updateUserMe.ts @@ -27,6 +27,7 @@ export interface UpdateUserMeInput { feedListStatusFilters?: { statuses: string[]; }; + lastActiveWorkspaceSlug?: string | null; }; }; } 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/discordUser/types/UserMe.ts b/services/backend-api/client/src/features/discordUser/types/UserMe.ts index 5223bb1ba..db98d4c2a 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(), @@ -29,6 +30,7 @@ export const UserMeSchema = object({ feedListStatusFilters: object({ statuses: array(string().required()).required(), }).optional(), + lastActiveWorkspaceSlug: string().nullable().optional(), }).default({}), subscription: object({ subscriptionId: string().optional().nullable(), @@ -62,7 +64,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/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/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 isRedditConnectionRequired = - error?.errorCode === ApiErrorCode.REDDIT_CONNECTION_REQUIRED; - 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 && !isRedditConnectionRequired && ( - - - 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())} - /> - )} - {isRedditConnectionRequired && ( - 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/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/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/FixFeedRequestsCTA/FixFeedRequestsCTA.test.tsx b/services/backend-api/client/src/features/feed/components/FixFeedRequestsCTA/FixFeedRequestsCTA.test.tsx index e4f32d73e..0fa14f79f 100644 --- a/services/backend-api/client/src/features/feed/components/FixFeedRequestsCTA/FixFeedRequestsCTA.test.tsx +++ b/services/backend-api/client/src/features/feed/components/FixFeedRequestsCTA/FixFeedRequestsCTA.test.tsx @@ -3,6 +3,7 @@ import { render, screen } from "@testing-library/react"; import { ChakraProvider } from "@chakra-ui/react"; import { describe, it, expect, vi } from "vitest"; import { system } from "@/utils/theme"; +import { FeedScopeProvider, type FeedScope } from "../../contexts/FeedScopeContext"; import { FixFeedRequestsCTA } from "."; let mockExternalAccounts: Array<{ type: string; status: string }> | undefined; @@ -11,22 +12,23 @@ vi.mock("../../../discordUser", () => ({ useUserMe: () => ({ data: { result: { externalAccounts: mockExternalAccounts } }, }), - RedditLoginButton: ({ onConnected }: { onConnected?: () => void }) => ( + RedditLoginButton: ({ + onConnected, + workspace, + }: { + onConnected?: () => void; + workspace?: { id: string }; + }) => ( ), })); -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/components/SearchFeedsModal/index.tsx b/services/backend-api/client/src/features/feed/components/SearchFeedsModal/index.tsx index 0a5637a19..a36c5eb68 100644 --- a/services/backend-api/client/src/features/feed/components/SearchFeedsModal/index.tsx +++ b/services/backend-api/client/src/features/feed/components/SearchFeedsModal/index.tsx @@ -164,9 +164,8 @@ export const SearchFeedsModal = () => { variant="ghost" aria-label="Search your feeds and go to one" color="fg.muted" - // _hover={{ color: "whiteAlpha.900", bg: "whiteAlpha.200" }} - // _focus={{ color: "whiteAlpha.900", bg: "whiteAlpha.200" }} - size={{ base: "sm", lg: "md" }} + _hover={{ color: "fg" }} + size="sm" onClick={() => setIsOpen(true)} > 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 f9fade892..220bb9a4f 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"; @@ -84,6 +85,7 @@ import { PopoverBody, } from "@/components/ui/popover"; import { Alert } from "@/components/ui/alert"; +import { useScopeCrumbLabel } from "@/contexts/ScopeLabelContext"; const tabIndexBySearchParam = new Map([ [UserFeedTabSearchParam.Connections, 0], @@ -105,6 +107,10 @@ 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 scopeCrumbLabel = useScopeCrumbLabel(); const { open: editIsOpen, onClose: editOnClose, onOpen: editOnOpen } = useDisclosure(); const { open: copySettingsIsOpen, @@ -171,7 +177,7 @@ export const UserFeedDetail: React.FC = () => { await mutateAsync({ feedId, }); - navigate(pages.userFeeds(), { + navigate(pages.userFeeds(scope), { state: { alertTitle: `Successfully deleted feed: ${feed.title}`, }, @@ -208,7 +214,7 @@ export const UserFeedDetail: React.FC = () => { }, }); - navigate(pages.userFeeds(), { + navigate(pages.userFeeds(scope), { state: { alertTitle: `Successfully removed shared access to feed: ${feed.title}`, }, @@ -230,6 +236,8 @@ export const UserFeedDetail: React.FC = () => {
); + const showEmptyConnectionsAlert = !!feed && !feed.connections.length && !isSharedWithMe; + const disabledConnections = feed?.connections.filter( (c) => c.disabledCode === FeedConnectionDisabledCode.Manual, ); @@ -289,7 +297,7 @@ export const UserFeedDetail: React.FC = () => { - Feeds + {scopeCrumbLabel} @@ -298,6 +306,7 @@ export const UserFeedDetail: React.FC = () => { {feed?.title} @@ -648,14 +657,18 @@ export const UserFeedDetail: React.FC = () => { {t("pages.userFeeds.tabConnections")} - - - Add connection - + {/* The empty-state alert below owns the CTA when there are no + connections — one primary action per view. */} + {!showEmptyConnectionsAlert && ( + + + Add connection + + )} {t("pages.feed.connectionSectionDescription")} - {feed && !feed.connections.length && !isSharedWithMe && ( + {showEmptyConnectionsAlert && ( @@ -663,7 +676,10 @@ export const UserFeedDetail: React.FC = () => { You'll need to set up at least one connection to tell the bot where to send new articles! - {addConnectionButtons} + + + Add connection + diff --git a/services/backend-api/client/src/features/feed/components/UserFeedDisabledAlert/index.tsx b/services/backend-api/client/src/features/feed/components/UserFeedDisabledAlert/index.tsx index 8a28eb33b..9953544f4 100644 --- a/services/backend-api/client/src/features/feed/components/UserFeedDisabledAlert/index.tsx +++ b/services/backend-api/client/src/features/feed/components/UserFeedDisabledAlert/index.tsx @@ -114,12 +114,24 @@ export const UserFeedDisabledAlert = () => { } if (disabledCode === UserFeedDisabledCode.ExceededFeedLimit) { + // Workspace feeds count against the workspace's limit, so the supporter + // upsell in the personal copy does not apply. + const isWorkspaceFeed = !!feed.isWorkspaceFeed; + return ( - {t("pages.userFeed.exceededFeedLimitTitle")} + + {isWorkspaceFeed + ? t("pages.userFeed.exceededFeedLimitWorkspaceTitle") + : t("pages.userFeed.exceededFeedLimitTitle")} + - {t("pages.userFeed.exceededFeedLimitText")} + + {isWorkspaceFeed + ? t("pages.userFeed.exceededFeedLimitWorkspaceText") + : t("pages.userFeed.exceededFeedLimitText")} + diff --git a/services/backend-api/client/src/features/feed/components/UserFeedHealthAlert/index.test.tsx b/services/backend-api/client/src/features/feed/components/UserFeedHealthAlert/index.test.tsx new file mode 100644 index 000000000..31d208949 --- /dev/null +++ b/services/backend-api/client/src/features/feed/components/UserFeedHealthAlert/index.test.tsx @@ -0,0 +1,106 @@ +import "@testing-library/jest-dom"; +import { render, screen } from "@testing-library/react"; +import { ChakraProvider } from "@chakra-ui/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it, vi } from "vitest"; +import { system } from "@/utils/theme"; +import { UserFeedHealthAlert } from "./index"; +import { UserFeedDisabledCode } from "../../types"; + +const { mockUserFeed, mockRequestsReturn } = vi.hoisted(() => ({ + mockUserFeed: vi.fn(), + mockRequestsReturn: vi.fn(), +})); + +vi.mock("../../contexts/UserFeedContext", () => ({ + useUserFeedContext: () => ({ userFeed: mockUserFeed() }), +})); + +vi.mock("../../hooks", async () => ({ + ...(await vi.importActual>("../../hooks")), + useUserFeedRequestsWithPagination: () => mockRequestsReturn(), + useCreateUserFeedManualRequest: () => ({ mutateAsync: vi.fn(), status: "idle" }), +})); + +const failingRequestsReturn = { + status: "success", + data: { + result: { + requests: [ + { + id: "req-1", + status: "FETCH_ERROR", + createdAt: new Date().toISOString(), + response: { statusCode: 500 }, + }, + ], + nextRetryAtIso: new Date().toISOString(), + }, + }, +}; + +const baseUserFeed = { + id: "feed-1", + url: "https://example.com/feed.xml", + refreshRateSeconds: 600, +}; + +const renderComponent = () => + render( + + + + + , + ); + +describe("UserFeedHealthAlert", () => { + it("shows the failing-requests warning for an enabled feed with failing requests", () => { + mockRequestsReturn.mockReturnValue(failingRequestsReturn); + mockUserFeed.mockReturnValue(baseUserFeed); + + renderComponent(); + + expect(screen.getByText("Requests are currently failing")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Retry feed request" })).toBeInTheDocument(); + }); + + // A disabled feed is not polled, so the warning (and its retry CTA) would be + // misleading regardless of WHY it was disabled; the disabled alert is the sole banner. + it.each(Object.values(UserFeedDisabledCode))( + "renders nothing when the feed is disabled with code %s despite failing requests", + (disabledCode) => { + mockRequestsReturn.mockReturnValue(failingRequestsReturn); + mockUserFeed.mockReturnValue({ ...baseUserFeed, disabledCode }); + + renderComponent(); + + expect(screen.queryByText("Requests are currently failing")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Retry feed request" })).not.toBeInTheDocument(); + }, + ); + + it("renders nothing when the latest request succeeded", () => { + mockRequestsReturn.mockReturnValue({ + status: "success", + data: { + result: { + requests: [ + { + id: "req-1", + status: "OK", + createdAt: new Date().toISOString(), + response: { statusCode: 200 }, + }, + ], + nextRetryAtIso: null, + }, + }, + }); + mockUserFeed.mockReturnValue(baseUserFeed); + + renderComponent(); + + expect(screen.queryByText("Requests are currently failing")).not.toBeInTheDocument(); + }); +}); 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..9edfaa002 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 @@ -2,8 +2,9 @@ import { Alert, Box, Button, Flex, HStack, Stack } from "@chakra-ui/react"; import dayjs from "dayjs"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; -import { UserFeedDisabledCode, UserFeedRequestStatus } from "../../types"; +import { 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: {}, @@ -65,11 +68,9 @@ export const UserFeedHealthAlert = () => { const isFailing = !!latestRequest && latestRequest.status !== UserFeedRequestStatus.OK; const nextRetryAt = data?.result.nextRetryAtIso ? dayjs(data.result.nextRetryAtIso) : null; - if ( - !isFailing || - status === "loading" || - userFeed.disabledCode === UserFeedDisabledCode.FailedRequests - ) { + // A disabled feed is not being polled, so the failing-requests warning (and its + // retry CTA) would be misleading; the disabled alert already explains the state. + if (!isFailing || status === "loading" || !!userFeed.disabledCode) { return null; } @@ -101,6 +102,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/UserFeedStatusTag.tsx b/services/backend-api/client/src/features/feed/components/UserFeedsTable/UserFeedStatusTag.tsx index 98c681d1d..e52a85c83 100644 --- a/services/backend-api/client/src/features/feed/components/UserFeedsTable/UserFeedStatusTag.tsx +++ b/services/backend-api/client/src/features/feed/components/UserFeedsTable/UserFeedStatusTag.tsx @@ -39,5 +39,17 @@ export const UserFeedStatusTag: React.FC = ({ status, ariaHidden }) => { ); } + if (status === UserFeedComputedStatus.FeedLimitExceeded) { + return ( + + ); + } + return ; }; 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/constants.ts b/services/backend-api/client/src/features/feed/components/UserFeedsTable/constants.ts index 23f2d71c9..d5428cc1e 100644 --- a/services/backend-api/client/src/features/feed/components/UserFeedsTable/constants.ts +++ b/services/backend-api/client/src/features/feed/components/UserFeedsTable/constants.ts @@ -26,6 +26,11 @@ export const STATUS_FILTERS = [ description: "Manually disabled", value: UserFeedComputedStatus.ManuallyDisabled, }, + { + label: "Feed Limit Exceeded", + description: "Disabled because the feed limit was exceeded", + value: UserFeedComputedStatus.FeedLimitExceeded, + }, ] as const; export const TOGGLEABLE_COLUMNS = [ 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 281e97f78..9eb1a1bb2 100644 --- a/services/backend-api/client/src/features/feed/components/index.ts +++ b/services/backend-api/client/src/features/feed/components/index.ts @@ -1,7 +1,6 @@ export * from "./RefreshUserFeedButton"; export * from "./RedditConnectionSetting/RedditConnectionSetting"; 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..0ca5be571 --- /dev/null +++ b/services/backend-api/client/src/features/feed/contexts/FeedScopeContext.tsx @@ -0,0 +1,39 @@ +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; + /** + * 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({}); + +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/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/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/feed/types/UserFeedComputedStatus.ts b/services/backend-api/client/src/features/feed/types/UserFeedComputedStatus.ts index 10120a4e3..59f180ca2 100644 --- a/services/backend-api/client/src/features/feed/types/UserFeedComputedStatus.ts +++ b/services/backend-api/client/src/features/feed/types/UserFeedComputedStatus.ts @@ -2,5 +2,6 @@ export enum UserFeedComputedStatus { Ok = "OK", RequiresAttention = "REQUIRES_ATTENTION", ManuallyDisabled = "MANUALLY_DISABLED", + FeedLimitExceeded = "FEED_LIMIT_EXCEEDED", Retrying = "RETRYING", } 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..a4a9c94b3 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, ); @@ -63,9 +64,7 @@ export const ConnectionCard = ({ feedId, connection }: Props) => { {connection.name} {connection.disabledCode === FeedConnectionDisabledCode.Manual && ( - - Disabled - + Disabled )} {isError && ( @@ -94,6 +93,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..dbe222981 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, @@ -55,6 +56,7 @@ import { import { TabContentContainer } from "@/components/TabContentContainer"; import { FeedDiscordChannelConnection } from "@/types"; import { UserFeedTabSearchParam } from "@/constants/userFeedTabSearchParam"; +import { useScopeCrumbLabel } from "@/contexts/ScopeLabelContext"; const tabIndexBySearchParam = new Map([ [UserFeedConnectionTabSearchParam.Message, 0], @@ -96,6 +98,9 @@ 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 scopeCrumbLabel = useScopeCrumbLabel(); const { search: urlSearch } = useLocation(); const actionsButtonRef = useRef(null); const { userFeed: feed } = useUserFeedContext(); @@ -177,13 +182,13 @@ const ConnectionDiscordChannelSettingsInner: React.FC = () => { - Feeds + {scopeCrumbLabel} - + {feed?.title} @@ -194,6 +199,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/ArticleSelectionDialog.tsx b/services/backend-api/client/src/features/feedConnections/discordChannel/messageBuilder/ArticleSelectionDialog.tsx index a1fcdea01..eb62f2ba3 100644 --- a/services/backend-api/client/src/features/feedConnections/discordChannel/messageBuilder/ArticleSelectionDialog.tsx +++ b/services/backend-api/client/src/features/feedConnections/discordChannel/messageBuilder/ArticleSelectionDialog.tsx @@ -327,7 +327,6 @@ export const ArticleSelectionDialog: React.FC = ({ + diff --git a/services/backend-api/client/src/features/feedConnections/discordChannel/shared/components/DiscordMessageDisplay/__snapshots__/DiscordMessageDisplay.test.tsx.snap b/services/backend-api/client/src/features/feedConnections/discordChannel/shared/components/DiscordMessageDisplay/__snapshots__/DiscordMessageDisplay.test.tsx.snap index 4174c951b..bbd2a2175 100644 --- a/services/backend-api/client/src/features/feedConnections/discordChannel/shared/components/DiscordMessageDisplay/__snapshots__/DiscordMessageDisplay.test.tsx.snap +++ b/services/backend-api/client/src/features/feedConnections/discordChannel/shared/components/DiscordMessageDisplay/__snapshots__/DiscordMessageDisplay.test.tsx.snap @@ -242,7 +242,7 @@ exports[`DiscordMessageDisplay > Snapshot Tests > matches snapshot for V2 messag class="chakra-stack css-1x0y7ph" > Snapshot Tests > matches snapshot for V2 messag + {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/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/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..0fbbcea69 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/components/WorkspaceScopeLayout/index.tsx @@ -0,0 +1,74 @@ +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, + refetch, + } = 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..0bf3cc06d --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/components/WorkspaceSettings/WorkspaceSettings.test.tsx @@ -0,0 +1,259 @@ +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(), +})); + +// The Reddit connection section has its own test file; stub it so these tests don't +// need its workspace/user queries wired up. +vi.mock("../WorkspaceRedditConnectionSetting", () => ({ + WorkspaceRedditConnectionSetting: ({ workspaceSlug }: { workspaceSlug: string }) => ( +
{`reddit-connection-setting:${workspaceSlug}`}
+ ), +})); + +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..738b1c952 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/components/WorkspaceSettings/index.tsx @@ -0,0 +1,201 @@ +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"; +import { WorkspaceRedditConnectionSetting } from "../WorkspaceRedditConnectionSetting"; + +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 + + + +
+ + + Integrations + + + + { + 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..19949f360 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/components/WorkspaceSwitcher/index.tsx @@ -0,0 +1,161 @@ +import { useMemo, useState } from "react"; +import { Box, Button, Icon, 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 ( + + + {/* Quiet ghost chip: the trigger reads as the scope segment of the header's + brand/scope path rather than a freestanding control. */} + + + + {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..764f6f587 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/hooks/index.ts @@ -0,0 +1,20 @@ +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"; +export * from "./useDisconnectWorkspaceReddit"; 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/useDisconnectWorkspaceReddit.tsx b/services/backend-api/client/src/features/workspaces/hooks/useDisconnectWorkspaceReddit.tsx new file mode 100644 index 000000000..4d7d0c333 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/hooks/useDisconnectWorkspaceReddit.tsx @@ -0,0 +1,24 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import ApiAdapterError from "@/utils/ApiAdapterError"; +import { disconnectWorkspaceReddit } from "../api"; + +export const useDisconnectWorkspaceReddit = () => { + 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/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..9e15f3cb7 --- /dev/null +++ b/services/backend-api/client/src/features/workspaces/types/index.ts @@ -0,0 +1,134 @@ +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(), + // 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; + +/** + * 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/locales/en-us/translation.json b/services/backend-api/client/src/locales/en-us/translation.json index 6d0308d84..2d625bfed 100644 --- a/services/backend-api/client/src/locales/en-us/translation.json +++ b/services/backend-api/client/src/locales/en-us/translation.json @@ -146,6 +146,8 @@ "invalidFeedFailureText": "The feed contents could not be read. You may attempt to re-enable it. If the feed is verified to be working, it will be re-enabled.", "exceededFeedLimitTitle": "This feed is currently disabled because it is over your feed limit.", "exceededFeedLimitText": "You currently have exceeded your feed limit. You may either become a supporter, or disable/delete some of your other feeds to be able to re-enable this feed.", + "exceededFeedLimitWorkspaceTitle": "This feed is currently disabled because the workspace is over its feed limit.", + "exceededFeedLimitWorkspaceText": "The workspace has exceeded its feed limit. Disable or delete some of the workspace's other feeds to re-enable this feed.", "adminDisabledTitle": "This feed is currently disabled by an administrator.", "adminDisabledText": "Please contact support for more details.", "feedTooLargeTitle": "Disabled due to feed size", 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..713d8798d 100644 --- a/services/backend-api/client/src/pages/AddUserFeeds.tsx +++ b/services/backend-api/client/src/pages/AddUserFeeds.tsx @@ -39,7 +39,8 @@ 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 { useScopeCrumbLabel } from "../contexts/ScopeLabelContext"; import { useCreateUserFeedUrlValidation } from "../features/feed/hooks/useCreateUserFeedUrlValidation"; import { useDiscordUserMe, useUserMe } from "../features/discordUser"; import { PricingDialogContext } from "@/features/subscriptionProducts"; @@ -224,6 +225,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 +278,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 +295,7 @@ const UploadProgressView = ({ }), ); }, - [createUrlValidation, createUserFeed, sourceFeed?.id], + [createUrlValidation, createUserFeed, sourceFeed?.id, scope], ); useEffect(() => { @@ -456,7 +459,7 @@ const UploadProgressView = ({ return; } - navigate(pages.userFeeds()); + navigate(pages.userFeeds(scope)); }} > Close @@ -484,6 +487,9 @@ const AddFormView = ({ onSubmitted }: { onSubmitted: (urls: string[]) => void }) status: deduplicateStatus, } = useCreateUserFeedDeduplicatedUrls(); const navigate = useNavigate(); + const { workspaceSlug } = useFeedScope(); + const scope = workspaceSlug ? { workspaceSlug } : undefined; + const scopeCrumbLabel = useScopeCrumbLabel(); const remainingFeedsAllowed = discordUserMe && userFeedsResults @@ -539,7 +545,7 @@ const AddFormView = ({ onSubmitted }: { onSubmitted: (urls: string[]) => void }) - Feeds + {scopeCrumbLabel} @@ -795,7 +801,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(); + // The logo is scope-relative: "home" inside a workspace is that workspace's feeds. + const { workspaceSlug } = useParams(); + + 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/ScopeAwareLanding.test.tsx b/services/backend-api/client/src/pages/ScopeAwareLanding.test.tsx new file mode 100644 index 000000000..7a9fef962 --- /dev/null +++ b/services/backend-api/client/src/pages/ScopeAwareLanding.test.tsx @@ -0,0 +1,119 @@ +import "@testing-library/jest-dom"; +import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ChakraProvider } from "@chakra-ui/react"; +import { system } from "@/utils/theme"; +import { ScopeAwareLanding } from "./ScopeAwareLanding"; +import { useUserMe } from "@/features/discordUser"; +import { useIsWorkspacesEnabled, useWorkspace } from "@/features/workspaces"; + +vi.mock("@/features/discordUser", () => ({ + useUserMe: vi.fn(), +})); + +vi.mock("@/features/workspaces", () => ({ + useIsWorkspacesEnabled: vi.fn(), + useWorkspace: vi.fn(), +})); + +vi.mock("react-router-dom", async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + Navigate: ({ to }: { to: string }) =>
, + }; +}); + +const mockState = ({ + enabled = true, + flagStatus = "success", + lastActiveWorkspaceSlug, + workspace, + workspaceStatus = "success", +}: { + enabled?: boolean; + flagStatus?: string; + lastActiveWorkspaceSlug?: string | null; + workspace?: { id: string; name: string; slug: string }; + workspaceStatus?: string; +}) => { + vi.mocked(useIsWorkspacesEnabled).mockReturnValue({ + enabled, + status: flagStatus, + } as never); + vi.mocked(useUserMe).mockReturnValue({ + data: { result: { preferences: { lastActiveWorkspaceSlug } } }, + } as never); + vi.mocked(useWorkspace).mockReturnValue({ + workspace, + status: workspaceStatus, + } as never); +}; + +const renderLanding = () => + render( + + + , + ); + +const navigateTarget = () => screen.getByTestId("navigate").getAttribute("data-to"); + +describe("ScopeAwareLanding", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("lands on personal feeds when no last-active workspace is recorded", () => { + mockState({ lastActiveWorkspaceSlug: null }); + + renderLanding(); + + expect(navigateTarget()).toBe("/feeds"); + }); + + it("lands on the last-active workspace's feeds when it is still accessible", () => { + mockState({ + lastActiveWorkspaceSlug: "acme-marketing", + workspace: { id: "w1", name: "Acme", slug: "acme-marketing" }, + }); + + renderLanding(); + + expect(navigateTarget()).toBe("/workspaces/acme-marketing/feeds"); + }); + + it("falls back to personal feeds when the recorded workspace is gone or inaccessible", () => { + mockState({ + lastActiveWorkspaceSlug: "deleted-team", + workspace: undefined, + workspaceStatus: "error", + }); + + renderLanding(); + + expect(navigateTarget()).toBe("/feeds"); + }); + + it("ignores the recorded workspace when the workspaces feature is disabled", () => { + mockState({ enabled: false, lastActiveWorkspaceSlug: "acme-marketing" }); + + renderLanding(); + + expect(navigateTarget()).toBe("/feeds"); + expect(vi.mocked(useWorkspace)).toHaveBeenCalledWith({ workspaceSlug: undefined }); + }); + + it("does not navigate while the recorded workspace is being validated", () => { + mockState({ + lastActiveWorkspaceSlug: "acme-marketing", + workspace: undefined, + workspaceStatus: "loading", + }); + + renderLanding(); + + expect(screen.queryByTestId("navigate")).not.toBeInTheDocument(); + }); +}); diff --git a/services/backend-api/client/src/pages/ScopeAwareLanding.tsx b/services/backend-api/client/src/pages/ScopeAwareLanding.tsx new file mode 100644 index 000000000..915ff37ff --- /dev/null +++ b/services/backend-api/client/src/pages/ScopeAwareLanding.tsx @@ -0,0 +1,38 @@ +import { Spinner } from "@chakra-ui/react"; +import { Navigate } from "react-router-dom"; +import { pages } from "../constants"; +import { useUserMe } from "@/features/discordUser"; +import { useIsWorkspacesEnabled, useWorkspace } from "@/features/workspaces"; + +/** + * The "/" landing redirect. Restores the last-active scope recorded by + * ScopeNavigationContainer: a valid, still-accessible workspace slug lands on that + * workspace's feeds; anything else (no preference, feature off, unknown slug, or + * revoked membership) silently falls back to personal feeds. Only this route + * consults the preference — typed URLs and deep links are never rewritten. + */ +export const ScopeAwareLanding = () => { + const { enabled, status: flagStatus } = useIsWorkspacesEnabled(); + const { data: userMe } = useUserMe(); + const lastActiveSlug = userMe?.result.preferences?.lastActiveWorkspaceSlug || undefined; + const workspaceSlug = enabled && lastActiveSlug ? lastActiveSlug : undefined; + // The same per-slug query WorkspaceScopeLayout validates with, so a successful + // landing arrives at the workspace route with this result already cached. + const { workspace, status: workspaceStatus } = useWorkspace({ workspaceSlug }); + + if (flagStatus === "loading") { + return ; + } + + if (workspaceSlug) { + if (workspaceStatus === "loading") { + return ; + } + + if (workspace) { + return ; + } + } + + return ; +}; diff --git a/services/backend-api/client/src/pages/ScopeNavigationContainer.test.tsx b/services/backend-api/client/src/pages/ScopeNavigationContainer.test.tsx new file mode 100644 index 000000000..f468701d2 --- /dev/null +++ b/services/backend-api/client/src/pages/ScopeNavigationContainer.test.tsx @@ -0,0 +1,159 @@ +import "@testing-library/jest-dom"; +import { render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ScopeNavigationContainer } from "./ScopeNavigationContainer"; +import { useScopeCrumbLabel } from "../contexts/ScopeLabelContext"; +import { useDiscordAuthStatus, useUpdateUserMe, useUserMe } from "@/features/discordUser"; +import { useWorkspaces } from "@/features/workspaces"; + +vi.mock("@/features/discordUser", () => ({ + useDiscordAuthStatus: vi.fn(), + useUserMe: vi.fn(), + useUpdateUserMe: vi.fn(), +})); + +vi.mock("@/features/workspaces", () => ({ + useWorkspaces: vi.fn(), +})); + +const LabelProbe = () => {useScopeCrumbLabel()}; + +const mutateAsync = vi.fn().mockResolvedValue({}); + +const mockState = ({ + authenticated = true, + workspacesFeature = true, + workspaces = [{ id: "w1", name: "Acme", slug: "acme-marketing", role: "admin" }], + lastActiveWorkspaceSlug = null, +}: { + authenticated?: boolean; + workspacesFeature?: boolean; + workspaces?: Array<{ id: string; name: string; slug: string; role: string }>; + lastActiveWorkspaceSlug?: string | null; +}) => { + vi.mocked(useDiscordAuthStatus).mockReturnValue({ + data: { authenticated }, + } as never); + vi.mocked(useUserMe).mockReturnValue({ + data: authenticated + ? { + result: { + capabilities: { workspaces: workspacesFeature }, + featureFlags: { workspaces: workspacesFeature }, + preferences: { lastActiveWorkspaceSlug }, + }, + } + : undefined, + } as never); + vi.mocked(useWorkspaces).mockReturnValue({ workspaces } as never); + vi.mocked(useUpdateUserMe).mockReturnValue({ mutateAsync } as never); +}; + +const renderAt = (pathname: string) => + render( + + + + + , + ); + +describe("ScopeNavigationContainer", () => { + beforeEach(() => { + vi.clearAllMocks(); + mutateAsync.mockResolvedValue({}); + }); + + describe("scope label", () => { + it("labels workspace routes with the workspace name", () => { + mockState({}); + + renderAt("/workspaces/acme-marketing/feeds"); + + expect(screen.getByTestId("scope-label")).toHaveTextContent("Acme"); + }); + + it("labels personal routes with Personal once the user has a workspace", () => { + mockState({}); + + renderAt("/feeds"); + + expect(screen.getByTestId("scope-label")).toHaveTextContent("Personal"); + }); + + it("falls back to Feeds for users with no workspaces", () => { + mockState({ workspaces: [] }); + + renderAt("/feeds"); + + expect(screen.getByTestId("scope-label")).toHaveTextContent("Feeds"); + }); + + it("falls back to Feeds when the feature is disabled", () => { + mockState({ workspacesFeature: false }); + + renderAt("/feeds"); + + expect(screen.getByTestId("scope-label")).toHaveTextContent("Feeds"); + }); + }); + + describe("last-active scope recording", () => { + it("records the workspace slug when entering a workspace route", async () => { + mockState({ lastActiveWorkspaceSlug: null }); + + renderAt("/workspaces/acme-marketing/feeds"); + + await waitFor(() => { + expect(mutateAsync).toHaveBeenCalledWith({ + details: { preferences: { lastActiveWorkspaceSlug: "acme-marketing" } }, + }); + }); + }); + + it("records personal scope (null) when on the personal feed area", async () => { + mockState({ lastActiveWorkspaceSlug: "acme-marketing" }); + + renderAt("/feeds"); + + await waitFor(() => { + expect(mutateAsync).toHaveBeenCalledWith({ + details: { preferences: { lastActiveWorkspaceSlug: null } }, + }); + }); + }); + + it("does not record on scope-neutral routes like account settings", () => { + mockState({ lastActiveWorkspaceSlug: "acme-marketing" }); + + renderAt("/settings"); + + expect(mutateAsync).not.toHaveBeenCalled(); + }); + + it("does not re-record an unchanged scope", () => { + mockState({ lastActiveWorkspaceSlug: "acme-marketing" }); + + renderAt("/workspaces/acme-marketing/feeds"); + + expect(mutateAsync).not.toHaveBeenCalled(); + }); + + it("does not record an unknown workspace slug", () => { + mockState({ lastActiveWorkspaceSlug: null }); + + renderAt("/workspaces/not-my-team/feeds"); + + expect(mutateAsync).not.toHaveBeenCalled(); + }); + + it("records nothing for users with no workspaces", () => { + mockState({ workspaces: [], lastActiveWorkspaceSlug: null }); + + renderAt("/feeds"); + + expect(mutateAsync).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/services/backend-api/client/src/pages/ScopeNavigationContainer.tsx b/services/backend-api/client/src/pages/ScopeNavigationContainer.tsx new file mode 100644 index 000000000..92c9a4a1f --- /dev/null +++ b/services/backend-api/client/src/pages/ScopeNavigationContainer.tsx @@ -0,0 +1,82 @@ +import { useEffect, useMemo } from "react"; +import { matchPath, useLocation } from "react-router-dom"; +import { ScopeLabelProvider } from "../contexts/ScopeLabelContext"; +import { useDiscordAuthStatus, useUpdateUserMe, useUserMe } from "@/features/discordUser"; +import { useWorkspaces } from "@/features/workspaces"; + +const getWorkspaceSlugFromPath = (pathname: string) => + matchPath("/workspaces/:workspaceSlug/*", pathname)?.params.workspaceSlug ?? + matchPath("/workspaces/:workspaceSlug", pathname)?.params.workspaceSlug; + +const isPersonalFeedAreaPath = (pathname: string) => + pathname === "/feeds" || pathname.startsWith("/feeds/") || pathname === "/add-feeds"; + +/** + * Pages-layer container mounted once around the route tree. Two scope-navigation + * concerns live here so feature components never need cross-feature imports: + * + * 1. Provides the breadcrumb scope label (workspace name / "Personal", count-gated + * like the header switcher) via ScopeLabelContext. + * 2. Records the last-active scope to `preferences.lastActiveWorkspaceSlug` so the + * "/" landing can restore it. Fire-and-forget: only scope-meaningful routes are + * recorded (feed areas and workspace routes; scope-neutral pages like /settings + * are skipped), failures are swallowed, and navigation is never blocked. + */ +export const ScopeNavigationContainer = ({ children }: { children: React.ReactNode }) => { + const { pathname } = useLocation(); + const { data: authStatusData } = useDiscordAuthStatus(); + const authenticated = !!authStatusData?.authenticated; + const { data: userMe } = useUserMe({ enabled: authenticated }); + // Mirrors useIsWorkspacesEnabled's two-layer gate, but gated on auth so this + // globally-mounted container never fires user queries for logged-out visitors. + const workspacesEnabled = !!( + userMe?.result.capabilities?.workspaces && userMe?.result.featureFlags?.workspaces + ); + const { workspaces } = useWorkspaces({ enabled: workspacesEnabled }); + const hasWorkspaces = (workspaces?.length ?? 0) > 0; + const { mutateAsync: updateUserMe } = useUpdateUserMe(); + + const pathWorkspaceSlug = getWorkspaceSlugFromPath(pathname); + const activeWorkspace = pathWorkspaceSlug + ? workspaces?.find((w) => w.slug === pathWorkspaceSlug) + : undefined; + + const scopeLabel = useMemo(() => { + if (!workspacesEnabled || !hasWorkspaces) { + return undefined; + } + + if (pathWorkspaceSlug) { + return activeWorkspace?.name; + } + + return "Personal"; + }, [workspacesEnabled, hasWorkspaces, pathWorkspaceSlug, activeWorkspace?.name]); + + const storedSlug = userMe?.result.preferences?.lastActiveWorkspaceSlug ?? null; + // null records personal scope; undefined means the route is scope-neutral + // (or an unknown workspace slug) and nothing should be recorded. + let scopeToRecord: string | null | undefined; + + if (activeWorkspace) { + scopeToRecord = activeWorkspace.slug; + } else if (!pathWorkspaceSlug && isPersonalFeedAreaPath(pathname)) { + scopeToRecord = null; + } + + useEffect(() => { + if (!workspacesEnabled || !hasWorkspaces || !userMe) { + return; + } + + if (scopeToRecord === undefined || scopeToRecord === storedSlug) { + return; + } + + updateUserMe({ + details: { preferences: { lastActiveWorkspaceSlug: scopeToRecord } }, + }).catch(() => {}); + }, [workspacesEnabled, hasWorkspaces, !!userMe, scopeToRecord, storedSlug]); + + return {children}; +}; diff --git a/services/backend-api/client/src/pages/UserFeeds.tsx b/services/backend-api/client/src/pages/UserFeeds.tsx index aef7ab855..446af6c58 100644 --- a/services/backend-api/client/src/pages/UserFeeds.tsx +++ b/services/backend-api/client/src/pages/UserFeeds.tsx @@ -16,7 +16,7 @@ import { } from "@chakra-ui/react"; import { Link, useLocation, useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import { FaPlus, FaCircleCheck, FaChevronDown, FaTrash, FaCopy } from "react-icons/fa6"; +import { FaPlus, FaCircleCheck, FaChevronDown, FaGear, FaTrash, FaCopy } from "react-icons/fa6"; import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { FaPause, FaPlay } from "react-icons/fa"; import { IoDuplicate } from "react-icons/io5"; @@ -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"; @@ -58,6 +59,7 @@ import { CopyUserFeedSettingsDialog } from "../features/feed/components/CopyUser import { SetupChecklist } from "../features/feed/components/SetupChecklist"; import { useUnconfiguredFeeds } from "../features/feed/hooks/useUnconfiguredFeeds"; import { ReducedLimitAlert } from "@/features/subscriptionProducts"; +import { useCurrentWorkspace } from "@/features/workspaces"; import { MenuRoot, MenuTrigger, MenuContent, MenuItem, MenuSeparator } from "@/components/ui/menu"; export const UserFeeds = () => { @@ -105,6 +107,9 @@ 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 currentWorkspace = useCurrentWorkspace(); const { data: userMeData } = useUserMe(); const { data: userFeedsRequireAttentionResults } = useUserFeeds({ limit: 1, @@ -172,14 +177,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 +247,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 +279,7 @@ const UserFeedsInner: React.FC = () => { } } }, - [createUserFeed, createInfoAlert, discordUserMe?.maxUserFeeds], + [createUserFeed, createInfoAlert, discordUserMe?.maxUserFeeds, scope], ); const handleCuratedFeedRemove = useCallback( @@ -314,13 +321,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) => { @@ -457,6 +471,21 @@ const UserFeedsInner: React.FC = () => { return ( <> + {/* In-scope settings affordance: once inside a workspace, its settings are one + visible on-page click rather than buried in the header switcher menu. */} + {currentWorkspace && ( + + + {currentWorkspace.name} + + + + )} { + } @@ -534,7 +563,7 @@ const UserFeedsInner: React.FC = () => { <> - + {t("pages.userFeeds.title")}{" "} {totalFeedCount !== undefined && @@ -661,7 +690,7 @@ const UserFeedsInner: React.FC = () => { - + Add multiple feeds @@ -721,7 +750,12 @@ const UserFeedsInner: React.FC = () => { )} + {/* Keyed by scope: a search submitted just before a scope switch commits is + validated against the OLD scope's credentials. Remounting on scope change + guarantees no cross-scope search state (query, result, Add button) is ever + shown under the new scope. */} { } /> +
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..e23ad24ef 100644 --- a/services/backend-api/client/src/pages/index.tsx +++ b/services/backend-api/client/src/pages/index.tsx @@ -9,7 +9,9 @@ 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 { ScopeAwareLanding } from "./ScopeAwareLanding"; import { SuspenseErrorBoundary } from "../components/SuspenseErrorBoundary"; import { lazyWithRetries } from "../utils/lazyImportWithRetry"; @@ -42,12 +44,23 @@ 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 = () => ( } /> - } /> + + + + } + /> ( } /> + {/* 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 68% rename from services/backend-api/client/src/utils/getStandardErrorCodeMessage copy.ts rename to services/backend-api/client/src/utils/getStandardErrorCodeMessage.ts index 4600ebda8..d1ab1e072 100644 --- a/services/backend-api/client/src/utils/getStandardErrorCodeMessage copy.ts +++ b/services/backend-api/client/src/utils/getStandardErrorCodeMessage.ts @@ -51,6 +51,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", } @@ -134,6 +157,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/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/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/client/src/utils/theme.test.ts b/services/backend-api/client/src/utils/theme.test.ts new file mode 100644 index 000000000..269bacc04 --- /dev/null +++ b/services/backend-api/client/src/utils/theme.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { system } from "./theme"; + +/** + * Guards the Tier-1 recipe wiring that call sites depend on implicitly. If any of + * these regress, call sites won't fail to compile — surfaces just silently change + * color (e.g. buttons inside status Alerts inherit the alert's palette via the + * colorPalette CSS-var cascade, or control edges drop below the WCAG 1.4.11 ratio). + */ +describe("theme system", () => { + it("pins the button recipe to the neutral palette so ambient palettes never cascade in", () => { + const recipe = system.getRecipe("button"); + + expect(recipe.base?.colorPalette).toBe("gray"); + }); + + it("defaults buttons to the outline variant with the controlBorder edge", () => { + const recipe = system.getRecipe("button"); + + expect(recipe.defaultVariants?.variant).toBe("outline"); + expect(recipe.variants?.variant?.outline?.borderColor).toBe("controlBorder"); + }); + + it("points recipe-driven control outlines at controlBorder", () => { + for (const name of ["input", "textarea"]) { + expect(system.getRecipe(name).variants?.variant?.outline?.borderColor).toBe("controlBorder"); + } + + expect( + system.getSlotRecipe("nativeSelect").variants?.variant?.outline?.field?.borderColor, + ).toBe("controlBorder"); + }); + + it("pins checked checkbox/radio fills to the brand palette", () => { + expect(system.getSlotRecipe("checkbox").base?.root?.colorPalette).toBe("brand"); + expect(system.getSlotRecipe("radioGroup").base?.root?.colorPalette).toBe("brand"); + }); + + it("resolves the controlBorder semantic token", () => { + expect(system.token("colors.controlBorder")).toBeTruthy(); + }); +}); diff --git a/services/backend-api/client/src/utils/theme.ts b/services/backend-api/client/src/utils/theme.ts index dc1311451..92dfda84e 100644 --- a/services/backend-api/client/src/utils/theme.ts +++ b/services/backend-api/client/src/utils/theme.ts @@ -273,6 +273,13 @@ const infoSlots = { const defaultButtonRecipe = defaultConfig.theme!.recipes!.button; const buttonRecipe = { ...defaultButtonRecipe, + // Pin the NEUTRAL palette at the recipe level. `colorPalette` cascades via CSS vars, so a bare + // button inside a status-tinted container (e.g. an Alert, which hardwires its status palette) + // silently inherits that tint. Pinning gray here keeps buttons neutral-by-default everywhere; + // call sites that MEAN a hue still state it (colorPalette="brand" via PrimaryActionButton, + // explicit status palettes) and props win over the recipe. This also removes the need for any + // per-call-site colorPalette="gray" (which the lint ratchet now bans). + base: { ...defaultButtonRecipe.base, colorPalette: "gray" }, defaultVariants: { ...defaultButtonRecipe.defaultVariants, variant: "outline" }, variants: { ...defaultButtonRecipe.variants, 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..d1cabf102 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(), @@ -116,6 +128,13 @@ const configSchema = z.object({ BACKEND_API_REDDIT_CLIENT_ID: z.string().optional(), BACKEND_API_REDDIT_CLIENT_SECRET: z.string().optional(), BACKEND_API_REDDIT_REDIRECT_URI: z.string().optional(), + // Overridable so tests can point Reddit traffic at a mock server. + BACKEND_API_REDDIT_API_BASE_URL: z + .string() + .default("https://www.reddit.com/api/v1"), + BACKEND_API_REDDIT_AUTHENTICATED_FEED_BASE_URL: z + .string() + .default("https://oauth.reddit.com"), // Admin BACKEND_API_ADMIN_USER_IDS: z @@ -129,7 +148,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..25634b7b3 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,10 +57,13 @@ 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"; import { UserFeedsService } from "./services/user-feeds/user-feeds.service"; +import { FeedCredentialsService } from "./services/feed-credentials/feed-credentials.service"; import { FeedConnectionsDiscordChannelsService } from "./services/feed-connections-discord-channels/feed-connections-discord-channels.service"; import { FeedFetcherService } from "./services/feed-fetcher"; import { createSmtpTransport } from "./infra/smtp"; @@ -91,7 +95,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,9 +128,12 @@ export interface Container { feedSchedulingService: FeedSchedulingService; feedsService: FeedsService; notificationsService: NotificationsService; + emailVerificationService: EmailVerificationService; + workspacesService: WorkspacesService; discordServersService: DiscordServersService; userFeedConnectionEventsService: UserFeedConnectionEventsService; mongoMigrationsService: MongoMigrationsService; + feedCredentialsService: FeedCredentialsService; userFeedsService: UserFeedsService; feedConnectionsDiscordChannelsService: FeedConnectionsDiscordChannelsService; userFeedManagementInvitesService: UserFeedManagementInvitesService; @@ -166,6 +175,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,11 +269,29 @@ 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, + userFeedRepository, + emailVerificationService, + redditApiService, + }); + const notificationsService = new NotificationsService({ config: deps.config, smtpTransport, usersService, userFeedRepository, + workspaceRepository, notificationDeliveryAttemptRepository, }); @@ -280,6 +313,12 @@ export function createContainer(deps: { userRepository, }); + const feedCredentialsService = new FeedCredentialsService({ + config: deps.config, + usersService, + workspacesService, + }); + const feedConnectionsDiscordChannelsService = new FeedConnectionsDiscordChannelsService({ config: deps.config, @@ -292,6 +331,7 @@ export function createContainer(deps: { discordAuthService, connectionEventsService: userFeedConnectionEventsService, usersService, + feedCredentialsService, }); const userFeedsService = new UserFeedsService({ @@ -300,10 +340,13 @@ export function createContainer(deps: { userRepository, feedsService, supportersService, + workspacesService, + feedCredentialsService, feedFetcherApiService, feedFetcherService, feedHandlerService, usersService, + notificationsService, publishMessage, feedConnectionsDiscordChannelsService, }); @@ -382,6 +425,8 @@ export function createContainer(deps: { notificationDeliveryAttemptRepository, feedSubscriberRepository, userRepository, + workspaceRepository, + emailVerificationRepository, customerRepository, feedRepository, feedFilteredFormatRepository, @@ -412,9 +457,12 @@ export function createContainer(deps: { feedSchedulingService, feedsService, notificationsService, + emailVerificationService, + workspacesService, discordServersService, userFeedConnectionEventsService, mongoMigrationsService, + feedCredentialsService, userFeedsService, feedConnectionsDiscordChannelsService, userFeedManagementInvitesService, 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/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(`