Compare commits

...
4 Commits
Author SHA1 Message Date
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
kristianwindandClaude Opus 5 9b21b3eac5 feat(projekter): del et projekt med en, der allerede er her
Et projekt kunne kun deles ved at stave en e-mailadresse. Det er den forkerte
vej at spørge om en kollega, man kender navnet på og sidder i samme rum som —
og beslutningen om, hvorvidt en instans' konti må vises for hinanden, var
allerede taget: `UsersForSharing` har gjort det for noter hele tiden, med
begrundelsen skrevet ved siden af. Der er ingen åben oprettelse, så konti er en
lukket, inviteret mængde, og det er en adressebog frem for en udlevering.
Projekterne bruger nu den samme.

`GET /members` svarer med tre lister frem for én, så panelet åbner færdigt i ét
kald — samme form som notens delingspanel. `POST /invites` tager `user_id` ved
siden af `email`, og de to veje mødes ét sted: en adresse, der viser sig at høre
til en konto, ER den konto, og skal ikke gennem en oprettelsesside, den ikke kan
komme igennem.

De ventende invitationer kom med, fordi den nye liste skabte behovet for dem:
den, der er inviteret og ikke kommet, har ingen konto og står derfor hverken
blandt medlemmerne eller blandt dem, man kan vælge. Før betød det, at man ikke
kunne se sin egen invitation; nu ville det betyde, at man leder efter en person,
der mangler i listen, og inviterer igen. Det andet link virker lige så godt som
det første. De kan trækkes tilbage, for ellers er den ventende liste kun til at
se på.

Tre ting, der kostede en kørsel hver:

`OpenAPI is valid` fangede den nye DELETE-rute, før jeg selv gjorde.

To felter hed "Rolle" på den samme side. Det er ikke kun en prøve, der brokker
sig — en skærmlæser siger "Rolle" to gange uden at kunne skelne. De har nu hver
sin tilstand og hvert sit navn, og den gamle prøves `getByLabel('Rolle')` er
blevet `exact`, fordi den matchede som understreng.

