Merge pull request #63 from kristianwind/del-note-med-en-uden-konto
feat(noter): del en note med en, der ikke har en konto endnu
This commit is contained in:
+70
-1
@@ -3126,6 +3126,13 @@ paths:
|
|||||||
candidates:
|
candidates:
|
||||||
type: array
|
type: array
|
||||||
items: { $ref: "#/components/schemas/Person" }
|
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:
|
follows:
|
||||||
description: >
|
description: >
|
||||||
The notes this one points at with [[links]], which are shared
|
The notes this one points at with [[links]], which are shared
|
||||||
@@ -3137,6 +3144,11 @@ paths:
|
|||||||
tags: [notes]
|
tags: [notes]
|
||||||
summary: Share a note with a person, or change the role they hold.
|
summary: Share a note with a person, or change the role they hold.
|
||||||
description: >
|
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
|
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
|
and the same role, so a link in a shared note leads somewhere. Only notes
|
||||||
the sharer owns follow, and the response lists them.
|
the sharer owns follow, and the response lists them.
|
||||||
@@ -3146,9 +3158,10 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
required: [user_id]
|
description: One of user_id or email.
|
||||||
properties:
|
properties:
|
||||||
user_id: { type: string }
|
user_id: { type: string }
|
||||||
|
email: { type: string, format: email }
|
||||||
role: { type: string, enum: [viewer, editor] }
|
role: { type: string, enum: [viewer, editor] }
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
@@ -3158,12 +3171,58 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
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:
|
follows:
|
||||||
type: array
|
type: array
|
||||||
items: { $ref: "#/components/schemas/NoteFollow" }
|
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" }
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
"409": { $ref: "#/components/responses/Conflict" }
|
||||||
"422": { $ref: "#/components/responses/Validation" }
|
"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}:
|
/api/v1/notes/{noteID}/shares/{userID}:
|
||||||
delete:
|
delete:
|
||||||
tags: [notes]
|
tags: [notes]
|
||||||
@@ -4072,6 +4131,16 @@ components:
|
|||||||
note_id: { type: string }
|
note_id: { type: string }
|
||||||
title: { 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:
|
Mailbox:
|
||||||
type: object
|
type: object
|
||||||
description: One connected mailbox. The password and any token are never returned.
|
description: One connected mailbox. The password and any token are never returned.
|
||||||
|
|||||||
@@ -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": "[email protected]", "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"] != "[email protected]" {
|
||||||
|
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, "[email protected]", "Sofie")
|
||||||
|
sofie := userID(t, owner, "[email protected]")
|
||||||
|
noteID := owner.createNote(t, "# Prisliste\n2026")
|
||||||
|
|
||||||
|
resp, body := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||||
|
map[string]string{"email": "[email protected]"})
|
||||||
|
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": "[email protected]"}); 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": "[email protected]"})
|
||||||
|
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": "[email protected]"}); 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, "[email protected]", "Sofie")
|
||||||
|
sofie := userID(t, owner, "[email protected]")
|
||||||
|
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": "[email protected]"})
|
||||||
|
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": "[email protected]"}); 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"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})
|
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{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"shares": out,
|
"shares": out,
|
||||||
"candidates": cand,
|
"candidates": cand,
|
||||||
|
"invites": pending,
|
||||||
"follows": followsOf(following, n.ID),
|
"follows": followsOf(following, n.ID),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -167,10 +182,33 @@ type noteShareJSON struct {
|
|||||||
|
|
||||||
type shareNoteRequest struct {
|
type shareNoteRequest struct {
|
||||||
UserID string `json:"user_id"`
|
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.
|
// 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) {
|
func (s *Server) handleShareNote(w http.ResponseWriter, r *http.Request) {
|
||||||
var req shareNoteRequest
|
var req shareNoteRequest
|
||||||
if err := decodeJSON(w, r, &req); err != nil {
|
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"})
|
writeFieldErrors(w, map[string]string{"role": "must be viewer or editor"})
|
||||||
return
|
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"})
|
writeFieldErrors(w, map[string]string{"user_id": "required"})
|
||||||
return
|
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
|
// 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.
|
// 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"})
|
writeFieldErrors(w, map[string]string{"user_id": "you already have this note"})
|
||||||
return
|
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")
|
writeError(w, http.StatusNotFound, CodeNotFound, "no such person")
|
||||||
return
|
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)
|
s.storeError(w, r, "share note", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.notifyNoteShared(r, n, req.UserID)
|
||||||
|
|
||||||
// Og det, noten peger på, følger med. Svaret siger hvad — en deling, der stille
|
// 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.
|
// 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 {
|
if err != nil {
|
||||||
s.internal(w, r, "sync linked shares", err)
|
s.internal(w, r, "sync linked shares", err)
|
||||||
return
|
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.
|
// handleUnshareNote takes a person's access away again.
|
||||||
|
|||||||
@@ -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
|
// notifyNoteChanged tells everybody who can see a note that somebody else wrote in
|
||||||
// it — folded into one line per note while it is unread.
|
// it — folded into one line per note while it is unread.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -109,14 +109,22 @@ func TestEditingASharedNoteNotifiesTheOthers(t *testing.T) {
|
|||||||
t.Fatal("del noten")
|
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.
|
// Ejeren skriver. Sofie skal høre det.
|
||||||
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||||
map[string]any{"body": "# Aftale om levering\nanden linje"}); resp.StatusCode != http.StatusOK {
|
map[string]any{"body": "# Aftale om levering\nanden linje"}); resp.StatusCode != http.StatusOK {
|
||||||
t.Fatal("ret noten")
|
t.Fatal("ret noten")
|
||||||
}
|
}
|
||||||
list, unread := notifications(t, other)
|
list, unread := notifications(t, other)
|
||||||
if unread != 1 || len(list) != 1 {
|
if unread != 2 || len(list) != 2 {
|
||||||
t.Fatalf("Sofie fik %d beskeder (%v ulæste), want 1", len(list), unread)
|
t.Fatalf("Sofie fik %d beskeder (%v ulæste), want 2 — delingen og rettelsen",
|
||||||
|
len(list), unread)
|
||||||
}
|
}
|
||||||
n := firstOf(t, list)
|
n := firstOf(t, list)
|
||||||
if n["kind"] != "note.changed" || n["note_id"] != noteID {
|
if n["kind"] != "note.changed" || n["note_id"] != noteID {
|
||||||
@@ -132,8 +140,9 @@ func TestEditingASharedNoteNotifiesTheOthers(t *testing.T) {
|
|||||||
t.Fatal("ret noten igen")
|
t.Fatal("ret noten igen")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if list, unread := notifications(t, other); len(list) != 1 || unread != 1 {
|
if list, unread := notifications(t, other); len(list) != 2 || unread != 2 {
|
||||||
t.Errorf("tre gemninger blev til %d beskeder, want 1", len(list))
|
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.
|
// 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 {
|
map[string]any{"body": "# Aftale om levering\nefter læsningen"}); resp.StatusCode != http.StatusOK {
|
||||||
t.Fatal("ret noten efter læsningen")
|
t.Fatal("ret noten efter læsningen")
|
||||||
}
|
}
|
||||||
if list, unread := notifications(t, other); len(list) != 2 || unread != 1 {
|
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 2 og 1", len(list), unread)
|
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.
|
// 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 {
|
map[string]any{"pinned": true}); resp.StatusCode != http.StatusOK {
|
||||||
t.Fatal("stjernemarkér")
|
t.Fatal("stjernemarkér")
|
||||||
}
|
}
|
||||||
if list, _ := notifications(t, other); len(list) != 2 {
|
if list, _ := notifications(t, other); len(list) != 3 {
|
||||||
t.Errorf("en stjernemarkering gav besked: %d beskeder, want 2", len(list))
|
t.Errorf("en stjernemarkering gav besked: %d beskeder, want 3", len(list))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.Get("/{noteID}/shares", s.handleListNoteShares)
|
||||||
r.Post("/{noteID}/shares", s.handleShareNote)
|
r.Post("/{noteID}/shares", s.handleShareNote)
|
||||||
r.Delete("/{noteID}/shares/{userID}", s.handleUnshareNote)
|
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.Get("/{noteID}", s.handleGetNote)
|
||||||
r.Patch("/{noteID}", s.handleUpdateNote)
|
r.Patch("/{noteID}", s.handleUpdateNote)
|
||||||
r.Delete("/{noteID}", s.handleDeleteNote)
|
r.Delete("/{noteID}", s.handleDeleteNote)
|
||||||
|
|||||||
+20
-1
@@ -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) Configured() bool { return s.cfg.Configured() }
|
||||||
|
|
||||||
func (s *Sender) SendInvite(ctx context.Context, to, inviterName, projectName, link string, ttl time.Duration) error {
|
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"
|
what := "verdande"
|
||||||
if projectName != "" {
|
if projectName != "" {
|
||||||
what = "projektet “" + 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
|
body := fmt.Sprintf(`Hej
|
||||||
|
|
||||||
%s har inviteret dig til %s.
|
%s har inviteret dig til %s.
|
||||||
|
|||||||
+161
-36
@@ -16,6 +16,7 @@ type Invite struct {
|
|||||||
ID string
|
ID string
|
||||||
Email string
|
Email string
|
||||||
ProjectID string // empty for an invite to the instance rather than to a project
|
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
|
Role Role
|
||||||
CreatedBy string
|
CreatedBy string
|
||||||
CreatedAt time.Time
|
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.
|
// CreateInvite issues an invite to a project and returns the token for the emailed
|
||||||
// projectID may be empty, which invites somebody to the instance rather than to a
|
// link. projectID may be empty, which invites somebody to the instance rather than
|
||||||
// particular project.
|
// to a particular project.
|
||||||
func (db *DB) CreateInvite(ctx context.Context, email, projectID string, role Role, createdBy string, ttl time.Duration) (string, *Invite, error) {
|
func (db *DB) CreateInvite(ctx context.Context, email, projectID string, role Role, createdBy string, ttl time.Duration) (string, *Invite, error) {
|
||||||
if !role.Valid() {
|
return db.createInvite(ctx, &Invite{Email: email, ProjectID: projectID, Role: role, CreatedBy: createdBy}, ttl)
|
||||||
return "", nil, fmt.Errorf("store: %q is not a role", role)
|
}
|
||||||
|
|
||||||
|
// 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()
|
token, err := auth.NewToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, err
|
return "", nil, err
|
||||||
}
|
}
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
inv := &Invite{
|
inv.ID = NewID()
|
||||||
ID: NewID(),
|
inv.Email = NormalizeEmail(inv.Email)
|
||||||
Email: NormalizeEmail(email),
|
inv.CreatedAt = now
|
||||||
ProjectID: projectID,
|
inv.ExpiresAt = now.Add(ttl)
|
||||||
Role: role,
|
|
||||||
CreatedBy: createdBy,
|
|
||||||
CreatedAt: now,
|
|
||||||
ExpiresAt: now.Add(ttl),
|
|
||||||
}
|
|
||||||
|
|
||||||
var project any
|
|
||||||
if projectID != "" {
|
|
||||||
project = projectID
|
|
||||||
}
|
|
||||||
_, err = db.ExecContext(ctx,
|
_, err = db.ExecContext(ctx,
|
||||||
`INSERT INTO invites (id, email, project_id, role, token_hash, created_by, created_at, expires_at)
|
`INSERT INTO invites (id, email, project_id, note_id, role, token_hash, created_by, created_at, expires_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
inv.ID, inv.Email, project, string(inv.Role), auth.HashToken(token),
|
inv.ID, inv.Email, nullString(inv.ProjectID), nullString(inv.NoteID),
|
||||||
|
string(inv.Role), auth.HashToken(token),
|
||||||
inv.CreatedBy, inv.CreatedAt.Unix(), inv.ExpiresAt.Unix())
|
inv.CreatedBy, inv.CreatedAt.Unix(), inv.ExpiresAt.Unix())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, err
|
return "", nil, err
|
||||||
@@ -112,14 +123,14 @@ func (db *DB) InviteByToken(ctx context.Context, token string) (*Invite, error)
|
|||||||
return nil, ErrInviteInvalid
|
return nil, ErrInviteInvalid
|
||||||
}
|
}
|
||||||
var inv Invite
|
var inv Invite
|
||||||
var projectID sql.NullString
|
var projectID, noteID sql.NullString
|
||||||
var created, expires int64
|
var created, expires int64
|
||||||
var accepted sql.NullInt64
|
var accepted sql.NullInt64
|
||||||
|
|
||||||
err := db.QueryRowContext(ctx,
|
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)).
|
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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, ErrInviteInvalid
|
return nil, ErrInviteInvalid
|
||||||
}
|
}
|
||||||
@@ -131,6 +142,7 @@ func (db *DB) InviteByToken(ctx context.Context, token string) (*Invite, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
inv.ProjectID = projectID.String
|
inv.ProjectID = projectID.String
|
||||||
|
inv.NoteID = noteID.String
|
||||||
inv.CreatedAt = time.Unix(created, 0).UTC()
|
inv.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
inv.ExpiresAt = time.Unix(expires, 0).UTC()
|
inv.ExpiresAt = time.Unix(expires, 0).UTC()
|
||||||
return &inv, nil
|
return &inv, nil
|
||||||
@@ -144,10 +156,16 @@ type PendingInvite struct {
|
|||||||
Email string
|
Email string
|
||||||
ProjectID string
|
ProjectID string
|
||||||
ProjectName string
|
ProjectName string
|
||||||
Role Role
|
// NoteID is set for an invitation to a single note. The note is named by its
|
||||||
InvitedBy string
|
// id and never by its title: titles are sealed in the database, and a private
|
||||||
CreatedAt time.Time
|
// note's first line is not something an administrator's user list should
|
||||||
ExpiresAt time.Time
|
// 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.
|
// ListPendingInvites returns invites that are neither accepted nor expired.
|
||||||
@@ -159,7 +177,8 @@ type PendingInvite struct {
|
|||||||
// able to say which it is.
|
// able to say which it is.
|
||||||
func (db *DB) ListPendingInvites(ctx context.Context) ([]PendingInvite, error) {
|
func (db *DB) ListPendingInvites(ctx context.Context) ([]PendingInvite, error) {
|
||||||
rows, err := db.QueryContext(ctx, `
|
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
|
COALESCE(u.name, ''), i.created_at, i.expires_at
|
||||||
FROM invites i
|
FROM invites i
|
||||||
LEFT JOIN projects p ON p.id = i.project_id
|
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() {
|
for rows.Next() {
|
||||||
var p PendingInvite
|
var p PendingInvite
|
||||||
var created, expires int64
|
var created, expires int64
|
||||||
if err := rows.Scan(&p.ID, &p.Email, &p.ProjectID, &p.ProjectName, &p.Role,
|
if err := rows.Scan(&p.ID, &p.Email, &p.ProjectID, &p.ProjectName, &p.NoteID,
|
||||||
&p.InvitedBy, &created, &expires); err != nil {
|
&p.Role, &p.InvitedBy, &created, &expires); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
p.CreatedAt = time.Unix(created, 0).UTC()
|
p.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
@@ -200,12 +219,16 @@ func (db *DB) DeleteInvite(ctx context.Context, inviteID string) error {
|
|||||||
return nil
|
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
|
// 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.
|
// 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
|
// 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 {
|
func (db *DB) AcceptInvite(ctx context.Context, inviteID, userID string) error {
|
||||||
return db.Tx(ctx, func(tx *sql.Tx) error {
|
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
res, err := tx.ExecContext(ctx,
|
res, err := tx.ExecContext(ctx,
|
||||||
@@ -218,13 +241,40 @@ func (db *DB) AcceptInvite(ctx context.Context, inviteID, userID string) error {
|
|||||||
return ErrInviteInvalid
|
return ErrInviteInvalid
|
||||||
}
|
}
|
||||||
|
|
||||||
var projectID sql.NullString
|
var projectID, noteID sql.NullString
|
||||||
var role Role
|
var role Role
|
||||||
|
var invitedBy string
|
||||||
if err := tx.QueryRowContext(ctx,
|
if err := tx.QueryRowContext(ctx,
|
||||||
`SELECT project_id, role FROM invites WHERE id = ?`, inviteID).
|
`SELECT project_id, note_id, role, created_by FROM invites WHERE id = ?`, inviteID).
|
||||||
Scan(&projectID, &role); err != nil {
|
Scan(&projectID, ¬eID, &role, &invitedBy); err != nil {
|
||||||
return err
|
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 == "" {
|
if !projectID.Valid || projectID.String == "" {
|
||||||
return nil // an invite to the instance, with no project attached
|
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 ----------------------------------------------------------
|
// --- password resets ----------------------------------------------------------
|
||||||
|
|
||||||
// CreatePasswordReset invalidates any outstanding reset for the user before issuing
|
// CreatePasswordReset invalidates any outstanding reset for the user before issuing
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -75,7 +75,12 @@ func (db *DB) ListNotifications(ctx context.Context, userID string, limit int) (
|
|||||||
FROM notifications n
|
FROM notifications n
|
||||||
LEFT JOIN users u ON u.id = n.actor_id
|
LEFT JOIN users u ON u.id = n.actor_id
|
||||||
WHERE n.user_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)
|
LIMIT ?`, userID, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -454,7 +454,12 @@ export const api = {
|
|||||||
// path. Owner only; the list call also returns who it can still be shared with.
|
// path. Owner only; the list call also returns who it can still be shared with.
|
||||||
noteShares: (id) => get(`/notes/${id}/shares`),
|
noteShares: (id) => get(`/notes/${id}/shares`),
|
||||||
shareNote: (id, userId, role) => post(`/notes/${id}/shares`, { user_id: userId, role }),
|
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}`),
|
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
|
// 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.
|
// there is no instance-wide registration behind these the way Gmail has one.
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
const KINDS = {
|
const KINDS = {
|
||||||
assigned: 'notif.assigned',
|
assigned: 'notif.assigned',
|
||||||
'note.changed': 'notif.noteChanged',
|
'note.changed': 'notif.noteChanged',
|
||||||
|
'note.shared': 'notif.noteShared',
|
||||||
comment: 'notif.comment'
|
comment: 'notif.comment'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const da = {
|
|||||||
'notif.markAllRead': 'Markér alle som læst',
|
'notif.markAllRead': 'Markér alle som læst',
|
||||||
'notif.assigned': '{name} gav dig en opgave',
|
'notif.assigned': '{name} gav dig en opgave',
|
||||||
'notif.noteChanged': '{name} rettede en note',
|
'notif.noteChanged': '{name} rettede en note',
|
||||||
|
'notif.noteShared': '{name} delte en note med dig',
|
||||||
'notif.comment': '{name} kommenterede',
|
'notif.comment': '{name} kommenterede',
|
||||||
'notif.justNow': 'lige nu',
|
'notif.justNow': 'lige nu',
|
||||||
'notif.minutes': '{n} min. siden',
|
'notif.minutes': '{n} min. siden',
|
||||||
@@ -50,6 +51,17 @@ export const da = {
|
|||||||
'notes.shareFollows': 'Følger med:',
|
'notes.shareFollows': 'Følger med:',
|
||||||
'notes.shareVia': 'følger med “{note}”',
|
'notes.shareVia': 'følger med “{note}”',
|
||||||
'notes.unsharedPerson': '{name} kan ikke længere se noten.',
|
'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': '[email protected]',
|
||||||
|
'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.shareOwnerOnly': 'Kun ejeren kan dele noten.',
|
||||||
'notes.save': 'Gem',
|
'notes.save': 'Gem',
|
||||||
'notes.format': 'Formatér',
|
'notes.format': 'Formatér',
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const en = {
|
|||||||
'notif.markAllRead': 'Mark all as read',
|
'notif.markAllRead': 'Mark all as read',
|
||||||
'notif.assigned': '{name} gave you a task',
|
'notif.assigned': '{name} gave you a task',
|
||||||
'notif.noteChanged': '{name} edited a note',
|
'notif.noteChanged': '{name} edited a note',
|
||||||
|
'notif.noteShared': '{name} shared a note with you',
|
||||||
'notif.comment': '{name} commented',
|
'notif.comment': '{name} commented',
|
||||||
'notif.justNow': 'just now',
|
'notif.justNow': 'just now',
|
||||||
'notif.minutes': '{n} min ago',
|
'notif.minutes': '{n} min ago',
|
||||||
@@ -47,6 +48,17 @@ export const en = {
|
|||||||
'notes.shareFollows': 'Comes along:',
|
'notes.shareFollows': 'Comes along:',
|
||||||
'notes.shareVia': 'came along with “{note}”',
|
'notes.shareVia': 'came along with “{note}”',
|
||||||
'notes.unsharedPerson': '{name} can no longer see the 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': '[email protected]',
|
||||||
|
'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.shareOwnerOnly': 'Only the owner can share the note.',
|
||||||
'notes.save': 'Save',
|
'notes.save': 'Save',
|
||||||
'notes.format': 'Format',
|
'notes.format': 'Format',
|
||||||
|
|||||||
@@ -199,6 +199,14 @@
|
|||||||
let sharePick = $state('');
|
let sharePick = $state('');
|
||||||
let shareRole = $state('viewer');
|
let shareRole = $state('viewer');
|
||||||
let shareBusy = $state(false);
|
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);
|
const ownsSelected = $derived(!!selected && selected.created_by === app.user?.id);
|
||||||
|
|
||||||
// The whole of sharing — a project and named people — lives in a popover behind
|
// The whole of sharing — a project and named people — lives in a popover behind
|
||||||
@@ -228,6 +236,9 @@
|
|||||||
shares = [];
|
shares = [];
|
||||||
shareCandidates = [];
|
shareCandidates = [];
|
||||||
shareFollows = [];
|
shareFollows = [];
|
||||||
|
shareInvites = [];
|
||||||
|
shareEmail = '';
|
||||||
|
shareInviteLink = '';
|
||||||
sharePick = '';
|
sharePick = '';
|
||||||
if (!id || selected.created_by !== app.user?.id) return;
|
if (!id || selected.created_by !== app.user?.id) return;
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -238,6 +249,7 @@
|
|||||||
shares = r.shares ?? [];
|
shares = r.shares ?? [];
|
||||||
shareCandidates = r.candidates ?? [];
|
shareCandidates = r.candidates ?? [];
|
||||||
shareFollows = r.follows ?? [];
|
shareFollows = r.follows ?? [];
|
||||||
|
shareInvites = r.invites ?? [];
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
return () => {
|
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) {
|
async function changeShareRole(userId, role) {
|
||||||
try {
|
try {
|
||||||
await api.shareNote(selected.id, userId, role);
|
await api.shareNote(selected.id, userId, role);
|
||||||
@@ -1083,6 +1143,25 @@
|
|||||||
>{i < shareFollows.length - 1 ? ', ' : ''}{/each}
|
>{i < shareFollows.length - 1 ? ', ' : ''}{/each}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if shareInvites.length}
|
||||||
|
<!-- Sendt, ikke taget imod endnu. Adressen er alt, hvad der findes
|
||||||
|
om personen indtil de dukker op — der er hverken navn eller
|
||||||
|
farve at vise, og det er selve pointen med linjen. -->
|
||||||
|
<ul class="invited">
|
||||||
|
{#each shareInvites as inv (inv.id)}
|
||||||
|
<li>
|
||||||
|
<span class="who">{inv.email}</span>
|
||||||
|
<span class="pending">{t('notes.invitePending')}</span>
|
||||||
|
<button
|
||||||
|
class="unshare"
|
||||||
|
onclick={() => revokeInvite(inv.id, inv.email)}
|
||||||
|
title={t('notes.inviteRevoke')}
|
||||||
|
aria-label={t('notes.inviteRevoke')}>×</button
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
{#if shareCandidates.length}
|
{#if shareCandidates.length}
|
||||||
<div class="addshare">
|
<div class="addshare">
|
||||||
<select bind:value={sharePick}>
|
<select bind:value={sharePick}>
|
||||||
@@ -1104,6 +1183,40 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
<!-- Og den, der ikke står i listen. Ét felt til begge udfald: har
|
||||||
|
adressen en konto, bliver det en deling med det samme, og ellers
|
||||||
|
en invitation. Hvem der har en konto, er ikke noget, man skal
|
||||||
|
vide for at dele en note. -->
|
||||||
|
<form
|
||||||
|
class="addshare byemail"
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
inviteByEmail();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
bind:value={shareEmail}
|
||||||
|
type="email"
|
||||||
|
placeholder={t('notes.shareEmailPlaceholder')}
|
||||||
|
aria-label={t('notes.shareByEmail')}
|
||||||
|
/>
|
||||||
|
<select bind:value={shareRole} aria-label={t('notes.shareRole')}>
|
||||||
|
<option value="viewer">{t('notes.shareRoleViewer')}</option>
|
||||||
|
<option value="editor">{t('notes.shareRoleEditor')}</option>
|
||||||
|
</select>
|
||||||
|
<button class="button" type="submit" disabled={!shareEmail || shareBusy}>
|
||||||
|
{t('notes.shareInvite')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{#if shareInviteLink}
|
||||||
|
<!-- Ingen post at sende det med. Så er linket det eneste, der
|
||||||
|
findes, og det skal kunne læses og kopieres frem for at blive
|
||||||
|
lovet i en besked, der ikke kom af sted. -->
|
||||||
|
<p class="invitelink">
|
||||||
|
{t('notes.inviteNoMail')}
|
||||||
|
<input readonly value={shareInviteLink} onclick={(e) => e.target.select()} />
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1982,6 +2095,63 @@
|
|||||||
text-decoration: underline;
|
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 {
|
.addshare {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user