Compare commits

..
Author SHA1 Message Date
kristianwindandClaude Opus 5 f400705bb7 fix(release): tagbeskeden når frem til udgivelsen
Den skulle have gjort det siden #72, og gjorde det ikke. v0.45.0 og v0.46.0 fik
begge flettecommittens overskrift som udgivelsestekst — uden at noget fejlede,
og uden at nogen kunne se det andet end på siden bagefter.

`fetch-depth: 0` er ikke nok. actions/checkout henter først alle tags korrekt og
overskriver derefter netop den ene, der udløste kørslen:

    git rev-parse refs/tags/v0.46.0
    git fetch --no-tags origin +0dc4c2f9b8…:refs/tags/v0.46.0

Den henter commit-SHA'en ind i tag-refen. Den annoterede tag bliver dermed til
en letvægtstag, der peger direkte på commit'en, og `%(contents)` giver så
commitbeskeden. Kommentaren i filen sagde, at en overfladisk checkout var
problemet; det var rigtigt og utilstrækkeligt.

Tagget hentes derfor som *ref* igen, lige før det læses.

Og vagten: hver tagbesked begynder med sin egen version, så holder det ikke,
læser vi noget andet end tagget, og kørslen fejler frem for at udgive en forkert
tekst. Det er det, der manglede — fejlen var ikke svær at rette, den var svær at
opdage.

Målt i et klonet arbejdstræ i alle tre tilstande: annoteret giver den rigtige
tekst med selvlinket fjernet, letvægt giver flettecommitten og udløser vagten,
og en re-fetch af refen bringer den annoterede tilbage.

Teksten på de to udgivelser, der allerede stod forkert, er rettet i hånden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 08:21:27 +02:00
Kristian Wind 0dc4c2f9b8 Merge pull request #77 from kristianwind/besked-og-en-udgang
CI / OpenAPI is valid (push) Successful in 8s
CI / End-to-end (push) Successful in 8m56s
CI / Docker image builds (push) Successful in 1m39s
Release / Build and publish image (push) Failing after 5s
CI / Go (push) Failing after 14m6s
feat(projekter): en deling siger til, og der er en vej ud af den
2026-09-14 08:17:53 +02:00
kristianwindandClaude Opus 5 7f09b35db5 feat(projekter): en deling siger til, og der er en vej ud af den
Spørgsmålet var, om modtageren ikke burde godkende først. Svaret er nej — men
det, spørgsmålet fik øje på, var rigtigt, og værre end det lød.

Tre ting gjaldt samtidig: man blev tilføjet uden at blive spurgt, man fik ikke
besked, og man kunne ikke gå ud igen. En delt note gav en klokkebesked; et delt
projekt gav ingenting — det stod bare i sidebjælken næste gang, man kiggede,
uden at noget sagde hvornår eller fra hvem. Og handleRemoveMember lå bag
ejer-kravet, så kun ejeren kunne fjerne nogen, også en selv.

Hvert enkelt af de tre er til at leve med. Alle tre sammen gør en deling til
noget, der sker for en.

Ikke et godkendelsestrin: en invitation, der venter på et ja, betyder, at den,
der delte, tror det er gjort, mens arbejdet ligger stille — og noter deles
allerede uden. Det, der manglede, var besked og en udgang.

`project.shared` er ny. Klokkens follow() kunne allerede føre et project_id hen
til projektet, så beskeden bærer sit eget mål og krævede ingen særregel.

