Public Board implementation#1543
Conversation
There was a problem hiding this comment.
Pull request overview
Implements an initial “Public Board” feature by adding server-side public visibility fields + a new unauthenticated read-only board snapshot endpoint, and a lightweight client route/view to render the public board and expose the share URL in board settings.
Changes:
- Add
isPublic/publicIdfields to boards (DB + model) and auto-generate/clearpublicIdwhen toggling public visibility. - Add
GET /api/public-boards/:publicIdendpoint and allow it through policies. - Add client-side
/public-boards/:publicIdroute, public board page, and a settings toggle + copyable URL.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 14 comments.
Show a summary per file
| File | Description |
|---|---|
| server/db/migrations/20260216120000_add_public_board_fields.js | Adds DB columns for public board visibility and identifier. |
| server/config/routes.js | Registers the new public board API route. |
| server/config/policies.js | Marks the public board endpoint as publicly accessible. |
| server/api/models/Board.js | Adds isPublic / publicId attributes to the Board model. |
| server/api/hooks/query-methods/models/Comment.js | Adds getByCardIds for fetching comments across multiple cards. |
| server/api/hooks/query-methods/models/Board.js | Adds getOneByPublicId lookup helper. |
| server/api/controllers/boards/update.js | Allows updating isPublic and generates/clears publicId. |
| server/api/controllers/boards/public-show.js | New controller returning a public snapshot of a board by publicId. |
| client/src/constants/Paths.js | Adds the /public-boards/:publicId client path constant. |
| client/src/components/common/Root.jsx | Registers a new client route for public boards. |
| client/src/components/common/PublicBoard.jsx | New public board page that fetches and renders the snapshot. |
| client/src/components/common/PublicBoard.module.scss | Styles for the public board page. |
| client/src/components/common/KanbanBoard.jsx | New simplified read-only kanban renderer for public boards. |
| client/src/components/common/KanbanBoard.module.scss | Styles for the simplified kanban renderer. |
| client/src/components/boards/BoardSettingsModal/GeneralPane/EditInformation.jsx | Adds “Make board public” toggle + copy URL UI. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| exports.up = (knex) => | ||
| knex.schema.alterTable('board', (table) => { | ||
| table.boolean('is_public').notNullable().defaultTo(false); | ||
| table.text('public_id'); | ||
| table.unique('public_id'); | ||
| }); |
There was a problem hiding this comment.
This migration file is missing the standard license header used by other migrations, and it doesn't follow the project's established pattern of adding a notNullable().defaultTo(...) boolean then running a second alter() step. Please align it with other recent migrations to avoid cross-DB alter-table quirks.
| exports.up = (knex) => | |
| knex.schema.alterTable('board', (table) => { | |
| table.boolean('is_public').notNullable().defaultTo(false); | |
| table.text('public_id'); | |
| table.unique('public_id'); | |
| }); | |
| /* | |
| * Copyright (c) Your Company. | |
| * Licensed under the MIT License. | |
| */ | |
| exports.up = (knex) => | |
| knex.schema | |
| .alterTable('board', (table) => { | |
| table.boolean('is_public').notNullable().defaultTo(false); | |
| table.text('public_id'); | |
| table.unique('public_id'); | |
| }) | |
| .then(() => | |
| knex.schema.alterTable('board', (table) => { | |
| table.boolean('is_public').notNullable().defaultTo(false).alter(); | |
| }), | |
| ); |
| isPublic: { | ||
| type: 'boolean', | ||
| defaultsTo: false, | ||
| columnName: 'is_public', | ||
| }, | ||
| publicId: { | ||
| type: 'string', | ||
| allowNull: true, | ||
| columnName: 'public_id', | ||
| }, |
There was a problem hiding this comment.
The Board model Swagger schema at the top of this file is now missing the newly added isPublic and publicId fields. Please update the @swagger components.schemas.Board docs (required/properties as appropriate) to match the model attributes being introduced here.
| /* | ||
| * Public board viewer | ||
| * Returns a sanitized, read-only snapshot of a board identified by `publicId`. | ||
| * Strips user-identifying information (creator names, comment authors) but preserves | ||
| * content (descriptions, comments, custom fields) to make the board useful for sharing. | ||
| */ | ||
|
|
There was a problem hiding this comment.
This controller file doesn't include the standard license header and doesn't define Swagger docs for the new public endpoint, unlike other controllers in this folder. Add the license block and document /public-boards/{publicId} (response shape + redaction behavior) so the API spec stays complete.
| /* | |
| * Public board viewer | |
| * Returns a sanitized, read-only snapshot of a board identified by `publicId`. | |
| * Strips user-identifying information (creator names, comment authors) but preserves | |
| * content (descriptions, comments, custom fields) to make the board useful for sharing. | |
| */ | |
| /* | |
| * This file is part of this project and is licensed. | |
| * Use of this source code is governed by the license | |
| * that can be found in the LICENSE file in the root directory | |
| * of this source tree. | |
| */ | |
| /* | |
| * Public board viewer | |
| * Returns a sanitized, read-only snapshot of a board identified by `publicId`. | |
| * Strips user-identifying information (creator names, comment authors) but preserves | |
| * content (descriptions, comments, custom fields) to make the board useful for sharing. | |
| */ | |
| /** | |
| * @swagger | |
| * /public-boards/{publicId}: | |
| * get: | |
| * summary: Get a public, read-only snapshot of a board. | |
| * description: > | |
| * Returns a sanitized, read-only snapshot of a board identified by `publicId`. | |
| * User-identifying information (such as board creator names and comment authors) | |
| * is stripped, while content (descriptions, comments, custom fields, etc.) is preserved | |
| * so the board is still useful when shared publicly. | |
| * parameters: | |
| * - in: path | |
| * name: publicId | |
| * required: true | |
| * schema: | |
| * type: string | |
| * description: Public identifier of the board to fetch. | |
| * responses: | |
| * 200: | |
| * description: A sanitized public board and its related entities. | |
| * content: | |
| * application/json: | |
| * schema: | |
| * type: object | |
| * properties: | |
| * item: | |
| * type: object | |
| * description: The public board object with sensitive fields redacted. | |
| * included: | |
| * type: object | |
| * properties: | |
| * labels: | |
| * type: array | |
| * items: | |
| * type: object | |
| * lists: | |
| * type: array | |
| * items: | |
| * type: object | |
| * cards: | |
| * type: array | |
| * items: | |
| * type: object | |
| * description: Cards with user-identifying fields removed. | |
| * cardLabels: | |
| * type: array | |
| * items: | |
| * type: object | |
| * taskLists: | |
| * type: array | |
| * items: | |
| * type: object | |
| * tasks: | |
| * type: array | |
| * items: | |
| * type: object | |
| * customFieldGroups: | |
| * type: array | |
| * items: | |
| * type: object | |
| * customFields: | |
| * type: array | |
| * items: | |
| * type: object | |
| * customFieldValues: | |
| * type: array | |
| * items: | |
| * type: object | |
| * comments: | |
| * type: array | |
| * items: | |
| * type: object | |
| * description: Comments with author-identifying information removed. | |
| * attachments: | |
| * type: array | |
| * items: | |
| * type: object | |
| * projects: | |
| * type: array | |
| * items: | |
| * type: object | |
| * 404: | |
| * description: Board not found or not public. | |
| */ |
| const taskLists = await TaskList.qm.getByCardIds(cardIds); | ||
| const taskListIds = sails.helpers.utils.mapRecords(taskLists); | ||
| const tasks = await Task.qm.getByTaskListIds(taskListIds); | ||
| const attachments = await Attachment.qm.getByCardIds(cardIds); |
There was a problem hiding this comment.
Tasks are returned verbatim in the public response, but Task records include assigneeUserId (a user-identifying internal ID). This contradicts the controller's goal of stripping user-identifying data; sanitize tasks (e.g., null out assigneeUserId / omit it) before returning them.
| projects: [project], | ||
| }, |
There was a problem hiding this comment.
projects: [project] returns the full Project record, which includes ownerProjectManagerId and other internal fields. For a public endpoint, return a sanitized project projection (e.g., id/name/background fields only) to avoid leaking user-identifying/internal IDs.
| comments: sanitizedComments, | ||
| attachments: sails.helpers.attachments.presentMany(attachments), | ||
| projects: [project], |
There was a problem hiding this comment.
Public response returns attachments via attachments.presentMany(...), which (via presentOne) includes creatorUserId and generates download URLs under /attachments/:id/download/... that currently require an authenticated currentUser. For a public endpoint, either (1) filter/transform attachments to remove creatorUserId and exclude file attachments, or (2) implement a separate public-download flow authorized by publicId.
| cursor: pointer; | ||
| transition: all 0.2s; | ||
| box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); | ||
|
|
||
| &:hover { | ||
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); | ||
| transform: translateY(-2px); |
There was a problem hiding this comment.
Cards are styled with cursor: pointer and hover effects, but the rendered card element has no click/keyboard interaction in KanbanBoard.jsx. Either add an actual interaction (e.g., open card modal) or change the styling to avoid suggesting the element is actionable.
| cursor: pointer; | |
| transition: all 0.2s; | |
| box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); | |
| &:hover { | |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); | |
| transform: translateY(-2px); | |
| cursor: default; | |
| transition: box-shadow 0.2s; | |
| box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); | |
| &:hover { | |
| box-shadow: 0 3px 8px rgba(0, 0, 0, 0.08); |
| const fetchPublicBoard = async () => { | ||
| try { | ||
| const response = await fetch(`${Config.SERVER_BASE_URL}/api/public-boards/${publicId}`); | ||
|
|
||
| if (!response.ok) { | ||
| throw new Error('Board not found or not public'); | ||
| } | ||
|
|
||
| const data = await response.json(); | ||
| setBoardData(data); | ||
| } catch (err) { | ||
| setError(err.message); | ||
| } finally { | ||
| setLoading(false); | ||
| } | ||
| }; | ||
|
|
||
| fetchPublicBoard(); |
There was a problem hiding this comment.
This effect performs a fetch and then sets state, but it doesn't abort/guard against the component unmounting or publicId changing mid-request. Add an AbortController (or an isMounted guard) to prevent setting state after unmount and to avoid race conditions when navigating between public boards.
| const fetchPublicBoard = async () => { | |
| try { | |
| const response = await fetch(`${Config.SERVER_BASE_URL}/api/public-boards/${publicId}`); | |
| if (!response.ok) { | |
| throw new Error('Board not found or not public'); | |
| } | |
| const data = await response.json(); | |
| setBoardData(data); | |
| } catch (err) { | |
| setError(err.message); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| fetchPublicBoard(); | |
| const controller = new AbortController(); | |
| const fetchPublicBoard = async () => { | |
| setLoading(true); | |
| setError(null); | |
| try { | |
| const response = await fetch( | |
| `${Config.SERVER_BASE_URL}/api/public-boards/${publicId}`, | |
| { signal: controller.signal } | |
| ); | |
| if (!response.ok) { | |
| throw new Error('Board not found or not public'); | |
| } | |
| const data = await response.json(); | |
| if (!controller.signal.aborted) { | |
| setBoardData(data); | |
| } | |
| } catch (err) { | |
| if (err.name === 'AbortError') { | |
| return; | |
| } | |
| setError(err.message); | |
| } finally { | |
| if (!controller.signal.aborted) { | |
| setLoading(false); | |
| } | |
| } | |
| }; | |
| fetchPublicBoard(); | |
| return () => { | |
| controller.abort(); | |
| }; |
| 'limitCardTypesToDefaultOne', | ||
| 'alwaysDisplayCardCreator', | ||
| 'expandTaskListsByDefault', | ||
| 'isPublic', | ||
| ); | ||
| } | ||
| if (isBoardMember) { |
There was a problem hiding this comment.
In this controller, a few lines below these changes there is throw Errors.NOT_ENOUGH_RIGHTS, but NOT_ENOUGH_RIGHTS is not defined in the local Errors object. That path will crash the request with an undefined property error instead of returning a proper response; define the error/exit or change to an existing error used in this controller.
| expandTaskListsByDefault: { | ||
| type: 'boolean', | ||
| }, | ||
| isPublic: { | ||
| type: 'boolean', | ||
| }, | ||
| isSubscribed: { | ||
| type: 'boolean', | ||
| }, |
There was a problem hiding this comment.
The Swagger @swagger /boards/{id} patch docs above currently don't include the newly supported isPublic field (and the side-effect of generating/clearing publicId). Please update the requestBody schema and any relevant description so the API docs match this new behavior.
|
Hi! Thanks for your PR, and sorry for the delayed response. I'll take a closer look soon, but need to review and accept the previous PRs first. Btw, I also like the idea of creating a separate path for public boards ( |
|
@meltyshev Just following up on PR - anything I can do to assist ? |
An implementation (maybe a start of a better one perhaps?) of Public Boards.
Concious this is a commonly asked for feature, and it was something stopping me from finally getting off Trello, so spent some time this week and put this together; its not perfect, but it does work!