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