Skip to content

Commit 35f5e83

Browse files
authored
Merge pull request #37 from markrai/feature/notifications
todo dialog roles for role: viewers to disable restricted items + key…
2 parents b8358ca + 45f3835 commit 35f5e83

12 files changed

Lines changed: 451 additions & 23 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,20 @@
33
> **Upgrades:** No breaking changes in **3.7.x** / **3.8.x** / **3.9.x** / **3.10.x** / **3.11.x** unless noted below.
44
55

6+
## [3.11.1] - 2026-04-04
7+
8+
### Fixes
9+
10+
- **Project list** — Invited users now see **authenticated** temporary boards (with a creator) they belong to via **`project_members`**. The membership branch does not apply when **`creator_user_id`** is null, so anonymous paste boards never appear from stray membership rows alone.
11+
- **Todo dialog (roles)****Viewers:** read-only title, status, body, links; Save off; “View Todo” when nothing to save. **Contributors:** title and status locked (body-only when assigned, same as API). Submit handler checks permissions; viewers no longer enter bulk-select via Ctrl/Cmd+click on cards.
12+
13+
### Other
14+
15+
- **Keycloak (local dev)**`docs/keycloak/realm-scrumboy-local.json` import + `docs/keycloak/README.md` (issuer env, public-client secret placeholder).
16+
- **Tests**`internal/store/list_projects_test.go` for temp-board listing.
17+
18+
---
19+
620
## [3.11.0] - 2026-04-04
721

822
### Features

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<p align="center">
22
<img width="372" src="internal/httpapi/web/githublogo.png" alt="scrumboy logo" />
33
<br />
4-
<img src="https://img.shields.io/badge/version-v3.11.0-blue" alt="version" />
4+
<img src="https://img.shields.io/badge/version-v3.11.1-blue" alt="version" />
55
<a href="LICENSE">
66
<img src="https://img.shields.io/badge/license-AGPL--v3-orange" alt="license" />
77
</a>