Og e2e-prøven lånte først sin anden person fra prøven ovenfor. Det så jeg kun,
fordi jeg kørte den alene: basen tømmes for hver kørsel, så en prøve, der kun
består, når hele filen kører i rækkefølge, består ikke af sine egne grunde. Den
laver nu selv sin — og viser dermed skiftet: den samme person er først en
ventende adresse og bagefter et navn, der kan vælges.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 17:52:03 +02:00
16 changed files with 912 additions and 34 deletions
+4 -3
View File
@@ -96,9 +96,10 @@ curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
| `GET` `PATCH` `DELETE` | `/projects/{id}` | Read, change, trash. Owner only for the last two. |
| `GET` `POST` | `/projects/{id}/sections` | |
| `PATCH` `DELETE` | `/sections/{id}` | |
| `GET` | `/projects/{id}/members` | |
| `POST` | `/projects/{id}/invites` | Share it. `{"email": …, "role": "editor"\|"viewer"}` |
| `DELETE` | `/projects/{id}/members/{userID}` | |
| `GET` | `/projects/{id}/members` | Who is on it, who can be added (`candidates`), and who has been invited and not arrived (`pending`). |
| `POST` | `/projects/{id}/invites` | Share it. `{"user_id": …}` for somebody who already has an account here, `{"email": …}` for anybody else — plus `"role": "editor"\|"viewer"`. An address that turns out to belong to an account is that account. |
| `DELETE` | `/projects/{id}/invites/{inviteID}` | Withdraw an invitation before it is used. |
| `PATCH` `DELETE` | `/projects/{id}/members/{userID}` | Change somebody's role, or take them off. |
| `GET` | `/projects/{id}/activity` | |
| `GET` | `/trash/projects` | What you have deleted and can still bring back, with how long is left. |
| `POST` | `/trash/projects/{id}/restore` | Brings it back with the tasks that went with it. |
+5 -2
View File
@@ -40,8 +40,11 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
it is due.
- **Drag where it means something** — reorder a list or a board, file a project
under a group, drop a task on another project, or drop it on another day.
- **Sharing** with owner, editor and viewer roles. Anyone outside your instance
joins through an invite link.
- **Sharing** with owner, editor and viewer roles. Somebody who already has an
account here you pick by name — the accounts on an instance are a closed,
invited set, so they are an address book rather than a directory to protect.
Anyone outside your instance joins through an invite link, and the invitation
stays visible until they arrive, so it does not get sent twice.
- **Live sync** — a change made by somebody else appears without a refresh.
- **[Told when it matters](notifications.md).** A bell for what other people did to
what you share, pushed to your devices and counted on the app's icon in the Dock.
+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 | |
+76 -5
View File
@@ -2307,7 +2307,15 @@ paths:
- { name: projectID, in: path, required: true, schema: { type: string } }
get:
tags: [projects]
summary: Who the project is shared with.
summary: Who the project is shared with, who can be added, and who has been invited.
description: >
Three lists in one answer, so the share panel opens complete in a single
round-trip. `candidates` is everybody with an account here who is not
already on the project — the instance's accounts are a closed, invited set,
so listing them to each other is the address book the feature needs, not a
disclosure. `pending` is the invitations sent to people who have no account
yet: without them an invitation sent yesterday is in neither of the other
two lists, and gets sent a second time.
responses:
"200":
description: OK
@@ -2319,6 +2327,17 @@ paths:
members:
type: array
items: { $ref: "#/components/schemas/Member" }
candidates:
type: array
items: { $ref: "#/components/schemas/Person" }
pending:
type: array
items:
type: object
properties:
id: { type: string }
email: { type: string, format: email }
role: { type: string, enum: [editor, viewer] }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/projects/{projectID}/members/{userID}:
@@ -2360,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.
@@ -2384,13 +2414,32 @@ paths:
application/json:
schema:
type: object
required: [email, role]
properties:
email: { type: string, format: email }
user_id:
type: string
description: >
Somebody who already has an account here, picked from the
candidates in GET /members. One of user_id or email.
email:
type: string
format: email
description: >
An address. If it belongs to an account they are added at
once; otherwise an invitation is sent.
role: { type: string, enum: [editor, viewer] }
responses:
"200":
description: Added straight away — the person already had an account.
content:
application/json:
schema:
type: object
properties:
added: { type: boolean }
user: { $ref: "#/components/schemas/Person" }
role: { type: string, enum: [editor, viewer] }
"201":
description: Created
description: Invited. No account yet, so a link was made.
content:
application/json:
schema:
@@ -2399,8 +2448,30 @@ paths:
link: { type: string }
emailed: { type: boolean }
"404": { $ref: "#/components/responses/NotFound" }
"409": { $ref: "#/components/responses/Conflict" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/projects/{projectID}/invites/{inviteID}:
delete:
tags: [projects]
summary: Withdraw an invitation to a project before it is used. Its owner only.
description: >
The only way back. Only the token's hash is stored, so a link sent to the
wrong address cannot be looked up and invalidated — only the row it belongs
to can.
parameters:
- name: projectID
in: path
required: true
schema: { type: string }
- name: inviteID
in: path
required: true
schema: { type: string }
responses:
"204": { description: Withdrawn }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/projects/{projectID}/activity:
parameters:
- { name: projectID, in: path, required: true, schema: { type: string } }
+32
View File
@@ -8,6 +8,38 @@ What the first account is created with. Nothing is emailed, nothing is registere
anywhere: the first person to open a fresh instance creates the administrator
account, and after that people arrive by invite link.
## Being let into a project
Two different situations, one panel — **the project's ⋯ menu → Share**.
Somebody who **already has an account here** you pick from a list by name. You do
not need to know their email address to share a project with a colleague whose
name you know. The accounts on an instance are a closed set — there is no open
signup, every one of them was invited — so listing them to each other is the
address book the feature needs, not a disclosure.
Somebody who is **not here yet** you invite by email. If the address turns out to
belong to an account it becomes an ordinary share straight away; otherwise it
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
is to send it a second time. The second link works exactly as well as the first,
and then there are two to keep track of.
## A code on top of it
**Settings → Account → Two-factor.** An ordinary TOTP secret — any authenticator
@@ -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.
//
+152 -14
View File
@@ -452,22 +452,100 @@ type memberJSON struct {
Role string `json:"role"`
}
// projectInviteJSON er en invitation, der venter på en konto. Der er intet navn og
// ingen farve at vise: adressen er alt, hvad der findes om personen, indtil de
// dukker op.
type projectInviteJSON struct {
ID string `json:"id"`
Email string `json:"email"`
Role string `json:"role"`
}
// handleListMembers is everything the share panel needs, in one answer: who is on
// the project, who can be added, and who has been invited and not arrived.
//
// Kandidaterne kommer med her frem for fra et endepunkt for sig, så panelet åbner
// færdigt i ét kald — samme form som notens delingspanel. Og de er nødvendige:
// indtil nu kunne et projekt kun deles ved at skrive en e-mailadresse, hvilket
// betyder, at man skal kende adressen på en kollega, man kender navnet på.
//
// De inviterede står ved siden af medlemmerne, fordi de ellers hverken er i den
// ene liste eller den anden — de har ingen konto at være medlem med, og de er
// derfor også fraværende fra kandidaterne. Uden dem ser en invitation sendt i går
// ud som om den aldrig blev sendt, og så bliver den sendt igen.
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
members, err := s.db.ListMembers(r.Context(), chi.URLParam(r, "projectID"))
projectID := chi.URLParam(r, "projectID")
members, err := s.db.ListMembers(r.Context(), projectID)
if err != nil {
s.internal(w, r, "list members", err)
return
}
out := make([]memberJSON, 0, len(members))
already := map[string]bool{}
for _, m := range members {
out = append(out, memberJSON{m.UserID, m.Email, m.Name, m.AvatarColor, string(m.Role)})
already[m.UserID] = true
}
writeJSON(w, http.StatusOK, map[string]any{"members": out})
project, err := s.db.GetProject(r.Context(), projectID, userFrom(r.Context()).ID)
if err != nil {
s.storeError(w, r, "get project", err)
return
}
already[project.OwnerID] = true
people, err := s.db.UsersForSharing(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, r, "share candidates", err)
return
}
candidates := make([]personJSON, 0, len(people))
for _, p := range people {
if already[p.ID] {
continue
}
candidates = append(candidates, personJSON{ID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor})
}
invites, err := s.db.ListProjectInvites(r.Context(), projectID)
if err != nil {
s.internal(w, r, "list project invites", err)
return
}
pending := make([]projectInviteJSON, 0, len(invites))
for _, i := range invites {
pending = append(pending, projectInviteJSON{ID: i.ID, Email: i.Email, Role: string(i.Role)})
}
writeJSON(w, http.StatusOK, map[string]any{
"members": out,
"candidates": candidates,
"pending": pending,
})
}
// handleDeleteProjectInvite withdraws an invitation that has not been taken up.
//
// Uden den er den ventende liste kun til at se på, og en invitation sendt til en
// forkert adresse kan ikke tages tilbage — den bliver bare liggende, til den
// udløber, med et link, der virker hele tiden.
func (s *Server) handleDeleteProjectInvite(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "projectID")
err := s.db.DeleteProjectInvite(r.Context(), projectID, chi.URLParam(r, "inviteID"))
if err != nil {
s.storeError(w, r, "delete project invite", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
type inviteRequest struct {
Email string `json:"email"`
Role string `json:"role"`
// UserID er den, man har peget på i listen. Email er den, man kender adressen
// på — og den er også den, der virker, når der ingen konto er endnu.
UserID string `json:"user_id"`
Email string `json:"email"`
Role string `json:"role"`
}
type inviteResponse struct {
@@ -478,8 +556,14 @@ type inviteResponse struct {
Emailed bool `json:"emailed"`
}
// handleInvite shares a project with somebody. An existing user is added directly;
// anybody else gets a link that creates their account and their membership at once.
// handleInvite shares a project with somebody.
//
// Der er to måder at sige, hvem det er — samme to som en note deles med. `user_id`
// er den, man har peget på i listen, og den er grunden til at det her blev skrevet
// om: indtil nu kunne et projekt kun deles ved at stave en e-mailadresse, altså
// ved at kende adressen på en kollega, man kender navnet på. `email` er stadig
// vejen til dem, der ikke er her endnu, og den bliver til et link, der opretter
// kontoen og medlemskabet på én gang.
func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
var req inviteRequest
if err := decodeJSON(w, r, &req); err != nil {
@@ -496,8 +580,13 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
writeFieldErrors(w, map[string]string{"role": "must be editor or viewer"})
return
}
email := store.NormalizeEmail(req.Email)
if email == "" || !strings.Contains(email, "@") {
if req.UserID == "" && email == "" {
writeFieldErrors(w, map[string]string{"user_id": "required"})
return
}
if req.UserID == "" && !strings.Contains(email, "@") {
writeFieldErrors(w, map[string]string{"email": "must be an email address"})
return
}
@@ -508,19 +597,50 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
return
}
// Somebody who already has an account is added straight away: sending them
// through a signup form they cannot complete would be a dead end.
if existing, err := s.db.UserByEmail(r.Context(), email); err == nil {
if existing.ID == project.OwnerID {
// En adresse, der viser sig at høre til en konto, ER den konto: at sende dem
// gennem en oprettelsesside, de ikke kan komme igennem, ville være en
// blindgyde. Derfor slås adressen op først, og de to veje mødes her.
if req.UserID == "" {
if existing, err := s.db.UserByEmail(r.Context(), email); err == nil {
req.UserID = existing.ID
}
}
if req.UserID != "" {
if req.UserID == project.OwnerID {
writeError(w, http.StatusConflict, CodeConflict, "that person already owns this project")
return
}
if err := s.db.AddMember(r.Context(), projectID, existing.ID, role); err != nil {
// Slået op frem for taget på ordet: et id, der ikke findes, ville ellers
// blive til en medlemsrække, der peger på ingen — og panelet ville vise en
// tom plads, ingen kan fjerne.
person, err := s.db.PersonByID(r.Context(), req.UserID)
if err != nil {
writeError(w, http.StatusNotFound, CodeNotFound, "no such person")
return
}
if err := s.db.AddMember(r.Context(), projectID, person.ID, role); err != nil {
s.internal(w, r, "add member", err)
return
}
s.activity(r, projectID, "", "member.added", map[string]any{"email": email, "role": string(role)})
writeJSON(w, http.StatusOK, map[string]any{"added": true})
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
// og derfor ikke selv ved, hvem den ramte.
"user": personJSON{ID: person.ID, Name: person.Name, AvatarColor: person.AvatarColor},
"role": string(role),
})
return
}
// Et andet link til den samme indbakke hjælper ingen: det første virker
// stadig, og nu er der to at holde styr på og to at trække tilbage.
if pending, err := s.db.PendingProjectInvite(r.Context(), projectID, email); err == nil && pending {
writeError(w, http.StatusConflict, CodeConflict, "that address has already been invited")
return
}
@@ -590,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")
+236
View File
@@ -486,3 +486,239 @@ func TestASectionHoldsMoreThanOneTask(t *testing.T) {
}
}
}
// Et projekt kunne kun deles ved at stave en e-mailadresse. Det er den forkerte
// vej at spørge om en kollega, man kender navnet på og sidder i samme rum som — og
// på en instans, hvor konti kun bliver til ved invitation, er der ikke noget at
// beskytte ved at lade være med at vise dem.
func TestAProjectCanBeSharedWithSomebodyWhoIsAlreadyHere(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.newUser(t, "anden@example.dk", "Anden")
anden, 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": "Firma"})
projectID := project["id"].(string)
// Først står de i listen over dem, der kan tilføjes.
_, before := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
if !hasPerson(before["candidates"], anden.ID) {
t.Fatalf("Anden er ikke blandt kandidaterne: %v", before["candidates"])
}
// Delt ved at pege, ikke ved at stave.
resp, added := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
map[string]any{"user_id": anden.ID, "role": "editor"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("status %d, body %v", resp.StatusCode, added)
}
// Personen kommer med retur, så fladen kan tegne rækken uden at spørge igen.
if user, _ := added["user"].(map[string]any); user["name"] != "Anden" {
t.Errorf("user = %v, want Anden", added["user"])
}
// Og bagefter er de et medlem og ikke længere en kandidat: en liste, der
// tilbyder at tilføje en, der allerede er der, er en liste, man gør det i.
_, after := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
if hasPerson(after["candidates"], anden.ID) {
t.Errorf("Anden står stadig som kandidat efter at være tilføjet")
}
found := false
for _, raw := range after["members"].([]any) {
if m := raw.(map[string]any); m["user_id"] == anden.ID {
found = true
if m["role"] != "editor" {
t.Errorf("role = %v, want editor", m["role"])
}
}
}
if !found {
t.Errorf("Anden blev ikke medlem: %v", after["members"])
}
}
// Ejeren skal ikke kunne tilbydes som en, projektet kan deles med — de har det.
func TestTheOwnerIsNotOfferedAsSomebodyToShareWith(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"})
_, body := ts.do(t, "GET", "/api/v1/projects/"+project["id"].(string)+"/members", nil)
if hasPerson(body["candidates"], me.ID) {
t.Errorf("ejeren står som kandidat til sit eget projekt")
}
}
// En invitation, der venter, står ved siden af medlemmerne — og kan tages tilbage.
//
// Uden den er den usynlig: personen har ingen konto, så de er hverken medlem eller
// kandidat, og så bliver invitationen sendt igen. Det andet link virker lige så
// godt som det første, og så er der to at trække tilbage i stedet for et.
func TestAPendingInviteIsVisibleAndCanBeWithdrawn(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
projectID := project["id"].(string)
resp, _ := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
map[string]any{"email": "fremmed@example.dk", "role": "viewer"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("invitation: status %d", resp.StatusCode)
}
_, body := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
pending, _ := body["pending"].([]any)
if len(pending) != 1 {
t.Fatalf("%d ventende invitationer, want 1: %v", len(pending), body["pending"])
}
first := pending[0].(map[string]any)
if first["email"] != "fremmed@example.dk" {
t.Errorf("email = %v", first["email"])
}
// Et andet link til den samme indbakke hjælper ingen.
if resp, _ := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
map[string]any{"email": "fremmed@example.dk", "role": "viewer"}); resp.StatusCode != http.StatusConflict {
t.Errorf("en gentaget invitation gav %d, want 409", resp.StatusCode)
}
resp, _ = ts.do(t, "DELETE",
"/api/v1/projects/"+projectID+"/invites/"+first["id"].(string), nil)
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("tilbagetrækning: status %d", resp.StatusCode)
}
_, after := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
if p, _ := after["pending"].([]any); len(p) != 0 {
t.Errorf("invitationen står der stadig: %v", after["pending"])
}
}
// Et id, der ikke findes, må ikke blive til en medlemsrække, der peger på ingen.
func TestSharingWithSomebodyWhoDoesNotExistIsRefused(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
resp, _ := ts.do(t, "POST", "/api/v1/projects/"+project["id"].(string)+"/invites",
map[string]any{"user_id": "0198f000-0000-7000-8000-00000000dead", "role": "editor"})
if resp.StatusCode != http.StatusNotFound {
t.Errorf("status %d, want 404", resp.StatusCode)
}
}
// hasPerson siger, om en liste af personer indeholder et bestemt id.
func hasPerson(raw any, id string) bool {
list, _ := raw.([]any)
for _, item := range list {
if p, ok := item.(map[string]any); ok && p["id"] == id {
return true
}
}
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)
}
}
+4 -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) {
@@ -506,8 +509,8 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Patch("/", s.handleUpdateProject)
r.Delete("/", s.handleDeleteProject)
r.Post("/invites", s.handleInvite)
r.Delete("/invites/{inviteID}", s.handleDeleteProjectInvite)
r.Patch("/members/{userID}", s.handleSetMemberRole)
r.Delete("/members/{userID}", s.handleRemoveMember)
})
})
})
+83
View File
@@ -354,6 +354,89 @@ func (db *DB) DeleteNoteInvite(ctx context.Context, noteID, inviteID string) err
return nil
}
// --- invitationer til ét projekt --------------------------------------------------
// ProjectInvite is somebody invited to a project who has not arrived yet.
//
// The same shape as NoteInvite and deliberately not the same type: the two rows
// differ in which column carries the target, and one struct covering both would
// need a field that is empty half the time — which is how a caller ends up
// reading the wrong one.
type ProjectInvite struct {
ID string `json:"id"`
Email string `json:"email"`
Role Role `json:"role"`
CreatedAt time.Time `json:"-"`
ExpiresAt time.Time `json:"-"`
}
// ListProjectInvites is who has been invited to a project and not yet taken it up.
//
// The panel needs them beside the members for the same reason the note panel does:
// an invitation sent yesterday is otherwise invisible, so it gets sent again — a
// second link to the same inbox, and no way to take either back. That mattered
// less while inviting meant typing an address; it matters more now that the panel
// offers a list to pick from, because the people who are *missing* from that list
// are exactly the ones already invited.
//
// Expired ones are left out, the same rule the administrator's list follows.
func (db *DB) ListProjectInvites(ctx context.Context, projectID string) ([]ProjectInvite, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, email, role, created_at, expires_at
FROM invites
WHERE project_id = ? AND note_id IS NULL AND accepted_at IS NULL AND expires_at > ?
ORDER BY created_at`, projectID, time.Now().Unix())
if err != nil {
return nil, err
}
defer rows.Close()
out := []ProjectInvite{}
for rows.Next() {
var i ProjectInvite
var created, expires int64
if err := rows.Scan(&i.ID, &i.Email, &i.Role, &created, &expires); err != nil {
return nil, err
}
i.CreatedAt = time.Unix(created, 0).UTC()
i.ExpiresAt = time.Unix(expires, 0).UTC()
out = append(out, i)
}
return out, rows.Err()
}
// DeleteProjectInvite withdraws an invitation to a project, and refuses to
// withdraw anything else.
//
// The project id is in the WHERE rather than checked beforehand, for the same
// reason DeleteNoteInvite puts the note id there: the caller's ownership has been
// established for *this* project, and a statement that can only touch this
// project's rows cannot be talked into deleting somebody else's invite by id.
func (db *DB) DeleteProjectInvite(ctx context.Context, projectID, inviteID string) error {
res, err := db.ExecContext(ctx,
`DELETE FROM invites WHERE id = ? AND project_id = ? AND note_id IS NULL`,
inviteID, projectID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// PendingProjectInvite reports whether this address already has a live invitation
// to this project, so a second attempt can say so instead of sending another link.
func (db *DB) PendingProjectInvite(ctx context.Context, projectID, email string) (bool, error) {
var n int
err := db.QueryRowContext(ctx, `
SELECT count(*) FROM invites
WHERE project_id = ? AND note_id IS NULL AND lower(email) = lower(?)
AND accepted_at IS NULL AND expires_at > ?`,
projectID, email, time.Now().Unix()).Scan(&n)
return n > 0, err
}
// PendingNoteInvite reports whether this address already has a live invitation to
// this note, so a second attempt can say so instead of sending a second link.
func (db *DB) PendingNoteInvite(ctx context.Context, noteID, email string) (bool, error) {
+121 -1
View File
@@ -1104,7 +1104,9 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
await projectAction(page, 'Del');
await page.getByLabel('Inviter via e-mail').fill('andreas@example.dk');
await page.getByLabel('Rolle').selectOption('viewer');
// `exact`, fordi siden nu også har "Rolle for den valgte" ved siden af
// vælgeren — og getByLabel matcher som understreng.
await page.getByLabel('Rolle', { exact: true }).selectOption('viewer');
await page.getByRole('button', { name: 'Inviter' }).click();
const link = await page.locator('.link-out code').textContent();
@@ -4515,3 +4517,121 @@ test('en indstilling kan findes ved at søge efter den', async ({ page }) => {
expect(trouble).toEqual([]);
});
/**
* Et projekt kan deles med en, der allerede er her.
*
* Indtil nu kunne det kun deles ved at stave en e-mailadresse — altså ved at kende
* adressen på en kollega, man kender navnet på og sidder i samme rum som. Konti på
* en instans bliver kun til ved invitation, så der er ikke noget at beskytte ved at
* lade være med at vise dem for hinanden; det er adressebogen, funktionen har brug
* for.
*
* Prøven laver selv sin anden person frem for at låne den fra prøven ovenfor. Den
* kobling så jeg først, da jeg kørte den her alene: basen tømmes for hver kørsel,
* så en prøve, der kun består, når hele filen kører i rækkefølge, består ikke af
* sine egne grunde.
*
* Og turen forbi invitationen er ikke spild. Den viser skiftet: den samme person er
* først en ventende adresse, og bagefter et navn, der kan vælges.
*/
test('et projekt kan deles med en, der allerede har en konto', async ({ browser, page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Vinterhaven');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(page.getByRole('heading', { name: 'Vinterhaven' })).toBeVisible();
// Først den, der ikke er her endnu. Adressen er den eneste vej til dem.
await projectAction(page, 'Del');
await page.getByLabel('Inviter via e-mail').fill('gartner@example.dk');
await page.getByRole('button', { name: 'Inviter' }).click();
// Og de bliver stående synligt, indtil de kommer — ellers er en invitation
// sendt i går usynlig, og så bliver den sendt igen.
const invited = page.locator('.members.invited');
await expect(invited.getByText('gartner@example.dk')).toBeVisible();
await expect(invited).toContainText('Inviteret, ikke kommet endnu');
const link = await page.locator('.link-out code').textContent();
expect(link).toContain('/invite?token=');
// Tom kontekst: en inviteret, der allerede er logget ind som den, der inviterede,
// er det ene tilfælde, der aldrig sker.
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const guest = await context.newPage();
await guest.goto(link);
await guest.getByLabel('Navn', { exact: true }).fill('Gartner');
await guest.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
await guest.getByRole('button', { name: 'Opret konto' }).click();
await expect(guest.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
await context.close();
// Nu er de her. Et nyt projekt, og den samme person er blevet et navn, man vælger.
await page.goto('/');
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Drivhuset');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(page.getByRole('heading', { name: 'Drivhuset' })).toBeVisible();
await projectAction(page, 'Del');
const picker = page.locator('.addshare select').first();
await expect(picker.locator('option', { hasText: 'Gartner' })).toHaveCount(1);
await picker.selectOption({ label: 'Gartner' });
await page.getByLabel('Rolle for den valgte').selectOption('viewer');
await page.locator('.addshare button', { hasText: 'Del' }).click();
const members = page.locator('.members').first();
await expect(members.locator('li', { hasText: 'Gartner' })).toContainText('Kan kun se');
// Og bagefter er de væk fra listen: en liste, der tilbyder at tilføje en, der
// allerede er der, er en liste, man gør det i ved et uheld.
await expect(picker.locator('option', { hasText: 'Gartner' })).toHaveCount(0);
// Tilbagetrækningen af en invitation prøves i Go
// (TestAPendingInviteIsVisibleAndCanBeWithdrawn) og ikke her. Den krævede et
// 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([]);
});
+5 -1
View File
@@ -338,7 +338,11 @@ export const api = {
reorderSections: (projectId, ids) => post(`/projects/${projectId}/sections/reorder`, { ids }),
listMembers: (projectId) => get(`/projects/${projectId}/members`),
invite: (projectId, email, role) => post(`/projects/${projectId}/invites`, { email, role }),
// Én vej ind, to måder at sige hvem: et id, man har peget på, eller en adresse,
// man har skrevet. Serveren slår adressen op og ender det samme sted, hvis den
// hører til en konto her.
invite: (projectId, who, role) => post(`/projects/${projectId}/invites`, { ...who, role }),
withdrawInvite: (projectId, inviteId) => del(`/projects/${projectId}/invites/${inviteId}`),
setMemberRole: (projectId, userId, role) =>
patch(`/projects/${projectId}/members/${userId}`, { role }),
removeMember: (projectId, userId) => del(`/projects/${projectId}/members/${userId}`),
@@ -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',
+9
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',
@@ -908,6 +909,14 @@ export const da = {
'project.canEdit': 'Kan redigere',
'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:',
'project.invited': 'Inviteret, ikke kommet endnu',
'project.withdrawInvite': 'Træk invitationen til {email} tilbage',
'project.inviteByEmail': 'Inviter via e-mail',
'project.emailAddress': 'e-mailadresse',
'project.role': 'Rolle',
+9
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',
@@ -902,6 +903,14 @@ export const en = {
'project.canEdit': 'Can edit',
'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:',
'project.invited': 'Invited, not arrived yet',
'project.withdrawInvite': 'Withdraw the invitation to {email}',
'project.inviteByEmail': 'Invite by email',
'project.emailAddress': 'email address',
'project.role': 'Role',
+150 -7
View File
@@ -41,6 +41,13 @@
let inviteRole = $state('editor');
let inviteLink = $state('');
let inviteError = $state('');
// De to andre halvdele af delingspanelet: hvem der kan tilføjes, og hvem der er
// inviteret og ikke kommet endnu. Begge kommer fra det samme svar som
// medlemmerne, så panelet åbner færdigt i ét kald.
let candidates = $state([]);
let pending = $state([]);
let sharePick = $state('');
let pickRole = $state('editor');
let id = $derived($page.params.id);
@@ -101,7 +108,7 @@
$effect(() => {
if (showShare && project) {
api.listMembers(project.id).then((r) => (members = r.members));
api.listMembers(project.id).then(readMembers);
}
});
@@ -512,23 +519,61 @@
}
}
async function invite(event) {
event.preventDefault();
// Ét sted at læse svaret, fordi der nu er tre lister i det og fire steder, der
// henter det. Tre tildelinger skrevet fire gange er tre, der kan blive glemt.
function readMembers(r) {
members = r.members;
candidates = r.candidates ?? [];
pending = r.pending ?? [];
}
async function share(who, role) {
inviteError = '';
inviteLink = '';
try {
const result = await api.invite(project.id, inviteEmail, inviteRole);
inviteEmail = '';
const result = await api.invite(project.id, who, role);
if (result.link && !result.emailed) {
// No mail server, or delivery failed. The link is the only way this
// invite reaches anybody, so it is shown rather than swallowed.
inviteLink = result.link;
}
members = (await api.listMembers(project.id)).members;
readMembers(await api.listMembers(project.id));
} catch (e) {
inviteError = humanMessage(e);
}
}
async function invite(event) {
event.preventDefault();
const email = inviteEmail.trim();
if (!email) return;
await share({ email }, inviteRole);
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);
readMembers(await api.listMembers(project.id));
} catch (e) {
app.toast(humanMessage(e));
}
}
</script>
<!-- Wide for a board and a month grid, narrow for a list. Decided here rather than
@@ -765,7 +810,7 @@
class="remove"
onclick={async () => {
await api.removeMember(project.id, member.user_id);
members = (await api.listMembers(project.id)).members;
readMembers(await api.listMembers(project.id));
}}
aria-label={t('project.removeMember', { name: member.name })}>×</button
>
@@ -774,7 +819,66 @@
{/each}
</ul>
<!-- De inviterede står ved siden af medlemmerne. Uden dem er en invitation
sendt i går usynlig: personen har ingen konto, så de er hverken
medlem eller nogen, man kan vælge — og så bliver den sendt igen. -->
{#if pending.length}
<ul class="members invited">
{#each pending as invite (invite.id)}
<li>
<span class="avatar waiting">@</span>
<span class="member-name">{invite.email}</span>
<span class="role">{t('project.invited')}</span>
{#if isOwner}
<button
class="remove"
onclick={() => withdraw(invite)}
aria-label={t('project.withdrawInvite', { email: invite.email })}>×</button
>
{/if}
</li>
{/each}
</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å
en kollega, man kender navnet på — og konti her bliver kun til ved
invitation, så listen er en adressebog og ikke en udlevering. -->
{#if candidates.length}
<div class="addshare">
<select bind:value={sharePick} aria-label={t('project.pickPerson')}>
<option value="">{t('project.pickPerson')}</option>
{#each candidates as person (person.id)}
<option value={person.id}>{person.name}</option>
{/each}
</select>
<select bind:value={pickRole} aria-label={t('project.roleForPicked')}>
<option value="editor">{t('project.canEdit')}</option>
<option value="viewer">{t('project.canView')}</option>
</select>
<button
disabled={!sharePick}
onclick={async () => {
const id = sharePick;
sharePick = '';
await share({ user_id: id }, pickRole);
}}>{t('project.shareWith')}</button
>
</div>
{/if}
<!-- Og den, der ikke er her endnu. Samme felt til begge udfald: har
adressen en konto, bliver det en deling med det samme, og ellers en
invitation. Hvem der har en konto, skal man ikke vide for at dele. -->
<p class="byemail-hint">{t('project.orByEmail')}</p>
<form onsubmit={invite}>
<input
bind:value={inviteEmail}
@@ -1396,6 +1500,45 @@
font-size: var(--text-sm);
}
/* De inviterede står under medlemmerne med en tynd streg imellem: de er på vej
ind, ikke inde, og en liste uden skel læses som om de allerede var det. */
.invited {
border-top: 1px solid var(--line);
padding-top: var(--s2);
}
/* Ingen farve at give dem: en invitation har en adresse og ellers ingenting,
og en tilfældig farve ville love et menneske, der ikke findes endnu. */
.avatar.waiting {
background: var(--surface-sunken);
border: 1px dashed var(--line);
color: var(--ink-muted);
}
.addshare {
display: flex;
gap: var(--s2);
align-items: center;
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);
color: var(--ink-muted);
}
.avatar {
width: 22px;
height: 22px;