diff --git a/docs/openapi.yaml b/docs/openapi.yaml index d09d277..400ac6c 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -3126,6 +3126,13 @@ paths: candidates: type: array items: { $ref: "#/components/schemas/Person" } + invites: + description: > + Addresses invited to the note that have not been taken up yet. + There is no account behind them, so there is no name or colour + to show — the address is all there is until they arrive. + type: array + items: { $ref: "#/components/schemas/NoteInvite" } follows: description: > The notes this one points at with [[links]], which are shared @@ -3137,6 +3144,11 @@ paths: tags: [notes] summary: Share a note with a person, or change the role they hold. description: > + The person is named either by `user_id` — somebody picked from the + candidates — or by `email`. An address that already has an account is + shared with straight away; one that does not gets an invitation, which + becomes the share the moment the account is created (201). + The notes this one points at with [[links]] are shared with the same person and the same role, so a link in a shared note leads somewhere. Only notes the sharer owns follow, and the response lists them. @@ -3146,9 +3158,10 @@ paths: application/json: schema: type: object - required: [user_id] + description: One of user_id or email. properties: user_id: { type: string } + email: { type: string, format: email } role: { type: string, enum: [viewer, editor] } responses: "200": @@ -3158,12 +3171,58 @@ paths: schema: type: object properties: + user: + description: > + Who it was shared with. Sent back because a caller that shared + by address does not otherwise know who it reached. + $ref: "#/components/schemas/Person" + role: { type: string, enum: [viewer, editor] } follows: type: array items: { $ref: "#/components/schemas/NoteFollow" } + "201": + description: > + Invited. The address has no account yet, so a link that creates one and + the share together has been issued. + content: + application/json: + schema: + type: object + properties: + invited: { $ref: "#/components/schemas/NoteInvite" } + link: + description: The signup link, shown so it can be passed on. + type: string + emailed: + description: > + False when the instance has no mail configured, which is when + the link is the only way the invitation reaches anybody. + type: boolean "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } "422": { $ref: "#/components/responses/Validation" } + /api/v1/notes/{noteID}/invites/{inviteID}: + delete: + tags: [notes] + summary: Withdraw an invitation to a note before it is used. + 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: noteID + 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/notes/{noteID}/shares/{userID}: delete: tags: [notes] @@ -4072,6 +4131,16 @@ components: note_id: { type: string } title: { type: string } + NoteInvite: + type: object + description: > + A share on a note that is waiting for an account. The address is the whole + of who the person is until they sign up. + properties: + id: { type: string } + email: { type: string, format: email } + role: { type: string, enum: [viewer, editor] } + Mailbox: type: object description: One connected mailbox. The password and any token are never returned. diff --git a/internal/httpapi/notes_invite_test.go b/internal/httpapi/notes_invite_test.go new file mode 100644 index 0000000..1c0ce59 --- /dev/null +++ b/internal/httpapi/notes_invite_test.go @@ -0,0 +1,186 @@ +package httpapi + +import ( + "net/http" + "strings" + "testing" +) + +// Hele vejen: ejeren deler en note med en adresse, der ikke har nogen konto, og +// den, der tager imod invitationen, har noten fra det øjeblik, kontoen findes. +func TestANoteCanBeSharedWithSomebodyWhoHasNoAccount(t *testing.T) { + owner := newTestServer(t) + owner.bootstrap(t) + noteID := owner.createNote(t, "# Aftale om levering\nfredag kl. 9") + + resp, body := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares", + map[string]string{"email": "sofie@example.dk", "role": "editor"}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("invite by email: status %d, body %v", resp.StatusCode, body) + } + invited, _ := body["invited"].(map[string]any) + if invited == nil || invited["email"] != "sofie@example.dk" { + t.Fatalf("the response does not say who was invited: %v", body) + } + if invited["role"] != "editor" { + t.Errorf("role = %v, want the one the owner chose", invited["role"]) + } + // Uden en postserver er linket det eneste, der findes, og det skal stå i + // svaret frem for at blive lovet i en mail, der ikke kom af sted. + if body["emailed"] != false { + t.Errorf("emailed = %v, want false with no mail server configured", body["emailed"]) + } + link, _ := body["link"].(string) + if !strings.Contains(link, "token=") { + t.Fatalf("link = %q, want one carrying a token", link) + } + + // Den venter i panelet under noten, så ejeren kan se, at den er sendt. + _, panel := owner.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil) + pending, _ := panel["invites"].([]any) + if len(pending) != 1 { + t.Fatalf("want one pending invite under the note, got %v", pending) + } + + // Og linket laver kontoen og delingen i ét. + token := link[strings.Index(link, "token=")+len("token="):] + fresh := newSignedOut(t, owner) + resp, _ = fresh.do(t, "POST", "/api/v1/auth/signup", map[string]any{ + "token": token, "name": "Sofie", "password": "et langt kodeord", + }) + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + t.Fatalf("signup with the note invite: status %d", resp.StatusCode) + } + + // Noten er hendes at læse — og som medredaktør også at rette. + resp, note := fresh.do(t, "GET", "/api/v1/notes/"+noteID, nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("the invited person cannot read the note: status %d", resp.StatusCode) + } + if note["title"] != "Aftale om levering" { + t.Errorf("the invited person got something else: %v", note) + } + resp, _ = fresh.do(t, "PATCH", "/api/v1/notes/"+noteID, + map[string]string{"body": "# Aftale om levering\nfredag kl. 10"}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("an invited editor cannot write: status %d", resp.StatusCode) + } + + // Og invitationen er brugt op: den står ikke længere og venter. + _, after := owner.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil) + if left, _ := after["invites"].([]any); len(left) != 0 { + t.Errorf("the invite is still pending after it was accepted: %v", left) + } + shares, _ := after["shares"].([]any) + if len(shares) != 1 { + t.Fatalf("want the accepted invite to be a share, got %v", shares) + } +} + +// En adresse, der viser sig at høre til en konto, er den konto — ikke en +// invitation til at oprette en, som personen aldrig kan komme igennem. +func TestSharingByEmailFindsAnExistingAccount(t *testing.T) { + owner := newTestServer(t) + owner.bootstrap(t) + owner.newUser(t, "sofie@example.dk", "Sofie") + sofie := userID(t, owner, "sofie@example.dk") + noteID := owner.createNote(t, "# Prisliste\n2026") + + resp, body := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares", + map[string]string{"email": "Sofie@Example.dk"}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("share with an existing address: status %d, body %v", resp.StatusCode, body) + } + person, _ := body["user"].(map[string]any) + if person == nil || person["id"] != sofie { + t.Fatalf("the response does not say who it reached: %v", body) + } + if _, invited := body["invited"]; invited { + t.Error("an address with an account was invited rather than shared with") + } + + role, _, err := owner.db.NoteShareRole(t.Context(), noteID, sofie) + if err != nil || role != "viewer" { + t.Fatalf("share role = %v (%v), want viewer by default", role, err) + } +} + +// To links til den samme indbakke er ikke en invitation mere: det er to, der gør +// det samme, og kun det ene kan trækkes tilbage ad gangen. +func TestASecondInviteToTheSameNoteIsRefused(t *testing.T) { + owner := newTestServer(t) + owner.bootstrap(t) + noteID := owner.createNote(t, "# Bankoplysninger\nkonto") + + if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares", + map[string]string{"email": "ny@example.dk"}); resp.StatusCode != http.StatusCreated { + t.Fatalf("first invite: status %d", resp.StatusCode) + } + resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares", + map[string]string{"email": "ny@example.dk"}) + if resp.StatusCode != http.StatusConflict { + t.Fatalf("second invite: status %d, want 409", resp.StatusCode) + } + + // Trukket tilbage kan den derimod, og så kan der sendes en ny. + _, panel := owner.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil) + pending, _ := panel["invites"].([]any) + id := pending[0].(map[string]any)["id"].(string) + if resp, _ := owner.do(t, "DELETE", "/api/v1/notes/"+noteID+"/invites/"+id, nil); resp.StatusCode != http.StatusNoContent { + t.Fatalf("withdraw: status %d", resp.StatusCode) + } + if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares", + map[string]string{"email": "ny@example.dk"}); resp.StatusCode != http.StatusCreated { + t.Fatalf("invite again after withdrawing: status %d", resp.StatusCode) + } +} + +// Kun ejeren. En medlæser, der kunne invitere, ville kunne give noten videre til +// nogen, ejeren aldrig har hørt om — og til en konto, der ikke fandtes før. +func TestOnlyTheOwnerCanInviteToANote(t *testing.T) { + owner := newTestServer(t) + owner.bootstrap(t) + other := owner.newUser(t, "sofie@example.dk", "Sofie") + sofie := userID(t, owner, "sofie@example.dk") + noteID := owner.createNote(t, "# Ferieplan\nuge 29") + + if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares", + map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK { + t.Fatalf("share: status %d", resp.StatusCode) + } + + resp, _ := other.do(t, "POST", "/api/v1/notes/"+noteID+"/shares", + map[string]string{"email": "fremmed@example.dk"}) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("an editor could invite somebody: status %d", resp.StatusCode) + } + if n, err := owner.db.ListNoteInvites(t.Context(), noteID); err != nil || len(n) != 0 { + t.Fatalf("invites = %v (%v), want none", n, err) + } +} + +// En invitation til én note kan ikke bruges til at rydde en anden notes +// invitationer af vejen: note-id'et er en del af WHERE, ikke et tjek udenom. +func TestANoteInviteCannotBeWithdrawnThroughAnotherNote(t *testing.T) { + owner := newTestServer(t) + owner.bootstrap(t) + mine := owner.createNote(t, "# Min note\n1") + other := owner.createNote(t, "# Min anden note\n2") + + if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+mine+"/shares", + map[string]string{"email": "ny@example.dk"}); resp.StatusCode != http.StatusCreated { + t.Fatal("invite failed") + } + invites, err := owner.db.ListNoteInvites(t.Context(), mine) + if err != nil || len(invites) != 1 { + t.Fatalf("invites = %v (%v)", invites, err) + } + + resp, _ := owner.do(t, "DELETE", "/api/v1/notes/"+other+"/invites/"+invites[0].ID, nil) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status %d, want 404 for another note's invite", resp.StatusCode) + } + if left, _ := owner.db.ListNoteInvites(t.Context(), mine); len(left) != 1 { + t.Error("the invite was withdrawn through a note it does not belong to") + } +} diff --git a/internal/httpapi/notes_share_handlers.go b/internal/httpapi/notes_share_handlers.go index 8a7864a..d6ecb50 100644 --- a/internal/httpapi/notes_share_handlers.go +++ b/internal/httpapi/notes_share_handlers.go @@ -2,6 +2,7 @@ package httpapi import ( "net/http" + "strings" "github.com/go-chi/chi/v5" @@ -128,9 +129,23 @@ func (s *Server) handleListNoteShares(w http.ResponseWriter, r *http.Request) { cand = append(cand, personJSON{ID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor}) } + // De inviterede står ved siden af dem, der er kommet. Uden dem er en + // invitation sendt i går usynlig, og så sender man den igen — endnu et link til + // den samme indbakke, og ingen af dem til at trække tilbage. + invites, err := s.db.ListNoteInvites(r.Context(), n.ID) + if err != nil { + s.internal(w, r, "list note invites", err) + return + } + pending := make([]noteInviteJSON, 0, len(invites)) + for _, i := range invites { + pending = append(pending, noteInviteJSON{ID: i.ID, Email: i.Email, Role: string(i.Role)}) + } + writeJSON(w, http.StatusOK, map[string]any{ "shares": out, "candidates": cand, + "invites": pending, "follows": followsOf(following, n.ID), }) } @@ -167,10 +182,33 @@ type noteShareJSON struct { type shareNoteRequest struct { UserID string `json:"user_id"` - Role string `json:"role"` + // Email deles der med, når personen ikke er at finde i listen — enten fordi + // man kender adressen bedre end navnet, eller fordi der ikke er nogen konto at + // finde endnu. + Email string `json:"email"` + Role string `json:"role"` +} + +// noteInviteJSON er en deling, der venter på en konto. Der er ikke noget navn og +// ingen farve at vise: adressen er alt, hvad der findes om personen indtil de +// dukker op. +type noteInviteJSON struct { + ID string `json:"id"` + Email string `json:"email"` + Role string `json:"role"` } // handleShareNote grants a person a role on a note, or changes the role they hold. +// +// Der er to måder at sige, hvem det er. `user_id` er den, man har peget på i +// listen. `email` er den, man kender adressen på — og det er den, der også virker, +// når personen ikke har nogen konto: så bliver delingen til en invitation, der +// venter, og bliver til en rigtig deling i det øjeblik, kontoen bliver oprettet. +// +// Det er ikke en ny magt til nogen. Enhver, der ejer et projekt, kunne i forvejen +// invitere en fremmed ind på instansen pr. e-mail; det her er den samme handling +// på en note, der ellers krævede tre skridt og en ventetid: invitér til instansen, +// vent på at de opretter sig, find noten frem, del den. func (s *Server) handleShareNote(w http.ResponseWriter, r *http.Request) { var req shareNoteRequest if err := decodeJSON(w, r, &req); err != nil { @@ -194,34 +232,134 @@ func (s *Server) handleShareNote(w http.ResponseWriter, r *http.Request) { writeFieldErrors(w, map[string]string{"role": "must be viewer or editor"}) return } - if req.UserID == "" { + + me := userFrom(r.Context()) + email := store.NormalizeEmail(req.Email) + + if req.UserID == "" && email == "" { writeFieldErrors(w, map[string]string{"user_id": "required"}) return } + + // 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 + // — samme regel som en projektinvitation følger. + if req.UserID == "" { + if !strings.Contains(email, "@") { + writeFieldErrors(w, map[string]string{"email": "must be an email address"}) + return + } + if email == store.NormalizeEmail(me.Email) { + writeFieldErrors(w, map[string]string{"email": "you already have this note"}) + return + } + existing, err := s.db.UserByEmail(r.Context(), email) + if err != nil { + s.inviteToNote(w, r, n, email, role) + return + } + req.UserID = existing.ID + } + // The recipient must be a real account and not the sharer themselves; ShareNote // refuses the owner, and a made-up id should not reach it. - if req.UserID == userFrom(r.Context()).ID { + if req.UserID == me.ID { writeFieldErrors(w, map[string]string{"user_id": "you already have this note"}) return } - if _, err := s.db.PersonByID(r.Context(), req.UserID); err != nil { + 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.ShareNote(r.Context(), n.ID, req.UserID, role, userFrom(r.Context()).ID); err != nil { + if err := s.db.ShareNote(r.Context(), n.ID, req.UserID, role, me.ID); err != nil { s.storeError(w, r, "share note", err) return } + s.notifyNoteShared(r, n, req.UserID) // Og det, noten peger på, følger med. Svaret siger hvad — en deling, der stille // tog tre noter mere med, ville være rigtig og alligevel ikke til at overskue. - followed, err := s.db.SyncLinkedShares(r.Context(), userFrom(r.Context()).ID) + followed, err := s.db.SyncLinkedShares(r.Context(), me.ID) if err != nil { s.internal(w, r, "sync linked shares", err) return } - writeJSON(w, http.StatusOK, map[string]any{"follows": followsOf(followed, n.ID)}) + writeJSON(w, http.StatusOK, map[string]any{ + "follows": followsOf(followed, n.ID), + // 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), + }) +} + +// inviteToNote sends a link that creates an account and the share at once. +// +// Linket vises i svaret, når der ikke er nogen post at sende det med. En +// invitation, der forsvandt, fordi instansen ikke har en SMTP-server, ville se ud +// som om den var sendt — og først blive opdaget som ikke-sendt af den, der ventede +// på den. +func (s *Server) inviteToNote(w http.ResponseWriter, r *http.Request, n *store.Note, email string, role store.Role) { + me := userFrom(r.Context()) + + // En anden invitation til den samme note i den samme indbakke er ikke en + // invitation mere: det er to links, der gør det samme, og kun det ene kan + // trækkes tilbage ad gangen. + pending, err := s.db.PendingNoteInvite(r.Context(), n.ID, email) + if err != nil { + s.internal(w, r, "pending note invite", err) + return + } + if pending { + writeError(w, http.StatusConflict, CodeConflict, "that address has already been invited to this note") + return + } + + token, inv, err := s.db.CreateNoteInvite(r.Context(), email, n.ID, role, me.ID, s.cfg.InviteTTL) + if err != nil { + s.internal(w, r, "create note invite", err) + return + } + link := s.cfg.BaseURL + "/invite?token=" + token + + emailed := false + if s.mail.Configured() { + if err := s.mail.SendNoteInvite(r.Context(), email, me.Name, n.Title, link, s.cfg.InviteTTL); err != nil { + s.log.Error("send note invite", "err", err, "to", email) + } else { + emailed = true + } + } + + writeJSON(w, http.StatusCreated, map[string]any{ + "invited": noteInviteJSON{ID: inv.ID, Email: inv.Email, Role: string(inv.Role)}, + "link": link, + "emailed": emailed, + }) +} + +// handleDeleteNoteInvite withdraws an invitation before it is used. +// +// Det er den eneste vej tilbage. Kun aftrykket af tokenet er gemt, så et link, der +// er sendt til den forkerte adresse, kan ikke findes frem og gøres ugyldigt — det +// kan kun rækken, det hører til. +func (s *Server) handleDeleteNoteInvite(w http.ResponseWriter, r *http.Request) { + n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID")) + if err != nil { + s.internal(w, r, "get note", err) + return + } + if n == nil || !s.ownsNote(r, n) { + writeError(w, http.StatusNotFound, CodeNotFound, "no such note") + return + } + if err := s.db.DeleteNoteInvite(r.Context(), n.ID, chi.URLParam(r, "inviteID")); err != nil { + s.storeError(w, r, "delete note invite", err) + return + } + w.WriteHeader(http.StatusNoContent) } // handleUnshareNote takes a person's access away again. diff --git a/internal/httpapi/notifications_handlers.go b/internal/httpapi/notifications_handlers.go index 8bf7236..322c7ae 100644 --- a/internal/httpapi/notifications_handlers.go +++ b/internal/httpapi/notifications_handlers.go @@ -120,6 +120,26 @@ func (s *Server) notifyAssigned(r *http.Request, t *store.Task) { }) } +// notifyNoteShared tells somebody that a note is now theirs to read. +// +// Én besked, når delingen sker, og ikke noget bagefter: den, der får en note delt, +// skal vide, at den er der. At den senere bliver rettet, er en anden besked — den +// skriver notifyNoteChanged — og de to må ikke sige det samme om det samme. +// +// Ikke sammenlagt med noget. En deling sker én gang pr. person pr. note, så der er +// ikke en gentagelse at folde sammen — og bliver rollen ændret bagefter, er det +// ikke en ny note at læse. +func (s *Server) notifyNoteShared(r *http.Request, n *store.Note, userID string) { + if n == nil || userID == "" { + return + } + actor := userFrom(r.Context()) + s.notify(r, &store.Notification{ + UserID: userID, ActorID: actor.ID, ProjectID: n.ProjectID, NoteID: n.ID, + Kind: "note.shared", Title: actor.Name + " delte en note med dig", Body: n.Title, + }) +} + // notifyNoteChanged tells everybody who can see a note that somebody else wrote in // it — folded into one line per note while it is unread. // diff --git a/internal/httpapi/notifications_test.go b/internal/httpapi/notifications_test.go index 774b9c5..b2dfc7b 100644 --- a/internal/httpapi/notifications_test.go +++ b/internal/httpapi/notifications_test.go @@ -109,14 +109,22 @@ func TestEditingASharedNoteNotifiesTheOthers(t *testing.T) { t.Fatal("del noten") } + // Selve delingen er den første besked: Sofie skal vide, at noten er der, før + // nogen retter i den. Den tælles med i alt, hvad der følger. + if list, unread := notifications(t, other); len(list) != 1 || unread != 1 || + firstOf(t, list)["kind"] != "note.shared" { + t.Fatalf("delingen gav %d beskeder (%v ulæste): %v", len(list), unread, list) + } + // Ejeren skriver. Sofie skal høre det. if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID, map[string]any{"body": "# Aftale om levering\nanden linje"}); resp.StatusCode != http.StatusOK { t.Fatal("ret noten") } list, unread := notifications(t, other) - if unread != 1 || len(list) != 1 { - t.Fatalf("Sofie fik %d beskeder (%v ulæste), want 1", len(list), unread) + if unread != 2 || len(list) != 2 { + t.Fatalf("Sofie fik %d beskeder (%v ulæste), want 2 — delingen og rettelsen", + len(list), unread) } n := firstOf(t, list) if n["kind"] != "note.changed" || n["note_id"] != noteID { @@ -132,8 +140,9 @@ func TestEditingASharedNoteNotifiesTheOthers(t *testing.T) { t.Fatal("ret noten igen") } } - if list, unread := notifications(t, other); len(list) != 1 || unread != 1 { - t.Errorf("tre gemninger blev til %d beskeder, want 1", len(list)) + if list, unread := notifications(t, other); len(list) != 2 || unread != 2 { + t.Errorf("tre gemninger blev til %d beskeder, want 2 — delingen og én rettelse", + len(list)) } // Læst, og så en rettelse mere: dét er en ny ting at få at vide. @@ -144,8 +153,8 @@ func TestEditingASharedNoteNotifiesTheOthers(t *testing.T) { map[string]any{"body": "# Aftale om levering\nefter læsningen"}); resp.StatusCode != http.StatusOK { t.Fatal("ret noten efter læsningen") } - if list, unread := notifications(t, other); len(list) != 2 || unread != 1 { - t.Errorf("efter læsningen gav en rettelse %d beskeder (%v ulæste), want 2 og 1", len(list), unread) + if list, unread := notifications(t, other); len(list) != 3 || unread != 1 { + t.Errorf("efter læsningen gav en rettelse %d beskeder (%v ulæste), want 3 og 1", len(list), unread) } // Ejeren har ikke fået besked om sine egne rettelser undervejs. @@ -171,8 +180,8 @@ func TestEditingASharedNoteNotifiesTheOthers(t *testing.T) { map[string]any{"pinned": true}); resp.StatusCode != http.StatusOK { t.Fatal("stjernemarkér") } - if list, _ := notifications(t, other); len(list) != 2 { - t.Errorf("en stjernemarkering gav besked: %d beskeder, want 2", len(list)) + if list, _ := notifications(t, other); len(list) != 3 { + t.Errorf("en stjernemarkering gav besked: %d beskeder, want 3", len(list)) } } diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 4ff6121..697752e 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -353,6 +353,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server r.Get("/{noteID}/shares", s.handleListNoteShares) r.Post("/{noteID}/shares", s.handleShareNote) r.Delete("/{noteID}/shares/{userID}", s.handleUnshareNote) + // En invitation til en note er en deling, der venter på en konto. + // Den kan tages tilbage indtil den bruges; se handleShareNote. + r.Delete("/{noteID}/invites/{inviteID}", s.handleDeleteNoteInvite) r.Get("/{noteID}", s.handleGetNote) r.Patch("/{noteID}", s.handleUpdateNote) r.Delete("/{noteID}", s.handleDeleteNote) diff --git a/internal/mail/mail.go b/internal/mail/mail.go index c3756fa..cebabc0 100644 --- a/internal/mail/mail.go +++ b/internal/mail/mail.go @@ -38,11 +38,30 @@ func New(cfg config.SMTP, baseURL string, log *slog.Logger) *Sender { func (s *Sender) Configured() bool { return s.cfg.Configured() } func (s *Sender) SendInvite(ctx context.Context, to, inviterName, projectName, link string, ttl time.Duration) error { - subject := fmt.Sprintf("%s har delt noget med dig i verdande", inviterName) what := "verdande" if projectName != "" { what = "projektet “" + projectName + "”" } + return s.sendInvite(ctx, to, inviterName, what, link, ttl) +} + +// SendNoteInvite is the same letter about a single note. +// +// The title is in the mail because it is what makes the invitation mean anything — +// "Kristian har inviteret dig til noten “Aftale om levering”" is a sentence +// somebody can decide about, where "til en note" is one they have to click a link +// to understand. It travels no further than the address the owner typed, which is +// the address they chose to share the note with. +func (s *Sender) SendNoteInvite(ctx context.Context, to, inviterName, noteTitle, link string, ttl time.Duration) error { + what := "en note" + if noteTitle != "" { + what = "noten “" + noteTitle + "”" + } + return s.sendInvite(ctx, to, inviterName, what, link, ttl) +} + +func (s *Sender) sendInvite(ctx context.Context, to, inviterName, what, link string, ttl time.Duration) error { + subject := fmt.Sprintf("%s har delt noget med dig i verdande", inviterName) body := fmt.Sprintf(`Hej %s har inviteret dig til %s. diff --git a/internal/store/invites.go b/internal/store/invites.go index 05ea3f9..1e25bd8 100644 --- a/internal/store/invites.go +++ b/internal/store/invites.go @@ -16,6 +16,7 @@ type Invite struct { ID string Email string ProjectID string // empty for an invite to the instance rather than to a project + NoteID string // set when the invite is to a single note Role Role CreatedBy string CreatedAt time.Time @@ -68,36 +69,46 @@ func (db *DB) CreateFirstAdmin(ctx context.Context, u *User, inboxName string) e }) } -// CreateInvite issues an invite and returns the token for the emailed link. -// projectID may be empty, which invites somebody to the instance rather than to a -// particular project. +// CreateInvite issues an invite to a project and returns the token for the emailed +// link. projectID may be empty, which invites somebody to the instance rather than +// to a particular project. func (db *DB) CreateInvite(ctx context.Context, email, projectID string, role Role, createdBy string, ttl time.Duration) (string, *Invite, error) { - if !role.Valid() { - return "", nil, fmt.Errorf("store: %q is not a role", role) + return db.createInvite(ctx, &Invite{Email: email, ProjectID: projectID, Role: role, CreatedBy: createdBy}, ttl) +} + +// CreateNoteInvite issues an invite to a single note. +// +// Same row, same token, same link as a project invite — the difference is only +// what the person gets when they arrive, and that is settled in AcceptInvite. A +// second table for "invited to a note" would have meant a second signup path to +// keep in step with this one, and the two would drift the first time either was +// touched. +func (db *DB) CreateNoteInvite(ctx context.Context, email, noteID string, role Role, createdBy string, ttl time.Duration) (string, *Invite, error) { + if noteID == "" { + return "", nil, errors.New("store: a note invite needs a note") + } + return db.createInvite(ctx, &Invite{Email: email, NoteID: noteID, Role: role, CreatedBy: createdBy}, ttl) +} + +func (db *DB) createInvite(ctx context.Context, inv *Invite, ttl time.Duration) (string, *Invite, error) { + if !inv.Role.Valid() { + return "", nil, fmt.Errorf("store: %q is not a role", inv.Role) } token, err := auth.NewToken() if err != nil { return "", nil, err } now := time.Now().UTC() - inv := &Invite{ - ID: NewID(), - Email: NormalizeEmail(email), - ProjectID: projectID, - Role: role, - CreatedBy: createdBy, - CreatedAt: now, - ExpiresAt: now.Add(ttl), - } + inv.ID = NewID() + inv.Email = NormalizeEmail(inv.Email) + inv.CreatedAt = now + inv.ExpiresAt = now.Add(ttl) - var project any - if projectID != "" { - project = projectID - } _, err = db.ExecContext(ctx, - `INSERT INTO invites (id, email, project_id, role, token_hash, created_by, created_at, expires_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - inv.ID, inv.Email, project, string(inv.Role), auth.HashToken(token), + `INSERT INTO invites (id, email, project_id, note_id, role, token_hash, created_by, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + inv.ID, inv.Email, nullString(inv.ProjectID), nullString(inv.NoteID), + string(inv.Role), auth.HashToken(token), inv.CreatedBy, inv.CreatedAt.Unix(), inv.ExpiresAt.Unix()) if err != nil { return "", nil, err @@ -112,14 +123,14 @@ func (db *DB) InviteByToken(ctx context.Context, token string) (*Invite, error) return nil, ErrInviteInvalid } var inv Invite - var projectID sql.NullString + var projectID, noteID sql.NullString var created, expires int64 var accepted sql.NullInt64 err := db.QueryRowContext(ctx, - `SELECT id, email, project_id, role, created_by, created_at, expires_at, accepted_at + `SELECT id, email, project_id, note_id, role, created_by, created_at, expires_at, accepted_at FROM invites WHERE token_hash = ?`, auth.HashToken(token)). - Scan(&inv.ID, &inv.Email, &projectID, &inv.Role, &inv.CreatedBy, &created, &expires, &accepted) + Scan(&inv.ID, &inv.Email, &projectID, ¬eID, &inv.Role, &inv.CreatedBy, &created, &expires, &accepted) if errors.Is(err, sql.ErrNoRows) { return nil, ErrInviteInvalid } @@ -131,6 +142,7 @@ func (db *DB) InviteByToken(ctx context.Context, token string) (*Invite, error) } inv.ProjectID = projectID.String + inv.NoteID = noteID.String inv.CreatedAt = time.Unix(created, 0).UTC() inv.ExpiresAt = time.Unix(expires, 0).UTC() return &inv, nil @@ -144,10 +156,16 @@ type PendingInvite struct { Email string ProjectID string ProjectName string - Role Role - InvitedBy string - CreatedAt time.Time - ExpiresAt time.Time + // NoteID is set for an invitation to a single note. The note is named by its + // id and never by its title: titles are sealed in the database, and a private + // note's first line is not something an administrator's user list should + // spell out. That there is an account on its way is the administrator's + // business; what it was invited to read is not. + NoteID string + Role Role + InvitedBy string + CreatedAt time.Time + ExpiresAt time.Time } // ListPendingInvites returns invites that are neither accepted nor expired. @@ -159,7 +177,8 @@ type PendingInvite struct { // able to say which it is. func (db *DB) ListPendingInvites(ctx context.Context) ([]PendingInvite, error) { rows, err := db.QueryContext(ctx, ` - SELECT i.id, i.email, COALESCE(i.project_id, ''), COALESCE(p.name, ''), i.role, + SELECT i.id, i.email, COALESCE(i.project_id, ''), COALESCE(p.name, ''), + COALESCE(i.note_id, ''), i.role, COALESCE(u.name, ''), i.created_at, i.expires_at FROM invites i LEFT JOIN projects p ON p.id = i.project_id @@ -175,8 +194,8 @@ func (db *DB) ListPendingInvites(ctx context.Context) ([]PendingInvite, error) { for rows.Next() { var p PendingInvite var created, expires int64 - if err := rows.Scan(&p.ID, &p.Email, &p.ProjectID, &p.ProjectName, &p.Role, - &p.InvitedBy, &created, &expires); err != nil { + if err := rows.Scan(&p.ID, &p.Email, &p.ProjectID, &p.ProjectName, &p.NoteID, + &p.Role, &p.InvitedBy, &created, &expires); err != nil { return nil, err } p.CreatedAt = time.Unix(created, 0).UTC() @@ -200,12 +219,16 @@ func (db *DB) DeleteInvite(ctx context.Context, inviteID string) error { return nil } -// AcceptInvite marks the invite used and grants the membership it promised, in one +// AcceptInvite marks the invite used and grants what it promised, in one // transaction. Splitting the two would allow a signup to fail partway and leave a // live invite behind, which is a link that still works after it has been used. // // The UPDATE is guarded on accepted_at IS NULL, so two requests racing with the -// same link produce one membership and one failure rather than two memberships. +// same link produce one grant and one failure rather than two. +// +// What is granted depends on what the invite was to: a project membership, a share +// on a single note, or — when it carries neither — nothing beyond the account the +// caller has just created. func (db *DB) AcceptInvite(ctx context.Context, inviteID, userID string) error { return db.Tx(ctx, func(tx *sql.Tx) error { res, err := tx.ExecContext(ctx, @@ -218,13 +241,40 @@ func (db *DB) AcceptInvite(ctx context.Context, inviteID, userID string) error { return ErrInviteInvalid } - var projectID sql.NullString + var projectID, noteID sql.NullString var role Role + var invitedBy string if err := tx.QueryRowContext(ctx, - `SELECT project_id, role FROM invites WHERE id = ?`, inviteID). - Scan(&projectID, &role); err != nil { + `SELECT project_id, note_id, role, created_by FROM invites WHERE id = ?`, inviteID). + Scan(&projectID, ¬eID, &role, &invitedBy); err != nil { return err } + + if noteID.Valid && noteID.String != "" { + // created_by is the person who invited, not the person arriving: the row + // records who did the sharing, the same as a share made from the panel. + // + // The note may have been deleted between the invite and the signup. The + // foreign key would refuse the row and take the whole signup with it, so + // the note is looked up first and a share on a note that is gone is + // simply not made — the account is still worth creating. + var alive int + if err := tx.QueryRowContext(ctx, + `SELECT count(*) FROM notes WHERE id = ? AND deleted_at IS NULL AND created_by <> ?`, + noteID.String, userID).Scan(&alive); err != nil { + return err + } + if alive == 0 { + return nil + } + _, err = tx.ExecContext(ctx, ` + INSERT INTO note_shares (note_id, user_id, role, created_by, created_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (note_id, user_id) DO UPDATE SET role = excluded.role`, + noteID.String, userID, string(role), invitedBy, time.Now().Unix()) + return err + } + if !projectID.Valid || projectID.String == "" { return nil // an invite to the instance, with no project attached } @@ -240,6 +290,81 @@ func (db *DB) AcceptInvite(ctx context.Context, inviteID, userID string) error { }) } +// --- invitationer til én note ---------------------------------------------------- + +// NoteInvite is somebody who has been invited to a note and has not arrived yet. +// The email address is the whole of who they are — there is no account to name +// them by, which is the entire reason the row exists. +type NoteInvite struct { + ID string `json:"id"` + Email string `json:"email"` + Role Role `json:"role"` + CreatedAt time.Time `json:"-"` + ExpiresAt time.Time `json:"-"` +} + +// ListNoteInvites is who has been invited to a note and not yet taken it up. +// +// The panel needs them beside the people who *are* on the note: without this, an +// invitation sent yesterday is invisible, and the owner sends it again — a second +// link to the same inbox, and no way to take either back. +// +// Expired ones are left out, the same rule the administrator's list follows: an +// invite past its date is not something anybody can act on. +func (db *DB) ListNoteInvites(ctx context.Context, noteID string) ([]NoteInvite, error) { + rows, err := db.QueryContext(ctx, ` + SELECT id, email, role, created_at, expires_at + FROM invites + WHERE note_id = ? AND accepted_at IS NULL AND expires_at > ? + ORDER BY created_at`, noteID, time.Now().Unix()) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []NoteInvite{} + for rows.Next() { + var i NoteInvite + 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() +} + +// DeleteNoteInvite withdraws an invitation to a note, and refuses to withdraw +// anything else. +// +// The note id is part of the WHERE rather than checked beforehand: the owner has +// been established for *this* note, and one statement that can only touch this +// note's rows cannot be talked into deleting a project invite by id. +func (db *DB) DeleteNoteInvite(ctx context.Context, noteID, inviteID string) error { + res, err := db.ExecContext(ctx, + `DELETE FROM invites WHERE id = ? AND note_id = ?`, inviteID, noteID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +// 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) { + var n int + err := db.QueryRowContext(ctx, ` + SELECT count(*) FROM invites + WHERE note_id = ? AND email = ? AND accepted_at IS NULL AND expires_at > ?`, + noteID, NormalizeEmail(email), time.Now().Unix()).Scan(&n) + return n > 0, err +} + // --- password resets ---------------------------------------------------------- // CreatePasswordReset invalidates any outstanding reset for the user before issuing diff --git a/internal/store/migrations/0034_invitationer_til_en_note.sql b/internal/store/migrations/0034_invitationer_til_en_note.sql new file mode 100644 index 0000000..9e2097d --- /dev/null +++ b/internal/store/migrations/0034_invitationer_til_en_note.sql @@ -0,0 +1,30 @@ +-- En invitation kan handle om en note. +-- +-- En note kunne deles med nogen, der allerede havde en konto — og kun med dem. +-- Skulle en, der ikke havde en, læse med, skulle man først invitere dem til +-- instansen, vente på at de oprettede sig, og så finde noten frem og dele den. Tre +-- skridt og en ventetid for det, der er én sætning: "lad Sofie se den her". +-- +-- Rækken er den, der allerede findes. En invitation bærer i forvejen en e-mail, en +-- rolle og et forseglet token; det eneste, der manglede, var at kunne sige, hvad +-- den er en invitation *til*. project_id sagde det for projekter, og note_id siger +-- det nu for noter. +-- +-- To søjler frem for én kolonne med et id og en art ved siden af — samme valg som +-- notifications traf i 0033, og af samme grund: fremmednøglen. ON DELETE CASCADE +-- er dén, der rydder invitationen væk, når noten slettes, inden nogen når at tage +-- imod en invitation til noget, der ikke er der længere. Et generisk par kan ikke +-- have den nøgle, og oprydningen skulle så skrives i kode, der huskede at køre. +-- +-- De to udelukker hinanden i praksis, men det står ikke som en CHECK: en +-- invitation uden nogen af delene er den gyldige "kom med på instansen", og en +-- regel, der skulle tillade nul eller én af to, ville skulle skrives om hver gang +-- der kom en tredje slags ting at invitere til. +ALTER TABLE invites ADD COLUMN note_id TEXT REFERENCES notes (id) ON DELETE CASCADE; + +-- Panelet under en note spørger "hvem er inviteret til den her, og har ikke taget +-- imod endnu" hver gang, det åbnes. Det er opslaget, og det er derfor, det er +-- indekset. Kun de uafklarede: en invitation, der er taget imod, er en deling nu +-- og står i note_shares. +CREATE INDEX invites_pending_by_note + ON invites (note_id) WHERE accepted_at IS NULL AND note_id IS NOT NULL; diff --git a/internal/store/notifications.go b/internal/store/notifications.go index 852297f..2dc1d6b 100644 --- a/internal/store/notifications.go +++ b/internal/store/notifications.go @@ -75,7 +75,12 @@ func (db *DB) ListNotifications(ctx context.Context, userID string, limit int) ( FROM notifications n LEFT JOIN users u ON u.id = n.actor_id WHERE n.user_id = ? - ORDER BY n.created_at DESC + -- Tiden er sekunder, og to ting kan ske i det samme sekund: en note bliver + -- delt og rettet i samme håndbevægelse. Uden andet at falde tilbage på + -- afgør basen selv, hvilken der står øverst, og klokken viser dem i den + -- rækkefølge, den nu engang fandt dem i. id'et er tidsordnet, så det er + -- den rigtige afgørelse at give den — og den er den samme hver gang. + ORDER BY n.created_at DESC, n.id DESC LIMIT ?`, userID, limit) if err != nil { return nil, err diff --git a/web/src/lib/api.js b/web/src/lib/api.js index b2ae722..196a688 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -454,7 +454,12 @@ export const api = { // path. Owner only; the list call also returns who it can still be shared with. noteShares: (id) => get(`/notes/${id}/shares`), shareNote: (id, userId, role) => post(`/notes/${id}/shares`, { user_id: userId, role }), + // Deling ved adresse. Har adressen en konto, bliver det en almindelig deling — + // svaret siger hvem — og ellers en invitation, der venter på, at kontoen bliver + // oprettet. + shareNoteByEmail: (id, email, role) => post(`/notes/${id}/shares`, { email, role }), unshareNote: (id, userId) => del(`/notes/${id}/shares/${userId}`), + revokeNoteInvite: (id, inviteId) => del(`/notes/${id}/invites/${inviteId}`), // Mailboxes read over IMAP. Each belongs to the person who connected it, so // there is no instance-wide registration behind these the way Gmail has one. diff --git a/web/src/lib/components/Notifications.svelte b/web/src/lib/components/Notifications.svelte index b31e0fa..d1b5926 100644 --- a/web/src/lib/components/Notifications.svelte +++ b/web/src/lib/components/Notifications.svelte @@ -23,6 +23,7 @@ const KINDS = { assigned: 'notif.assigned', 'note.changed': 'notif.noteChanged', + 'note.shared': 'notif.noteShared', comment: 'notif.comment' }; diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js index fcc78a5..5f5aaf8 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -30,6 +30,7 @@ export const da = { 'notif.markAllRead': 'Markér alle som læst', 'notif.assigned': '{name} gav dig en opgave', 'notif.noteChanged': '{name} rettede en note', + 'notif.noteShared': '{name} delte en note med dig', 'notif.comment': '{name} kommenterede', 'notif.justNow': 'lige nu', 'notif.minutes': '{n} min. siden', @@ -50,6 +51,17 @@ export const da = { 'notes.shareFollows': 'Følger med:', 'notes.shareVia': 'følger med “{note}”', 'notes.unsharedPerson': '{name} kan ikke længere se noten.', + // Deling med en adresse. Ordene skal kunne bære begge udfald — en deling nu + // eller en invitation, der venter — uden at love det ene. + 'notes.shareByEmail': 'Del med en e-mailadresse', + 'notes.shareEmailPlaceholder': 'navn@eksempel.dk', + 'notes.shareRole': 'Rolle', + 'notes.shareInvite': 'Inviter', + 'notes.invited': 'Invitationen er sendt til {email}.', + 'notes.invitePending': 'inviteret', + 'notes.inviteRevoke': 'Træk invitationen tilbage', + 'notes.inviteRevoked': 'Invitationen til {email} er trukket tilbage.', + 'notes.inviteNoMail': 'Instansen kan ikke sende post. Giv linket videre selv:', 'notes.shareOwnerOnly': 'Kun ejeren kan dele noten.', 'notes.save': 'Gem', 'notes.format': 'Formatér', diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js index 390e8d4..c14cd53 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -27,6 +27,7 @@ export const en = { 'notif.markAllRead': 'Mark all as read', 'notif.assigned': '{name} gave you a task', 'notif.noteChanged': '{name} edited a note', + 'notif.noteShared': '{name} shared a note with you', 'notif.comment': '{name} commented', 'notif.justNow': 'just now', 'notif.minutes': '{n} min ago', @@ -47,6 +48,17 @@ export const en = { 'notes.shareFollows': 'Comes along:', 'notes.shareVia': 'came along with “{note}”', 'notes.unsharedPerson': '{name} can no longer see the note.', + // Sharing with an address. The words have to carry both outcomes — a share now, + // or an invitation that waits — without promising either one. + 'notes.shareByEmail': 'Share with an email address', + 'notes.shareEmailPlaceholder': 'name@example.com', + 'notes.shareRole': 'Role', + 'notes.shareInvite': 'Invite', + 'notes.invited': 'The invitation has been sent to {email}.', + 'notes.invitePending': 'invited', + 'notes.inviteRevoke': 'Withdraw the invitation', + 'notes.inviteRevoked': 'The invitation to {email} has been withdrawn.', + 'notes.inviteNoMail': 'This instance cannot send mail. Pass the link on yourself:', 'notes.shareOwnerOnly': 'Only the owner can share the note.', 'notes.save': 'Save', 'notes.format': 'Format', diff --git a/web/src/routes/noter/+page.svelte b/web/src/routes/noter/+page.svelte index 55c4fe5..389da12 100644 --- a/web/src/routes/noter/+page.svelte +++ b/web/src/routes/noter/+page.svelte @@ -199,6 +199,14 @@ let sharePick = $state(''); let shareRole = $state('viewer'); let shareBusy = $state(false); + // De inviterede: delinger, der venter på, at der bliver oprettet en konto. + // De står i panelet ved siden af dem, der er kommet — ellers er en invitation + // sendt i går usynlig, og så sender man den igen. + let shareInvites = $state([]); + let shareEmail = $state(''); + // Linket vises kun, når instansen ikke kan sende post. Så er det den eneste vej + // frem, og det skal kunne kopieres frem for at forsvinde. + let shareInviteLink = $state(''); const ownsSelected = $derived(!!selected && selected.created_by === app.user?.id); // The whole of sharing — a project and named people — lives in a popover behind @@ -228,6 +236,9 @@ shares = []; shareCandidates = []; shareFollows = []; + shareInvites = []; + shareEmail = ''; + shareInviteLink = ''; sharePick = ''; if (!id || selected.created_by !== app.user?.id) return; let alive = true; @@ -238,6 +249,7 @@ shares = r.shares ?? []; shareCandidates = r.candidates ?? []; shareFollows = r.follows ?? []; + shareInvites = r.invites ?? []; }) .catch(() => {}); return () => { @@ -274,6 +286,54 @@ } } + /** + * Deling med en adresse frem for et navn i listen. + * + * Ét felt til begge udfald. Man skriver adressen på den, der skal læse med, og + * det er serveren, der ved, om der er en konto bag: er der det, bliver det en + * deling med det samme, og ellers en invitation, der bliver til en deling, når + * kontoen oprettes. At skulle vide det på forhånd — og vælge det rigtige felt — + * er at skulle vide noget om instansens brugerliste for at dele en note. + */ + async function inviteByEmail() { + if (!selected || !shareEmail.trim() || shareBusy) return; + shareBusy = true; + shareInviteLink = ''; + try { + const r = await api.shareNoteByEmail(selected.id, shareEmail.trim(), shareRole); + if (r?.invited) { + shareInvites = [...shareInvites, r.invited]; + // Kan instansen ikke sende post, er linket det eneste, der findes: + // vist frem for slugt, så det kan gives videre ad anden vej. + if (!r.emailed && r.link) shareInviteLink = r.link; + app.toast(t('notes.invited', { email: r.invited.email })); + } else if (r?.user) { + shares = [...shares, { user: r.user, role: r.role ?? shareRole }].sort((a, b) => + byName(a.user, b.user) + ); + shareCandidates = shareCandidates.filter((p) => p.id !== r.user.id); + shareFollows = r?.follows ?? shareFollows; + app.toast(t('notes.sharedToast', { name: r.user.name ?? '' })); + } + shareEmail = ''; + } catch (e) { + app.toast(humanMessage(e)); + } finally { + shareBusy = false; + } + } + + async function revokeInvite(inviteId, email) { + try { + await api.revokeNoteInvite(selected.id, inviteId); + shareInvites = shareInvites.filter((i) => i.id !== inviteId); + shareInviteLink = ''; + app.toast(t('notes.inviteRevoked', { email })); + } catch (e) { + app.toast(humanMessage(e)); + } + } + async function changeShareRole(userId, role) { try { await api.shareNote(selected.id, userId, role); @@ -1083,6 +1143,25 @@ >{i < shareFollows.length - 1 ? ', ' : ''}{/each}