docs/keycloak/README.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Keycloak realm (local development)
2+
3+
This directory contains a **realm export** for running Scrumboy against a local Keycloak instance.
4+
5+
## File
6+
7+
- [`realm-scrumboy-local.json`](realm-scrumboy-local.json) — realm **`scrumboy`**, public OIDC client **`scrumboy`**, test user **`test`** / **`test`**, redirect URIs for Scrumboy on **localhost:8080**.
8+
9+
## Import
10+
11+
Stop Keycloak, then (paths may vary):
12+
13+
```sh
14+
/opt/keycloak/bin/kc.sh import --file /path/to/realm-scrumboy-local.json
15+
```
16+
17+
Or copy the file into Keycloak’s import directory and start with `--import-realm` per [Keycloak import/export](https://www.keycloak.org/server/importExport).
18+
19+
After import, the **issuer** (no `/auth` prefix on modern Keycloak) is:
20+
21+
```text
22+
http://<keycloak-host>:<port>/realms/scrumboy
23+
```
24+
25+
Example if Keycloak listens on **8180**:
26+
27+
```sh
28+
SCRUMBOY_OIDC_ISSUER=http://localhost:8180/realms/scrumboy
29+
SCRUMBOY_OIDC_CLIENT_ID=scrumboy
30+
SCRUMBOY_OIDC_CLIENT_SECRET=dev-public-client-placeholder
31+
SCRUMBOY_OIDC_REDIRECT_URL=http://localhost:8080/api/auth/oidc/callback
32+
```
33+
34+
Scrumboy’s config requires **all four** variables and a **non-empty** `SCRUMBOY_OIDC_CLIENT_SECRET` string. For this **public** Keycloak client, the token endpoint uses **PKCE**; use any non-empty placeholder secret in Scrumboy’s env (Keycloak ignores it for public clients).
35+
36+
Ensure the **redirect URL** matches what is registered in the client (**`…/*`** in the realm file covers `/api/auth/oidc/callback`).
37+
38+
## Test user
39+
40+
| Field | Value |
41+
|----------|------------------|
42+
| Username | `test` |
43+
| Password | `test` |
44+
| Email | `test@example.com` |
45+
46+
`emailVerified` is **true** so Scrumboy accepts the ID token (`email_verified` requirement).
47+
48+
## Security note
49+
50+
This realm is for **local development** only (`sslRequired: none`, known test password). Do not import as-is into production.
51+
52+
For more on Scrumboy OIDC behavior, see [OIDC.md](../OIDC.md).
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
{
2+
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
3+
"realm": "scrumboy",
4+
"displayName": "Scrumboy (local dev)",
5+
"enabled": true,
6+
"sslRequired": "none",
7+
"registrationAllowed": false,
8+
"registrationEmailAsUsername": false,
9+
"rememberMe": true,
10+
"verifyEmail": false,
11+
"loginWithEmailAllowed": true,
12+
"duplicateEmailsAllowed": false,
13+
"resetPasswordAllowed": true,
14+
"editUsernameAllowed": false,
15+
"bruteForceProtected": true,
16+
"failureFactor": 5,
17+
"defaultSignatureAlgorithm": "RS256",
18+
"accessTokenLifespan": 300,
19+
"ssoSessionIdleTimeout": 1800,
20+
"ssoSessionMaxLifespan": 36000,
21+
"accessCodeLifespan": 60,
22+
"accessCodeLifespanUserAction": 300,
23+
"accessCodeLifespanLogin": 1800,
24+
"browserFlow": "browser",
25+
"registrationFlow": "registration",
26+
"directGrantFlow": "direct grant",
27+
"resetCredentialsFlow": "reset credentials",
28+
"clientAuthenticationFlow": "clients",
29+
"dockerAuthenticationFlow": "docker auth",
30+
"firstBrokerLoginFlow": "first broker login",
31+
"attributes": {
32+
"cibaBackchannelTokenDeliveryMode": "poll",
33+
"cibaExpiresIn": "120",
34+
"cibaAuthRequestedUserHint": "login_hint",
35+
"parRequestUriLifespan": "60"
36+
},
37+
"browserSecurityHeaders": {
38+
"contentSecurityPolicyReportOnly": "",
39+
"xContentTypeOptions": "nosniff",
40+
"referrerPolicy": "no-referrer",
41+
"xRobotsTag": "none",
42+
"xFrameOptions": "SAMEORIGIN",
43+
"contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';",
44+
"strictTransportSecurity": "max-age=31536000; includeSubDomains"
45+
},
46+
"smtpServer": {},
47+
"eventsEnabled": false,
48+
"eventsListeners": ["jboss-logging"],
49+
"roles": {
50+
"realm": [
51+
{
52+
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
53+
"name": "offline_access",
54+
"description": "${role_offline-access}",
55+
"composite": false,
56+
"clientRole": false,
57+
"attributes": {}
58+
},
59+
{
60+
"id": "c3d4e5f6-a7b8-9012-cdef-123456789012",
61+
"name": "uma_authorization",
62+
"description": "${role_uma_authorization}",
63+
"composite": false,
64+
"clientRole": false,
65+
"attributes": {}
66+
},
67+
{
68+
"id": "d4e5f6a7-b8c9-0123-def0-234567890123",
69+
"name": "default-roles-scrumboy",
70+
"description": "${role_default-roles}",
71+
"composite": true,
72+
"composites": {
73+
"realm": ["offline_access", "uma_authorization"]
74+
},
75+
"clientRole": false,
76+
"attributes": {}
77+
}
78+
]
79+
},
80+
"defaultRole": {
81+
"name": "default-roles-scrumboy",
82+
"description": "${role_default-roles}",
83+
"composite": true,
84+
"clientRole": false
85+
},
86+
"users": [
87+
{
88+
"username": "test",
89+
"enabled": true,
90+
"emailVerified": true,
91+
"email": "test@example.com",
92+
"firstName": "Test",
93+
"lastName": "User",
94+
"credentials": [
95+
{
96+
"type": "password",
97+
"value": "test",
98+
"temporary": false
99+
}
100+
],
101+
"realmRoles": ["default-roles-scrumboy"]
102+
}
103+
],
104+
"clients": [
105+
{
106+
"clientId": "scrumboy",
107+
"name": "Scrumboy",
108+
"description": "Scrumboy web app (local development)",
109+
"rootUrl": "http://localhost:8080",
110+
"baseUrl": "http://localhost:8080",
111+
"surrogateAuthRequired": false,
112+
"enabled": true,
113+
"alwaysDisplayInConsole": false,
114+
"clientAuthenticatorType": "client-secret",
115+
"redirectUris": [
116+
"http://localhost:8080/*",
117+
"http://127.0.0.1:8080/*"
118+
],
119+
"webOrigins": [
120+
"http://localhost:8080",
121+
"http://127.0.0.1:8080"
122+
],
123+
"notBefore": 0,
124+
"bearerOnly": false,
125+
"consentRequired": false,
126+
"standardFlowEnabled": true,
127+
"implicitFlowEnabled": false,
128+
"directAccessGrantsEnabled": true,
129+
"serviceAccountsEnabled": false,
130+
"publicClient": true,
131+
"frontchannelLogout": true,
132+
"protocol": "openid-connect",
133+
"attributes": {
134+
"post.logout.redirect.uris": "http://localhost:8080/*##http://127.0.0.1:8080/*",
135+
"pkce.code.challenge.method": "S256"
136+
},
137+
"fullScopeAllowed": true,
138+
"nodeReRegistrationTimeout": -1,
139+
"defaultClientScopes": [
140+
"web-origins",
141+
"acr",
142+
"profile",
143+
"roles",
144+
"basic",
145+
"email"
146+
],
147+
"optionalClientScopes": [
148+
"address",
149+
"phone",
150+
"offline_access",
151+
"microprofile-jwt"
152+
]
153+
}
154+
]
155+
}

internal/httpapi/web/app.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { apiFetch } from './dist/api.js';
77
import { navigate, router } from './dist/router.js';
88
import { getRoute, getProjectId, getBoard, getAuthStatusAvailable, getMobileTab, getSlug, getTag, getSearch, getSprintIdFromUrl, getProjectView, getProjectsTab, getProjects, getSettingsProjectId, getEditingTodo, getAvailableTags, getAutocompleteSuggestion, getAvailableTagsMap, getTagColors, getUser, getSettingsActiveTab, getBackupImportBtn, getBackupData, getBackupPreview, getAuthStatusChecked } from './dist/state/selectors.js';
99
import { setProjectId, setBoard, setSlug, setTag, setMobileTab, setProjects, setProjectsTab, setProjectView, setEditingTodo, setAvailableTags, setAvailableTagsMap, setAutocompleteSuggestion, setTagColors, setSettingsProjectId, setSettingsActiveTab, setBackupImportBtn, setBackupData, setBackupPreview } from './dist/state/mutations.js';
10-
import { openTodoDialog, renderTagsChips, setupTagAutocomplete, removeTag, renderTagAutocomplete, getTagsFromChips, resetAssigneeSelect } from './dist/dialogs/todo.js';
10+
import { openTodoDialog, renderTagsChips, setupTagAutocomplete, removeTag, renderTagAutocomplete, getTagsFromChips, resetAssigneeSelect, getTodoFormPermissions } from './dist/dialogs/todo.js';
1111
import { renderSettingsModal, invalidateTagsCache } from './dist/dialogs/settings.js';
1212
import { initDnD, columnsSpec, dragInProgress, dragJustEnded } from './dist/features/drag-drop.js';
1313
import { setupContextMenuCloseHandler } from './dist/features/context-menu.js';
@@ -117,6 +117,10 @@ deleteTodoBtn.addEventListener("click", async () => {
117117
todoForm.addEventListener("submit", async (e) => {
118118
e.preventDefault();
119119

120+
if (!getTodoFormPermissions().canSubmitTodo) {
121+
return;
122+
}
123+
120124
const title = todoTitle.value;
121125
const body = todoBody.value;
122126
const tags = getTagsFromChips();

internal/httpapi/web/dist/dialogs/todo.js

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,14 @@ let permissions = {
1616
canEditNotes: false,
1717
canEditAssignment: false,
1818
canDeleteTodo: false,
19+
canEditTitle: false,
20+
canEditStatus: false,
21+
canSubmitTodo: false,
22+
canEditLinks: false,
1923
};
24+
export function getTodoFormPermissions() {
25+
return { ...permissions };
26+
}
2027
let linksSearchDebounce = null;
2128
let linksSearchController = null;
2229
let lastLoadedLinksForTodo = null;
@@ -514,12 +521,17 @@ function renderLinksChips(slug, currentLocalId, onNavigateToLinkedTodo) {
514521
const container = document.getElementById("linksChips");
515522
if (!container)
516523
return;
517-
const outbound = currentLinks.outbound.map((item) => `
524+
const outbound = currentLinks.outbound.map((item) => {
525+
const removeBtn = permissions.canEditLinks
526+
? `<button type="button" class="tag-chip-remove" data-link-remove="${item.localId}" aria-label="Remove link">×</button>`
527+
: "";
528+
return `
518529
<span class="tag-chip" data-link-local-id="${item.localId}" data-link-direction="outbound">
519530
<button type="button" class="tag-chip-link" data-link-open="${item.localId}">#${item.localId} ${escapeHTML(item.title)}</button>
520-
<button type="button" class="tag-chip-remove" data-link-remove="${item.localId}" aria-label="Remove link">×</button>
531+
${removeBtn}
521532
</span>
522-
`).join("");
533+
`;
534+
}).join("");
523535
const inbound = currentLinks.inbound.map((item) => `
524536
<span class="tag-chip" data-link-local-id="${item.localId}" data-link-direction="inbound">
525537
<button type="button" class="tag-chip-link" data-link-open="${item.localId}">#${item.localId} ${escapeHTML(item.title)}</button>
@@ -591,6 +603,13 @@ function setupLinkedStoriesSearch(slug, currentLocalId, onNavigateToLinkedTodo)
591603
linkAutocompleteSuggestion = null;
592604
removeLinksAutocompleteOverlay();
593605
clearLinkSearchInFlight();
606+
if (!permissions.canEditLinks) {
607+
input.disabled = true;
608+
input.placeholder = "";
609+
if (addBtn)
610+
addBtn.disabled = true;
611+
return;
612+
}
594613
const submitLinkFromInput = async () => {
595614
const directLocalID = parseLocalIDFromLinkInput(input.value);
596615
const target = linkAutocompleteSuggestion?.localId ?? directLocalID;
@@ -726,17 +745,29 @@ export async function openTodoDialog(opts) {
726745
const board = getBoard();
727746
const anonymousBoard = isAnonymousBoard(board);
728747
const isMaintainer = (opts.role ?? "") === "maintainer" || anonymousBoard;
748+
const roleNorm = (opts.role ?? "").toLowerCase();
749+
const isContributor = roleNorm === "contributor" || roleNorm === "editor";
729750
const currentUser = getUser();
730751
const isAssignedToMe = currentUser &&
731752
mode === "edit" &&
732753
Number(todo?.assigneeUserId) === Number(currentUser.id);
754+
const canEditTitle = isMaintainer;
755+
const canEditStatus = isMaintainer;
756+
const canSubmitTodo = mode === "create"
757+
? isMaintainer || anonymousBoard
758+
: isMaintainer || (!anonymousBoard && isContributor && !!isAssignedToMe);
759+
const canEditLinks = isMaintainer || (!anonymousBoard && isContributor);
733760
permissions = {
734761
canChangeSprint: isMaintainer && !anonymousBoard,
735762
canChangeEstimation: isMaintainer,
736763
canEditTags: isMaintainer,
737-
canEditNotes: isMaintainer || (!anonymousBoard && opts.role === "contributor" && !!isAssignedToMe),
764+
canEditNotes: isMaintainer || (!anonymousBoard && isContributor && !!isAssignedToMe),
738765
canEditAssignment: isMaintainer && !anonymousBoard,
739766
canDeleteTodo: isMaintainer,
767+
canEditTitle,
768+
canEditStatus,
769+
canSubmitTodo,
770+
canEditLinks,
740771
};
741772
// Fetch available tags for autocomplete
742773
// Authenticated boards: fetch ALL user-owned tags from full library (/api/tags/mine)
@@ -959,7 +990,7 @@ export async function openTodoDialog(opts) {
959990
setDates(undefined, undefined);
960991
}
961992
else {
962-
todoDialogTitle.textContent = "Edit Todo";
993+
todoDialogTitle.textContent = permissions.canSubmitTodo ? "Edit Todo" : "View Todo";
963994
todoTitle.value = todo.title || "";
964995
todoBody.value = todo.body || "";
965996
todoTags.value = "";
@@ -993,6 +1024,11 @@ export async function openTodoDialog(opts) {
9931024
if (addTagBtn)
9941025
addTagBtn.disabled = !permissions.canEditTags;
9951026
todoBody.readOnly = !permissions.canEditNotes;
1027+
todoTitle.readOnly = !permissions.canEditTitle;
1028+
todoStatus.disabled = !permissions.canEditStatus;
1029+
const saveTodoBtn = document.getElementById("saveTodoBtn");
1030+
if (saveTodoBtn)
1031+
saveTodoBtn.disabled = !permissions.canSubmitTodo;
9961032
// 4. Tag chips: clear and re-render so old chips (with "×" from previous maintainer open) are not reused
9971033
const tagsChips = document.getElementById("tagsChips");
9981034
if (tagsChips)
@@ -1022,7 +1058,12 @@ export async function openTodoDialog(opts) {
10221058
return;
10231059
}
10241060
if (mode === "edit") {
1025-
todoStatus?.focus();
1061+
if (!permissions.canSubmitTodo) {
1062+
closeTodoBtn?.focus();
1063+
}
1064+
else {
1065+
todoStatus?.focus();
1066+
}
10261067
}
10271068
else {
10281069
todoTitle.focus();

internal/httpapi/web/dist/views/board.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -724,6 +724,11 @@ function attachBoardDelegationHandlers() {
724724
if (!todo)
725725
return;
726726
if (me.ctrlKey || me.metaKey) {
727+
if (currentUserProjectRole === "viewer") {
728+
clearTodoMultiSelection();
729+
openTodoFromCard(todo);
730+
return;
731+
}
727732
e.preventDefault();
728733
e.stopPropagation();
729734
toggleTodoSelection(id);

0 commit comments

Comments
 (0)