Ruten til at fjerne et medlem er flyttet ud af ejer-gruppen, og handleren skiller
nu de to ting ad: ejeren, der fjerner nogen, og den, der selv går. At pege på en
andens medlemskab uden at eje projektet svarer 404 og ikke 403 — "det må du
ikke" bekræfter, at medlemskabet findes. Ejeren kan stadig ikke gå fra sit eget
projekt; det ville være en overdragelse, og den findes ikke endnu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 08:07:46 +02:00
Kristian Wind 0f4048d8f2 Merge pull request #76 from kristianwind/del-projekt-med-en-der-er-her
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 8m53s
CI / Docker image builds (push) Successful in 1m39s
CI / Go (push) Failing after 14m7s
feat(projekter): del et projekt med en, der allerede er her
2026-09-13 18:00:04 +02:00
13 changed files with 275 additions and 5 deletions
+34 -3
View File
@@ -72,13 +72,44 @@ jobs:
#
# Den sidste linje er et link til udgivelsen selv, og den tages af: en
# side, der linker til sig selv, hjælper ingen.
#
# Og `fetch-depth: 0` er ikke nok, hvilket kostede to udgivelser at opdage.
# actions/checkout henter først alle tags korrekt — og overskriver derefter
# netop den ene, der udløste kørslen:
#
# git rev-parse refs/tags/v0.46.0
# git fetch --no-tags origin +0dc4c2f9b8…:refs/tags/v0.46.0
#
# Den henter commit-SHA'en ind i tag-refen, så den annoterede tag bliver til
# en letvægtstag, der peger direkte på commit'en. `%(contents)` giver så
# commitbeskeden, og det var flettecommittens overskrift, der stod som
# udgivelsestekst på v0.45.0 og v0.46.0 — uden at noget fejlede.
#
# Derfor hentes tagget som *ref* igen her, før det læses.
- id: notes
run: |
git fetch --force origin \
"refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
body=$(git tag -l --format='%(contents)' "${GITHUB_REF_NAME}" \
| sed '\#^https://github.com/.*/releases/tag/#d' \
| sed -e :a -e '/^\n*$/{$d;N;ba' -e '}')
# Hver tagbesked begynder med sin egen version. Holder det ikke, læser vi
# noget andet end tagget — og en forkert udgivelsestekst skal fejle her
# frem for at blive opdaget på siden bagefter.
case "$body" in
"${GITHUB_REF_NAME}"*) ;;
*)
echo "::error::Tagbeskeden begynder ikke med ${GITHUB_REF_NAME}." \
"Læste vi en letvægtstag? Første linje: $(printf '%s' "$body" | head -1)"
exit 1
;;
esac
{
echo 'body<<VERDANDE_EOF'
git tag -l --format='%(contents)' "${GITHUB_REF_NAME}" \
| sed '\#^https://github.com/.*/releases/tag/#d' \
| sed -e :a -e '/^\n*$/{$d;N;ba' -e '}'
printf '%s\n' "$body"
echo VERDANDE_EOF
} >> "$GITHUB_OUTPUT"
+1
View File
@@ -11,6 +11,7 @@ the app's icon.
| --- | --- |
| Somebody edited a note shared with you | Repeat edits fold into one line while it is unread |
| Somebody shared a note with you | Once, when it happens |
| Somebody shared a project with you | Once, when it happens — the entry opens the project, and its share panel has a *Leave the project* button |
| Somebody named you in a note with `@` | Instead of "edited a note" — one act, one message |
| Somebody gave you a task | Only on the change, and only to the person who got it |
| Somebody commented | |
+12 -1
View File
@@ -2379,8 +2379,19 @@ paths:
"422": { $ref: "#/components/responses/Validation" }
delete:
tags: [projects]
summary: Remove somebody from a project. Its owner only.
summary: Remove somebody from a project — its owner, or yourself.
description: |
Two things through one route: the owner taking somebody off, and anybody
taking themselves off. The second is what makes a share something you can
undo. You are let into a project without being asked, so a way out that
does not go through asking its owner is the other half of that.
The owner cannot leave their own project. That is a transfer of ownership,
not an exit, and there is no transfer yet.
Pointing at somebody else's membership without owning the project answers
404 rather than 403: saying "you may not" confirms the membership exists.
Their tasks are unassigned rather than left pointing at somebody who can
no longer see the project — which is why changing a role is a PATCH and
not a remove-and-reinvite.
+10
View File
@@ -24,6 +24,16 @@ becomes a link that creates their account and their membership at once. With no
mail server configured the link is shown in the panel instead of being sent, which
is the correct behaviour on a one-person instance rather than an error.
**You are told, and you can leave.** Being added to a project is not something
that happens to you quietly: the bell says who shared what, and the entry takes
you to the project. In the share panel, anybody who is not the owner has a *Leave
the project* button. That is deliberate rather than an approval step — an
invitation waiting on a yes means the person who shared thinks it is done while
the work sits still. What was wrong was the other three-quarters of it: you were
added without being asked, without being told, and with no way out except asking
the owner. The owner cannot leave their own project; that is a transfer of
ownership, and there is no transfer yet.
An invitation that has not been taken up stays listed under the members, greyed,
with a × to withdraw it. Without that it is invisible — the person has no account,
so they are neither a member nor somebody you can pick — and the usual next move
@@ -140,6 +140,30 @@ func (s *Server) notifyNoteShared(r *http.Request, n *store.Note, userID string)
})
}
// notifyProjectShared tells somebody that a project is now theirs to work in.
//
// Indtil nu skete det i tavshed. En note delt med dig gav en besked; et projekt gav
// ingenting — det stod bare i sidebjælken næste gang, du kiggede, uden at noget
// sagde hvornår eller fra hvem. Et projekt er mere end en note: der kan lægges
// opgaver til dig i det.
//
// Én besked, når delingen sker. En rolle, der ændres bagefter, er ikke et nyt
// projekt at åbne, og en invitation, der bliver taget imod, er modtagerens egen
// handling — de skal ikke have besked om det, de lige selv gjorde.
func (s *Server) notifyProjectShared(r *http.Request, project *store.Project, userID string) {
if project == nil || userID == "" {
return
}
actor := userFrom(r.Context())
if actor.ID == userID {
return
}
s.notify(r, &store.Notification{
UserID: userID, ActorID: actor.ID, ProjectID: project.ID,
Kind: "project.shared", Title: actor.Name + " delte et projekt med dig", Body: project.Name,
})
}
// notifyNoteChanged tells everybody who can see a note that somebody else wrote in
// it — folded into one line per note while it is unread.
//
+21
View File
@@ -624,6 +624,9 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
return
}
s.activity(r, projectID, "", "member.added", map[string]any{"name": person.Name, "role": string(role)})
// Og de får det at vide. Før stod projektet bare i sidebjælken næste gang,
// de kiggede — uden at noget sagde hvornår eller fra hvem.
s.notifyProjectShared(r, project, person.ID)
writeJSON(w, http.StatusOK, map[string]any{
"added": true,
// Personen sendes med retur, fordi fladen kan have delt med en adresse
@@ -707,15 +710,33 @@ func (s *Server) handleSetMemberRole(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// handleRemoveMember takes somebody off a project — the owner removing anybody, or
// anybody removing themselves.
//
// Den anden halvdel er ny, og den er grunden til at ruten er flyttet ud af
// ejer-gruppen. Man blev lukket ind i et projekt uden at blive spurgt, og kunne så
// ikke komme ud igen: kun ejeren kunne fjerne en, også en selv. To af de tre ting
// kan man leve med; alle tre er det, der gør en deling til noget, der sker for en.
//
// Ejeren kan stadig ikke gå fra sit eget projekt. Det er ikke en udgang, det er en
// overdragelse, og den findes ikke endnu.
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "projectID")
userID := chi.URLParam(r, "userID")
me := userFrom(r.Context())
owner, err := s.db.ProjectOwner(r.Context(), projectID)
if err != nil {
s.storeError(w, r, "project owner", err)
return
}
// 404 og ikke 403: at sige "det må du ikke" til en, der peger på en anden
// persons medlemskab, bekræfter at medlemskabet findes.
if me.ID != owner && userID != me.ID {
writeError(w, http.StatusNotFound, CodeNotFound, "no such member")
return
}
if userID == owner {
writeError(w, http.StatusConflict, CodeConflict,
"the owner cannot be removed from their own project")
+95
View File
@@ -627,3 +627,98 @@ func hasPerson(raw any, id string) bool {
}
return false
}
// Man bliver lukket ind i et projekt uden at blive spurgt. Så skal man i det
// mindste vide det — og kunne gå ud igen uden at bede ejeren om lov.
func TestBeingAddedToAProjectIsToldAndCanBeUndone(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
anden := ts.newUser(t, "anden@example.dk", "Anden")
andenUser, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
if err != nil {
t.Fatal(err)
}
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Fælleshuset"})
projectID := project["id"].(string)
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
map[string]any{"user_id": andenUser.ID, "role": "editor"})
// Beskeden. Før stod projektet bare i sidebjælken næste gang, de kiggede.
_, inbox := anden.do(t, "GET", "/api/v1/notifications", nil)
var shared map[string]any
for _, raw := range inbox["notifications"].([]any) {
if n := raw.(map[string]any); n["kind"] == "project.shared" {
shared = n
}
}
if shared == nil {
t.Fatalf("ingen besked om delingen: %v", inbox["notifications"])
}
if shared["project_id"] != projectID {
t.Errorf("project_id = %v, want %s — uden det fører beskeden ingen steder hen",
shared["project_id"], projectID)
}
// Udgangen.
resp, _ := anden.do(t, "DELETE", "/api/v1/projects/"+projectID+"/members/"+andenUser.ID, nil)
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("forlad projektet: status %d", resp.StatusCode)
}
if resp, _ := anden.do(t, "GET", "/api/v1/projects/"+projectID, nil); resp.StatusCode != http.StatusNotFound {
t.Errorf("projektet er stadig synligt efter at være forladt: status %d", resp.StatusCode)
}
}
// En udgang for en selv er ikke en dør til de andres medlemskaber.
func TestAMemberCannotRemoveAnotherMember(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
en := ts.newUser(t, "en@example.dk", "En")
ts.newUser(t, "to@example.dk", "To")
enUser, err := ts.db.UserByEmail(t.Context(), "en@example.dk")
if err != nil {
t.Fatal(err)
}
toUser, err := ts.db.UserByEmail(t.Context(), "to@example.dk")
if err != nil {
t.Fatal(err)
}
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
projectID := project["id"].(string)
for _, u := range []string{enUser.ID, toUser.ID} {
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
map[string]any{"user_id": u, "role": "editor"})
}
// 404 og ikke 403: "det må du ikke" bekræfter, at medlemskabet findes.
resp, _ := en.do(t, "DELETE", "/api/v1/projects/"+projectID+"/members/"+toUser.ID, nil)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("status %d, want 404", resp.StatusCode)
}
_, members := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
if len(members["members"].([]any)) != 3 {
t.Errorf("%d medlemmer tilbage, want 3", len(members["members"].([]any)))
}
}
// Ejeren kan ikke gå fra sit eget projekt. Det ville være en overdragelse.
func TestTheOwnerCannotLeaveTheirOwnProject(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
me, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Mit"})
resp, _ := ts.do(t, "DELETE",
"/api/v1/projects/"+project["id"].(string)+"/members/"+me.ID, nil)
if resp.StatusCode != http.StatusConflict {
t.Errorf("status %d, want 409", resp.StatusCode)
}
}
+3 -1
View File
@@ -493,6 +493,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Get("/", s.handleGetProject)
r.Get("/sections", s.handleListSections)
r.Get("/members", s.handleListMembers)
// Uden for ejer-gruppen, fordi den nu dækker to ting: ejeren, der
// fjerner nogen, og den, der selv går. Handleren skiller dem ad.
r.Delete("/members/{userID}", s.handleRemoveMember)
r.Get("/activity", s.handleActivity)
r.Group(func(r chi.Router) {
@@ -508,7 +511,6 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Post("/invites", s.handleInvite)
r.Delete("/invites/{inviteID}", s.handleDeleteProjectInvite)
r.Patch("/members/{userID}", s.handleSetMemberRole)
r.Delete("/members/{userID}", s.handleRemoveMember)
})
})
})
+36
View File
@@ -4597,5 +4597,41 @@ test('et projekt kan deles med en, der allerede har en konto', async ({ browser,
// spring tilbage til det første projekt, og delingspanelet står åbent på tværs
// af det spring — så klikket på "Del" lukkede det, jeg lige havde åbnet.
// Og så den anden vej: hvad Gartner selv oplever. Man bliver lukket ind i et
// projekt uden at blive spurgt, så man skal i det mindste få det at vide og
// kunne gå ud igen uden at bede ejeren om lov.
const theirs = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const gartner = await theirs.newPage();
const gartnerTrouble = watchForTrouble(gartner);
await gartner.goto('/');
await gartner.getByLabel('E-mail').fill('gartner@example.dk');
await gartner.getByLabel('Adgangskode').fill('et langt kodeord til test');
await gartner.getByRole('button', { name: 'Log ind' }).click();
await expect(gartner.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
await gartner.getByRole('button', { name: /Beskeder/ }).click();
const bell = gartner.getByRole('dialog', { name: 'Beskeder' });
await expect(bell.getByText('delte et projekt med dig')).toBeVisible();
// Beskeden fører hen til projektet. En besked, der ikke kan følges, er en
// oplysning om noget, man selv skal gå ud og finde.
await bell.locator('.row').filter({ hasText: 'delte et projekt med dig' }).click();
await expect(gartner.getByRole('heading', { name: 'Drivhuset' })).toBeVisible();
// Udgangen. Bekræftelsen siges ja til, ellers sker der intet.
gartner.on('dialog', (d) => d.accept());
await projectAction(gartner, /^Del/);
await gartner.getByRole('button', { name: 'Forlad projektet' }).click();
// Væk fra sidebjælken, og ikke længere til at åbne.
await expect(
gartner.getByRole('navigation', { name: 'Hovedmenu' }).getByRole('link', { name: /Drivhuset/ })
).toHaveCount(0);
// En 404 her er ikke uro: den er svaret på at bede om et projekt, man lige har
// forladt, og den er hele pointen med at have forladt det.
expect(gartnerTrouble.filter((t) => !t.includes('404'))).toEqual([]);
await theirs.close();
expect(trouble).toEqual([]);
});
@@ -24,6 +24,7 @@
assigned: 'notif.assigned',
'note.changed': 'notif.noteChanged',
'note.shared': 'notif.noteShared',
'project.shared': 'notif.projectShared',
'note.mention': 'notif.mention',
'daily.plan': 'notif.dailyPlan',
'update.available': 'notif.update',
+3
View File
@@ -31,6 +31,7 @@ export const da = {
'notif.assigned': '{name} gav dig en opgave',
'notif.noteChanged': '{name} rettede en note',
'notif.noteShared': '{name} delte en note med dig',
'notif.projectShared': '{name} delte et projekt med dig',
'notif.mention': '{name} nævnte dig i en note',
'notif.dailyPlan': 'Dagens plan',
'notif.update': 'Der er kommet en ny version',
@@ -909,6 +910,8 @@ export const da = {
'project.canView': 'Kan kun se',
'project.invite': 'Inviter',
'project.pickPerson': 'Vælg en person…',
'project.leave': 'Forlad projektet',
'project.leaveSure': 'Forlad {name}? Du mister adgangen, og kun ejeren kan lukke dig ind igen.',
'project.roleForPicked': 'Rolle for den valgte',
'project.shareWith': 'Del',
'project.orByEmail': 'Eller en, der ikke er her endnu:',
+3
View File
@@ -28,6 +28,7 @@ export const en = {
'notif.assigned': '{name} gave you a task',
'notif.noteChanged': '{name} edited a note',
'notif.noteShared': '{name} shared a note with you',
'notif.projectShared': '{name} shared a project with you',
'notif.mention': '{name} mentioned you in a note',
'notif.dailyPlan': "Today's plan",
'notif.update': 'A new version is out',
@@ -903,6 +904,8 @@ export const en = {
'project.canView': 'Can only view',
'project.invite': 'Invite',
'project.pickPerson': 'Pick somebody…',
'project.leave': 'Leave the project',
'project.leaveSure': 'Leave {name}? You lose access, and only its owner can let you back in.',
'project.roleForPicked': 'Role for the person picked',
'project.shareWith': 'Share',
'project.orByEmail': 'Or somebody who is not here yet:',
+32
View File
@@ -551,6 +551,21 @@
if (!inviteError) inviteEmail = '';
}
// Udgangen. Man bliver lukket ind i et projekt uden at blive spurgt, så der skal
// være en vej ud, der ikke går gennem at bede ejeren om det.
async function leave() {
if (!confirm(t('project.leaveSure', { name: project.name }))) return;
try {
await api.removeMember(project.id, app.user.id);
// Væk fra en side, man ikke længere må se: bliver man stående, er det
// næste, siden gør, at hente den og få 404.
await goto('/');
await app.refreshProjects();
} catch (e) {
app.toast(humanMessage(e));
}
}
async function withdraw(invite) {
try {
await api.withdrawInvite(project.id, invite.id);
@@ -826,6 +841,12 @@
</ul>
{/if}
<!-- Kun for dem, der er lukket ind. Ejeren kan ikke gå fra sit eget
projekt — det ville være en overdragelse, ikke en udgang. -->
{#if !isOwner}
<button class="leave" onclick={leave}>{t('project.leave')}</button>
{/if}
{#if isOwner}
<!-- Den, der allerede er her, vælges. Indtil nu kunne et projekt kun
deles ved at stave en e-mailadresse, altså ved at kende adressen på
@@ -1501,6 +1522,17 @@
flex-wrap: wrap;
}
/* I fareskrift og alene: det er den eneste knap i panelet, der handler om en
selv, og den skal ikke kunne forveksles med at fjerne en anden. */
.leave {
align-self: flex-start;
padding: var(--s2) var(--s3);
border: 1px solid var(--danger);
border-radius: var(--radius);
color: var(--danger);
font-size: var(--text-sm);
}
.byemail-hint {
margin: 0;
font-size: var(--text-xs);