{/if} + {#if shareInvites.length} + + + {/if} {#if shareCandidates.length}
+ + + + {#if shareInviteLink} + + + {/if}
{/if} @@ -1982,6 +2095,63 @@ text-decoration: underline; } + /* De inviterede. Samme form som listen ovenover, fordi det er den samme slags + linje — en person, noten er delt med — og forskellen er, at de ikke er kommet + endnu. Den forskel siges med ordet til højre, ikke med en anden form. */ + .invited { + list-style: none; + margin: 2px 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 2px; + } + + .invited li { + display: flex; + align-items: center; + gap: var(--s1); + font-size: var(--text-xs); + color: var(--ink-muted); + } + + .invited .who { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .invited .pending { + margin-left: auto; + color: var(--ink-faint); + white-space: nowrap; + } + + /* Adressefeltet får sin egen række af samme grund som personvælgeren: en + e-mailadresse klemt ned ved siden af en rolle og en knap bliver et felt, man + ikke kan læse det, man har skrevet i. */ + .byemail input { + flex: 1 1 100%; + min-width: 0; + font-size: var(--text-xs); + padding: 2px var(--s1); + } + + .invitelink { + margin: var(--s1) 0 0; + font-size: var(--text-xs); + color: var(--ink-muted); + line-height: 1.5; + } + + .invitelink input { + width: 100%; + margin-top: 2px; + font-size: var(--text-xs); + padding: 2px var(--s1); + } + .addshare { display: flex; align-items: center;