Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0dc4c2f9b8 | ||
|
|
7f09b35db5 | ||
|
|
0f4048d8f2 | ||
|
|
9b21b3eac5 | ||
|
|
a39f655565 |
+4
-3
@@ -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` `PATCH` `DELETE` | `/projects/{id}` | Read, change, trash. Owner only for the last two. |
|
||||||
| `GET` `POST` | `/projects/{id}/sections` | |
|
| `GET` `POST` | `/projects/{id}/sections` | |
|
||||||
| `PATCH` `DELETE` | `/sections/{id}` | |
|
| `PATCH` `DELETE` | `/sections/{id}` | |
|
||||||
| `GET` | `/projects/{id}/members` | |
|
| `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. `{"email": …, "role": "editor"\|"viewer"}` |
|
| `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}/members/{userID}` | |
|
| `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` | `/projects/{id}/activity` | |
|
||||||
| `GET` | `/trash/projects` | What you have deleted and can still bring back, with how long is left. |
|
| `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. |
|
| `POST` | `/trash/projects/{id}/restore` | Brings it back with the tasks that went with it. |
|
||||||
|
|||||||
+5
-2
@@ -40,8 +40,11 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
|
|||||||
it is due.
|
it is due.
|
||||||
- **Drag where it means something** — reorder a list or a board, file a project
|
- **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.
|
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
|
- **Sharing** with owner, editor and viewer roles. Somebody who already has an
|
||||||
joins through an invite link.
|
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.
|
- **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
|
- **[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.
|
what you share, pushed to your devices and counted on the app's icon in the Dock.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ the app's icon.
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Somebody edited a note shared with you | Repeat edits fold into one line while it is unread |
|
| Somebody edited a note shared with you | Repeat edits fold into one line while it is unread |
|
||||||
| Somebody shared a note with you | Once, when it happens |
|
| Somebody shared a note with you | Once, when it happens |
|
||||||
|
| Somebody shared a project with you | Once, when it happens — the entry opens the project, and its share panel has a *Leave the project* button |
|
||||||
| Somebody named you in a note with `@` | Instead of "edited a note" — one act, one message |
|
| Somebody named you in a note with `@` | Instead of "edited a note" — one act, one message |
|
||||||
| Somebody gave you a task | Only on the change, and only to the person who got it |
|
| Somebody gave you a task | Only on the change, and only to the person who got it |
|
||||||
| Somebody commented | |
|
| Somebody commented | |
|
||||||
|
|||||||
+76
-5
@@ -2307,7 +2307,15 @@ paths:
|
|||||||
- { name: projectID, in: path, required: true, schema: { type: string } }
|
- { name: projectID, in: path, required: true, schema: { type: string } }
|
||||||
get:
|
get:
|
||||||
tags: [projects]
|
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:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: OK
|
description: OK
|
||||||
@@ -2319,6 +2327,17 @@ paths:
|
|||||||
members:
|
members:
|
||||||
type: array
|
type: array
|
||||||
items: { $ref: "#/components/schemas/Member" }
|
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" }
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
|
||||||
/api/v1/projects/{projectID}/members/{userID}:
|
/api/v1/projects/{projectID}/members/{userID}:
|
||||||
@@ -2360,8 +2379,19 @@ paths:
|
|||||||
"422": { $ref: "#/components/responses/Validation" }
|
"422": { $ref: "#/components/responses/Validation" }
|
||||||
delete:
|
delete:
|
||||||
tags: [projects]
|
tags: [projects]
|
||||||
summary: Remove somebody from a project. Its owner only.
|
summary: Remove somebody from a project — its owner, or yourself.
|
||||||
description: |
|
description: |
|
||||||
|
Two things through one route: the owner taking somebody off, and anybody
|
||||||
|
taking themselves off. The second is what makes a share something you can
|
||||||
|
undo. You are let into a project without being asked, so a way out that
|
||||||
|
does not go through asking its owner is the other half of that.
|
||||||
|
|
||||||
|
The owner cannot leave their own project. That is a transfer of ownership,
|
||||||
|
not an exit, and there is no transfer yet.
|
||||||
|
|
||||||
|
Pointing at somebody else's membership without owning the project answers
|
||||||
|
404 rather than 403: saying "you may not" confirms the membership exists.
|
||||||
|
|
||||||
Their tasks are unassigned rather than left pointing at somebody who can
|
Their tasks are unassigned rather than left pointing at somebody who can
|
||||||
no longer see the project — which is why changing a role is a PATCH and
|
no longer see the project — which is why changing a role is a PATCH and
|
||||||
not a remove-and-reinvite.
|
not a remove-and-reinvite.
|
||||||
@@ -2384,13 +2414,32 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
required: [email, role]
|
|
||||||
properties:
|
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] }
|
role: { type: string, enum: [editor, viewer] }
|
||||||
responses:
|
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":
|
"201":
|
||||||
description: Created
|
description: Invited. No account yet, so a link was made.
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
@@ -2399,8 +2448,30 @@ paths:
|
|||||||
link: { type: string }
|
link: { type: string }
|
||||||
emailed: { type: boolean }
|
emailed: { 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/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:
|
/api/v1/projects/{projectID}/activity:
|
||||||
parameters:
|
parameters:
|
||||||
- { name: projectID, in: path, required: true, schema: { type: string } }
|
- { name: projectID, in: path, required: true, schema: { type: string } }
|
||||||
|
|||||||
@@ -8,6 +8,38 @@ What the first account is created with. Nothing is emailed, nothing is registere
|
|||||||
anywhere: the first person to open a fresh instance creates the administrator
|
anywhere: the first person to open a fresh instance creates the administrator
|
||||||
account, and after that people arrive by invite link.
|
account, and after that people arrive by invite link.
|
||||||
|
|
||||||
|
## Being let into a project
|
||||||
|
|
||||||
|
Two different situations, one panel — **the project's ⋯ menu → Share**.
|
||||||
|
|
||||||
|
Somebody who **already has an account here** you pick from a list by name. You do
|
||||||
|
not need to know their email address to share a project with a colleague whose
|
||||||
|
name you know. The accounts on an instance are a closed set — there is no open
|
||||||
|
signup, every one of them was invited — so listing them to each other is the
|
||||||
|
address book the feature needs, not a disclosure.
|
||||||
|
|
||||||
|
Somebody who is **not here yet** you invite by email. If the address turns out to
|
||||||
|
belong to an account it becomes an ordinary share straight away; otherwise it
|
||||||
|
becomes a link that creates their account and their membership at once. With no
|
||||||
|
mail server configured the link is shown in the panel instead of being sent, which
|
||||||
|
is the correct behaviour on a one-person instance rather than an error.
|
||||||
|
|
||||||
|
**You are told, and you can leave.** Being added to a project is not something
|
||||||
|
that happens to you quietly: the bell says who shared what, and the entry takes
|
||||||
|
you to the project. In the share panel, anybody who is not the owner has a *Leave
|
||||||
|
the project* button. That is deliberate rather than an approval step — an
|
||||||
|
invitation waiting on a yes means the person who shared thinks it is done while
|
||||||
|
the work sits still. What was wrong was the other three-quarters of it: you were
|
||||||
|
added without being asked, without being told, and with no way out except asking
|
||||||
|
the owner. The owner cannot leave their own project; that is a transfer of
|
||||||
|
ownership, and there is no transfer yet.
|
||||||
|
|
||||||
|
An invitation that has not been taken up stays listed under the members, greyed,
|
||||||
|
with a × to withdraw it. Without that it is invisible — the person has no account,
|
||||||
|
so they are neither a member nor somebody you can pick — and the usual next move
|
||||||
|
is to send it a second time. The second link works exactly as well as the first,
|
||||||
|
and then there are two to keep track of.
|
||||||
|
|
||||||
## A code on top of it
|
## A code on top of it
|
||||||
|
|
||||||
**Settings → Account → Two-factor.** An ordinary TOTP secret — any authenticator
|
**Settings → Account → Two-factor.** An ordinary TOTP secret — any authenticator
|
||||||
|
|||||||
@@ -140,6 +140,30 @@ func (s *Server) notifyNoteShared(r *http.Request, n *store.Note, userID string)
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// notifyProjectShared tells somebody that a project is now theirs to work in.
|
||||||
|
//
|
||||||
|
// Indtil nu skete det i tavshed. En note delt med dig gav en besked; et projekt gav
|
||||||
|
// ingenting — det stod bare i sidebjælken næste gang, du kiggede, uden at noget
|
||||||
|
// sagde hvornår eller fra hvem. Et projekt er mere end en note: der kan lægges
|
||||||
|
// opgaver til dig i det.
|
||||||
|
//
|
||||||
|
// Én besked, når delingen sker. En rolle, der ændres bagefter, er ikke et nyt
|
||||||
|
// projekt at åbne, og en invitation, der bliver taget imod, er modtagerens egen
|
||||||
|
// handling — de skal ikke have besked om det, de lige selv gjorde.
|
||||||
|
func (s *Server) notifyProjectShared(r *http.Request, project *store.Project, userID string) {
|
||||||
|
if project == nil || userID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actor := userFrom(r.Context())
|
||||||
|
if actor.ID == userID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.notify(r, &store.Notification{
|
||||||
|
UserID: userID, ActorID: actor.ID, ProjectID: project.ID,
|
||||||
|
Kind: "project.shared", Title: actor.Name + " delte et projekt med dig", Body: project.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// notifyNoteChanged tells everybody who can see a note that somebody else wrote in
|
// 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.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -452,22 +452,100 @@ type memberJSON struct {
|
|||||||
Role string `json:"role"`
|
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) {
|
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 {
|
if err != nil {
|
||||||
s.internal(w, r, "list members", err)
|
s.internal(w, r, "list members", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := make([]memberJSON, 0, len(members))
|
out := make([]memberJSON, 0, len(members))
|
||||||
|
already := map[string]bool{}
|
||||||
for _, m := range members {
|
for _, m := range members {
|
||||||
out = append(out, memberJSON{m.UserID, m.Email, m.Name, m.AvatarColor, string(m.Role)})
|
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 {
|
type inviteRequest struct {
|
||||||
Email string `json:"email"`
|
// UserID er den, man har peget på i listen. Email er den, man kender adressen
|
||||||
Role string `json:"role"`
|
// 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 {
|
type inviteResponse struct {
|
||||||
@@ -478,8 +556,14 @@ type inviteResponse struct {
|
|||||||
Emailed bool `json:"emailed"`
|
Emailed bool `json:"emailed"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleInvite shares a project with somebody. An existing user is added directly;
|
// handleInvite shares a project with somebody.
|
||||||
// anybody else gets a link that creates their account and their membership at once.
|
//
|
||||||
|
// 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) {
|
func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
||||||
var req inviteRequest
|
var req inviteRequest
|
||||||
if err := decodeJSON(w, r, &req); err != nil {
|
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"})
|
writeFieldErrors(w, map[string]string{"role": "must be editor or viewer"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
email := store.NormalizeEmail(req.Email)
|
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"})
|
writeFieldErrors(w, map[string]string{"email": "must be an email address"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -508,19 +597,50 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Somebody who already has an account is added straight away: sending them
|
// En adresse, der viser sig at høre til en konto, ER den konto: at sende dem
|
||||||
// through a signup form they cannot complete would be a dead end.
|
// gennem en oprettelsesside, de ikke kan komme igennem, ville være en
|
||||||
if existing, err := s.db.UserByEmail(r.Context(), email); err == nil {
|
// blindgyde. Derfor slås adressen op først, og de to veje mødes her.
|
||||||
if existing.ID == project.OwnerID {
|
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")
|
writeError(w, http.StatusConflict, CodeConflict, "that person already owns this project")
|
||||||
return
|
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)
|
s.internal(w, r, "add member", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.activity(r, projectID, "", "member.added", map[string]any{"email": email, "role": string(role)})
|
s.activity(r, projectID, "", "member.added", map[string]any{"name": person.Name, "role": string(role)})
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"added": true})
|
// Og de får det at vide. Før stod projektet bare i sidebjælken næste gang,
|
||||||
|
// de kiggede — uden at noget sagde hvornår eller fra hvem.
|
||||||
|
s.notifyProjectShared(r, project, person.ID)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"added": true,
|
||||||
|
// Personen sendes med retur, fordi fladen kan have delt med en adresse
|
||||||
|
// og derfor ikke selv ved, hvem den ramte.
|
||||||
|
"user": personJSON{ID: person.ID, Name: person.Name, AvatarColor: person.AvatarColor},
|
||||||
|
"role": string(role),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Et andet link til den samme indbakke hjælper ingen: det første virker
|
||||||
|
// stadig, og nu er der to at holde styr på og to at trække tilbage.
|
||||||
|
if pending, err := s.db.PendingProjectInvite(r.Context(), projectID, email); err == nil && pending {
|
||||||
|
writeError(w, http.StatusConflict, CodeConflict, "that address has already been invited")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,15 +710,33 @@ func (s *Server) handleSetMemberRole(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleRemoveMember takes somebody off a project — the owner removing anybody, or
|
||||||
|
// anybody removing themselves.
|
||||||
|
//
|
||||||
|
// Den anden halvdel er ny, og den er grunden til at ruten er flyttet ud af
|
||||||
|
// ejer-gruppen. Man blev lukket ind i et projekt uden at blive spurgt, og kunne så
|
||||||
|
// ikke komme ud igen: kun ejeren kunne fjerne en, også en selv. To af de tre ting
|
||||||
|
// kan man leve med; alle tre er det, der gør en deling til noget, der sker for en.
|
||||||
|
//
|
||||||
|
// Ejeren kan stadig ikke gå fra sit eget projekt. Det er ikke en udgang, det er en
|
||||||
|
// overdragelse, og den findes ikke endnu.
|
||||||
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||||
projectID := chi.URLParam(r, "projectID")
|
projectID := chi.URLParam(r, "projectID")
|
||||||
userID := chi.URLParam(r, "userID")
|
userID := chi.URLParam(r, "userID")
|
||||||
|
me := userFrom(r.Context())
|
||||||
|
|
||||||
owner, err := s.db.ProjectOwner(r.Context(), projectID)
|
owner, err := s.db.ProjectOwner(r.Context(), projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, r, "project owner", err)
|
s.storeError(w, r, "project owner", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 404 og ikke 403: at sige "det må du ikke" til en, der peger på en anden
|
||||||
|
// persons medlemskab, bekræfter at medlemskabet findes.
|
||||||
|
if me.ID != owner && userID != me.ID {
|
||||||
|
writeError(w, http.StatusNotFound, CodeNotFound, "no such member")
|
||||||
|
return
|
||||||
|
}
|
||||||
if userID == owner {
|
if userID == owner {
|
||||||
writeError(w, http.StatusConflict, CodeConflict,
|
writeError(w, http.StatusConflict, CodeConflict,
|
||||||
"the owner cannot be removed from their own project")
|
"the owner cannot be removed from their own project")
|
||||||
|
|||||||
@@ -486,3 +486,239 @@ func TestASectionHoldsMoreThanOneTask(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Et projekt kunne kun deles ved at stave en e-mailadresse. Det er den forkerte
|
||||||
|
// vej at spørge om en kollega, man kender navnet på og sidder i samme rum som — og
|
||||||
|
// på en instans, hvor konti kun bliver til ved invitation, er der ikke noget at
|
||||||
|
// beskytte ved at lade være med at vise dem.
|
||||||
|
func TestAProjectCanBeSharedWithSomebodyWhoIsAlreadyHere(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
ts.newUser(t, "anden@example.dk", "Anden")
|
||||||
|
|
||||||
|
anden, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
|
||||||
|
projectID := project["id"].(string)
|
||||||
|
|
||||||
|
// Først står de i listen over dem, der kan tilføjes.
|
||||||
|
_, before := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
|
||||||
|
if !hasPerson(before["candidates"], anden.ID) {
|
||||||
|
t.Fatalf("Anden er ikke blandt kandidaterne: %v", before["candidates"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delt ved at pege, ikke ved at stave.
|
||||||
|
resp, added := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
|
||||||
|
map[string]any{"user_id": anden.ID, "role": "editor"})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status %d, body %v", resp.StatusCode, added)
|
||||||
|
}
|
||||||
|
// Personen kommer med retur, så fladen kan tegne rækken uden at spørge igen.
|
||||||
|
if user, _ := added["user"].(map[string]any); user["name"] != "Anden" {
|
||||||
|
t.Errorf("user = %v, want Anden", added["user"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Og bagefter er de et medlem og ikke længere en kandidat: en liste, der
|
||||||
|
// tilbyder at tilføje en, der allerede er der, er en liste, man gør det i.
|
||||||
|
_, after := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
|
||||||
|
if hasPerson(after["candidates"], anden.ID) {
|
||||||
|
t.Errorf("Anden står stadig som kandidat efter at være tilføjet")
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, raw := range after["members"].([]any) {
|
||||||
|
if m := raw.(map[string]any); m["user_id"] == anden.ID {
|
||||||
|
found = true
|
||||||
|
if m["role"] != "editor" {
|
||||||
|
t.Errorf("role = %v, want editor", m["role"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("Anden blev ikke medlem: %v", after["members"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ejeren skal ikke kunne tilbydes som en, projektet kan deles med — de har det.
|
||||||
|
func TestTheOwnerIsNotOfferedAsSomebodyToShareWith(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
me, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Mit"})
|
||||||
|
|
||||||
|
_, body := ts.do(t, "GET", "/api/v1/projects/"+project["id"].(string)+"/members", nil)
|
||||||
|
if hasPerson(body["candidates"], me.ID) {
|
||||||
|
t.Errorf("ejeren står som kandidat til sit eget projekt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// En invitation, der venter, står ved siden af medlemmerne — og kan tages tilbage.
|
||||||
|
//
|
||||||
|
// Uden den er den usynlig: personen har ingen konto, så de er hverken medlem eller
|
||||||
|
// kandidat, og så bliver invitationen sendt igen. Det andet link virker lige så
|
||||||
|
// godt som det første, og så er der to at trække tilbage i stedet for et.
|
||||||
|
func TestAPendingInviteIsVisibleAndCanBeWithdrawn(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
|
||||||
|
projectID := project["id"].(string)
|
||||||
|
|
||||||
|
resp, _ := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
|
||||||
|
map[string]any{"email": "fremmed@example.dk", "role": "viewer"})
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("invitation: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, body := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
|
||||||
|
pending, _ := body["pending"].([]any)
|
||||||
|
if len(pending) != 1 {
|
||||||
|
t.Fatalf("%d ventende invitationer, want 1: %v", len(pending), body["pending"])
|
||||||
|
}
|
||||||
|
first := pending[0].(map[string]any)
|
||||||
|
if first["email"] != "fremmed@example.dk" {
|
||||||
|
t.Errorf("email = %v", first["email"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Et andet link til den samme indbakke hjælper ingen.
|
||||||
|
if resp, _ := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
|
||||||
|
map[string]any{"email": "fremmed@example.dk", "role": "viewer"}); resp.StatusCode != http.StatusConflict {
|
||||||
|
t.Errorf("en gentaget invitation gav %d, want 409", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, _ = ts.do(t, "DELETE",
|
||||||
|
"/api/v1/projects/"+projectID+"/invites/"+first["id"].(string), nil)
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("tilbagetrækning: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, after := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
|
||||||
|
if p, _ := after["pending"].([]any); len(p) != 0 {
|
||||||
|
t.Errorf("invitationen står der stadig: %v", after["pending"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Et id, der ikke findes, må ikke blive til en medlemsrække, der peger på ingen.
|
||||||
|
func TestSharingWithSomebodyWhoDoesNotExistIsRefused(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
|
||||||
|
resp, _ := ts.do(t, "POST", "/api/v1/projects/"+project["id"].(string)+"/invites",
|
||||||
|
map[string]any{"user_id": "0198f000-0000-7000-8000-00000000dead", "role": "editor"})
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("status %d, want 404", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasPerson siger, om en liste af personer indeholder et bestemt id.
|
||||||
|
func hasPerson(raw any, id string) bool {
|
||||||
|
list, _ := raw.([]any)
|
||||||
|
for _, item := range list {
|
||||||
|
if p, ok := item.(map[string]any); ok && p["id"] == id {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Man bliver lukket ind i et projekt uden at blive spurgt. Så skal man i det
|
||||||
|
// mindste vide det — og kunne gå ud igen uden at bede ejeren om lov.
|
||||||
|
func TestBeingAddedToAProjectIsToldAndCanBeUndone(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
anden := ts.newUser(t, "anden@example.dk", "Anden")
|
||||||
|
|
||||||
|
andenUser, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Fælleshuset"})
|
||||||
|
projectID := project["id"].(string)
|
||||||
|
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
|
||||||
|
map[string]any{"user_id": andenUser.ID, "role": "editor"})
|
||||||
|
|
||||||
|
// Beskeden. Før stod projektet bare i sidebjælken næste gang, de kiggede.
|
||||||
|
_, inbox := anden.do(t, "GET", "/api/v1/notifications", nil)
|
||||||
|
var shared map[string]any
|
||||||
|
for _, raw := range inbox["notifications"].([]any) {
|
||||||
|
if n := raw.(map[string]any); n["kind"] == "project.shared" {
|
||||||
|
shared = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if shared == nil {
|
||||||
|
t.Fatalf("ingen besked om delingen: %v", inbox["notifications"])
|
||||||
|
}
|
||||||
|
if shared["project_id"] != projectID {
|
||||||
|
t.Errorf("project_id = %v, want %s — uden det fører beskeden ingen steder hen",
|
||||||
|
shared["project_id"], projectID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Udgangen.
|
||||||
|
resp, _ := anden.do(t, "DELETE", "/api/v1/projects/"+projectID+"/members/"+andenUser.ID, nil)
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("forlad projektet: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if resp, _ := anden.do(t, "GET", "/api/v1/projects/"+projectID, nil); resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("projektet er stadig synligt efter at være forladt: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// En udgang for en selv er ikke en dør til de andres medlemskaber.
|
||||||
|
func TestAMemberCannotRemoveAnotherMember(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
en := ts.newUser(t, "en@example.dk", "En")
|
||||||
|
ts.newUser(t, "to@example.dk", "To")
|
||||||
|
|
||||||
|
enUser, err := ts.db.UserByEmail(t.Context(), "en@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
toUser, err := ts.db.UserByEmail(t.Context(), "to@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
|
||||||
|
projectID := project["id"].(string)
|
||||||
|
for _, u := range []string{enUser.ID, toUser.ID} {
|
||||||
|
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
|
||||||
|
map[string]any{"user_id": u, "role": "editor"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 404 og ikke 403: "det må du ikke" bekræfter, at medlemskabet findes.
|
||||||
|
resp, _ := en.do(t, "DELETE", "/api/v1/projects/"+projectID+"/members/"+toUser.ID, nil)
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("status %d, want 404", resp.StatusCode)
|
||||||
|
}
|
||||||
|
_, members := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
|
||||||
|
if len(members["members"].([]any)) != 3 {
|
||||||
|
t.Errorf("%d medlemmer tilbage, want 3", len(members["members"].([]any)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ejeren kan ikke gå fra sit eget projekt. Det ville være en overdragelse.
|
||||||
|
func TestTheOwnerCannotLeaveTheirOwnProject(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
me, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Mit"})
|
||||||
|
|
||||||
|
resp, _ := ts.do(t, "DELETE",
|
||||||
|
"/api/v1/projects/"+project["id"].(string)+"/members/"+me.ID, nil)
|
||||||
|
if resp.StatusCode != http.StatusConflict {
|
||||||
|
t.Errorf("status %d, want 409", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -493,6 +493,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Get("/", s.handleGetProject)
|
r.Get("/", s.handleGetProject)
|
||||||
r.Get("/sections", s.handleListSections)
|
r.Get("/sections", s.handleListSections)
|
||||||
r.Get("/members", s.handleListMembers)
|
r.Get("/members", s.handleListMembers)
|
||||||
|
// Uden for ejer-gruppen, fordi den nu dækker to ting: ejeren, der
|
||||||
|
// fjerner nogen, og den, der selv går. Handleren skiller dem ad.
|
||||||
|
r.Delete("/members/{userID}", s.handleRemoveMember)
|
||||||
r.Get("/activity", s.handleActivity)
|
r.Get("/activity", s.handleActivity)
|
||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
@@ -506,8 +509,8 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Patch("/", s.handleUpdateProject)
|
r.Patch("/", s.handleUpdateProject)
|
||||||
r.Delete("/", s.handleDeleteProject)
|
r.Delete("/", s.handleDeleteProject)
|
||||||
r.Post("/invites", s.handleInvite)
|
r.Post("/invites", s.handleInvite)
|
||||||
|
r.Delete("/invites/{inviteID}", s.handleDeleteProjectInvite)
|
||||||
r.Patch("/members/{userID}", s.handleSetMemberRole)
|
r.Patch("/members/{userID}", s.handleSetMemberRole)
|
||||||
r.Delete("/members/{userID}", s.handleRemoveMember)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -354,6 +354,89 @@ func (db *DB) DeleteNoteInvite(ctx context.Context, noteID, inviteID string) err
|
|||||||
return nil
|
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
|
// 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.
|
// 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) {
|
func (db *DB) PendingNoteInvite(ctx context.Context, noteID, email string) (bool, error) {
|
||||||
|
|||||||
+121
-1
@@ -1104,7 +1104,9 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
|
|||||||
|
|
||||||
await projectAction(page, 'Del');
|
await projectAction(page, 'Del');
|
||||||
await page.getByLabel('Inviter via e-mail').fill('andreas@example.dk');
|
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();
|
await page.getByRole('button', { name: 'Inviter' }).click();
|
||||||
const link = await page.locator('.link-out code').textContent();
|
const link = await page.locator('.link-out code').textContent();
|
||||||
|
|
||||||
@@ -4515,3 +4517,121 @@ test('en indstilling kan findes ved at søge efter den', async ({ page }) => {
|
|||||||
|
|
||||||
expect(trouble).toEqual([]);
|
expect(trouble).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Et projekt kan deles med en, der allerede er her.
|
||||||
|
*
|
||||||
|
* Indtil nu kunne det kun deles ved at stave en e-mailadresse — altså ved at kende
|
||||||
|
* adressen på en kollega, man kender navnet på og sidder i samme rum som. Konti på
|
||||||
|
* en instans bliver kun til ved invitation, så der er ikke noget at beskytte ved at
|
||||||
|
* lade være med at vise dem for hinanden; det er adressebogen, funktionen har brug
|
||||||
|
* for.
|
||||||
|
*
|
||||||
|
* Prøven laver selv sin anden person frem for at låne den fra prøven ovenfor. Den
|
||||||
|
* kobling så jeg først, da jeg kørte den her alene: basen tømmes for hver kørsel,
|
||||||
|
* så en prøve, der kun består, når hele filen kører i rækkefølge, består ikke af
|
||||||
|
* sine egne grunde.
|
||||||
|
*
|
||||||
|
* Og turen forbi invitationen er ikke spild. Den viser skiftet: den samme person er
|
||||||
|
* først en ventende adresse, og bagefter et navn, der kan vælges.
|
||||||
|
*/
|
||||||
|
test('et projekt kan deles med en, der allerede har en konto', async ({ browser, page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||||
|
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||||
|
await sidebar.getByLabel('Projektnavn').fill('Vinterhaven');
|
||||||
|
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Vinterhaven' })).toBeVisible();
|
||||||
|
|
||||||
|
// Først den, der ikke er her endnu. Adressen er den eneste vej til dem.
|
||||||
|
await projectAction(page, 'Del');
|
||||||
|
await page.getByLabel('Inviter via e-mail').fill('gartner@example.dk');
|
||||||
|
await page.getByRole('button', { name: 'Inviter' }).click();
|
||||||
|
|
||||||
|
// Og de bliver stående synligt, indtil de kommer — ellers er en invitation
|
||||||
|
// sendt i går usynlig, og så bliver den sendt igen.
|
||||||
|
const invited = page.locator('.members.invited');
|
||||||
|
await expect(invited.getByText('gartner@example.dk')).toBeVisible();
|
||||||
|
await expect(invited).toContainText('Inviteret, ikke kommet endnu');
|
||||||
|
|
||||||
|
const link = await page.locator('.link-out code').textContent();
|
||||||
|
expect(link).toContain('/invite?token=');
|
||||||
|
|
||||||
|
// Tom kontekst: en inviteret, der allerede er logget ind som den, der inviterede,
|
||||||
|
// er det ene tilfælde, der aldrig sker.
|
||||||
|
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||||
|
const guest = await context.newPage();
|
||||||
|
await guest.goto(link);
|
||||||
|
await guest.getByLabel('Navn', { exact: true }).fill('Gartner');
|
||||||
|
await guest.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
||||||
|
await guest.getByRole('button', { name: 'Opret konto' }).click();
|
||||||
|
await expect(guest.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||||
|
await context.close();
|
||||||
|
|
||||||
|
// Nu er de her. Et nyt projekt, og den samme person er blevet et navn, man vælger.
|
||||||
|
await page.goto('/');
|
||||||
|
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||||
|
await sidebar.getByLabel('Projektnavn').fill('Drivhuset');
|
||||||
|
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Drivhuset' })).toBeVisible();
|
||||||
|
|
||||||
|
await projectAction(page, 'Del');
|
||||||
|
const picker = page.locator('.addshare select').first();
|
||||||
|
await expect(picker.locator('option', { hasText: 'Gartner' })).toHaveCount(1);
|
||||||
|
|
||||||
|
await picker.selectOption({ label: 'Gartner' });
|
||||||
|
await page.getByLabel('Rolle for den valgte').selectOption('viewer');
|
||||||
|
await page.locator('.addshare button', { hasText: 'Del' }).click();
|
||||||
|
|
||||||
|
const members = page.locator('.members').first();
|
||||||
|
await expect(members.locator('li', { hasText: 'Gartner' })).toContainText('Kan kun se');
|
||||||
|
|
||||||
|
// Og bagefter er de væk fra listen: en liste, der tilbyder at tilføje en, der
|
||||||
|
// allerede er der, er en liste, man gør det i ved et uheld.
|
||||||
|
await expect(picker.locator('option', { hasText: 'Gartner' })).toHaveCount(0);
|
||||||
|
|
||||||
|
// Tilbagetrækningen af en invitation prøves i Go
|
||||||
|
// (TestAPendingInviteIsVisibleAndCanBeWithdrawn) og ikke her. Den krævede et
|
||||||
|
// spring tilbage til det første projekt, og delingspanelet står åbent på tværs
|
||||||
|
// af det spring — så klikket på "Del" lukkede det, jeg lige havde åbnet.
|
||||||
|
|
||||||
|
// Og så den anden vej: hvad Gartner selv oplever. Man bliver lukket ind i et
|
||||||
|
// projekt uden at blive spurgt, så man skal i det mindste få det at vide og
|
||||||
|
// kunne gå ud igen uden at bede ejeren om lov.
|
||||||
|
const theirs = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||||
|
const gartner = await theirs.newPage();
|
||||||
|
const gartnerTrouble = watchForTrouble(gartner);
|
||||||
|
await gartner.goto('/');
|
||||||
|
await gartner.getByLabel('E-mail').fill('gartner@example.dk');
|
||||||
|
await gartner.getByLabel('Adgangskode').fill('et langt kodeord til test');
|
||||||
|
await gartner.getByRole('button', { name: 'Log ind' }).click();
|
||||||
|
await expect(gartner.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||||
|
|
||||||
|
await gartner.getByRole('button', { name: /Beskeder/ }).click();
|
||||||
|
const bell = gartner.getByRole('dialog', { name: 'Beskeder' });
|
||||||
|
await expect(bell.getByText('delte et projekt med dig')).toBeVisible();
|
||||||
|
|
||||||
|
// Beskeden fører hen til projektet. En besked, der ikke kan følges, er en
|
||||||
|
// oplysning om noget, man selv skal gå ud og finde.
|
||||||
|
await bell.locator('.row').filter({ hasText: 'delte et projekt med dig' }).click();
|
||||||
|
await expect(gartner.getByRole('heading', { name: 'Drivhuset' })).toBeVisible();
|
||||||
|
|
||||||
|
// Udgangen. Bekræftelsen siges ja til, ellers sker der intet.
|
||||||
|
gartner.on('dialog', (d) => d.accept());
|
||||||
|
await projectAction(gartner, /^Del/);
|
||||||
|
await gartner.getByRole('button', { name: 'Forlad projektet' }).click();
|
||||||
|
|
||||||
|
// Væk fra sidebjælken, og ikke længere til at åbne.
|
||||||
|
await expect(
|
||||||
|
gartner.getByRole('navigation', { name: 'Hovedmenu' }).getByRole('link', { name: /Drivhuset/ })
|
||||||
|
).toHaveCount(0);
|
||||||
|
|
||||||
|
// En 404 her er ikke uro: den er svaret på at bede om et projekt, man lige har
|
||||||
|
// forladt, og den er hele pointen med at have forladt det.
|
||||||
|
expect(gartnerTrouble.filter((t) => !t.includes('404'))).toEqual([]);
|
||||||
|
await theirs.close();
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|||||||
+5
-1
@@ -338,7 +338,11 @@ export const api = {
|
|||||||
reorderSections: (projectId, ids) => post(`/projects/${projectId}/sections/reorder`, { ids }),
|
reorderSections: (projectId, ids) => post(`/projects/${projectId}/sections/reorder`, { ids }),
|
||||||
|
|
||||||
listMembers: (projectId) => get(`/projects/${projectId}/members`),
|
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) =>
|
setMemberRole: (projectId, userId, role) =>
|
||||||
patch(`/projects/${projectId}/members/${userId}`, { role }),
|
patch(`/projects/${projectId}/members/${userId}`, { role }),
|
||||||
removeMember: (projectId, userId) => del(`/projects/${projectId}/members/${userId}`),
|
removeMember: (projectId, userId) => del(`/projects/${projectId}/members/${userId}`),
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
assigned: 'notif.assigned',
|
assigned: 'notif.assigned',
|
||||||
'note.changed': 'notif.noteChanged',
|
'note.changed': 'notif.noteChanged',
|
||||||
'note.shared': 'notif.noteShared',
|
'note.shared': 'notif.noteShared',
|
||||||
|
'project.shared': 'notif.projectShared',
|
||||||
'note.mention': 'notif.mention',
|
'note.mention': 'notif.mention',
|
||||||
'daily.plan': 'notif.dailyPlan',
|
'daily.plan': 'notif.dailyPlan',
|
||||||
'update.available': 'notif.update',
|
'update.available': 'notif.update',
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const da = {
|
|||||||
'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.noteShared': '{name} delte en note med dig',
|
||||||
|
'notif.projectShared': '{name} delte et projekt med dig',
|
||||||
'notif.mention': '{name} nævnte dig i en note',
|
'notif.mention': '{name} nævnte dig i en note',
|
||||||
'notif.dailyPlan': 'Dagens plan',
|
'notif.dailyPlan': 'Dagens plan',
|
||||||
'notif.update': 'Der er kommet en ny version',
|
'notif.update': 'Der er kommet en ny version',
|
||||||
@@ -908,6 +909,14 @@ export const da = {
|
|||||||
'project.canEdit': 'Kan redigere',
|
'project.canEdit': 'Kan redigere',
|
||||||
'project.canView': 'Kan kun se',
|
'project.canView': 'Kan kun se',
|
||||||
'project.invite': 'Inviter',
|
'project.invite': 'Inviter',
|
||||||
|
'project.pickPerson': 'Vælg en person…',
|
||||||
|
'project.leave': 'Forlad projektet',
|
||||||
|
'project.leaveSure': 'Forlad {name}? Du mister adgangen, og kun ejeren kan lukke dig ind igen.',
|
||||||
|
'project.roleForPicked': 'Rolle for den valgte',
|
||||||
|
'project.shareWith': 'Del',
|
||||||
|
'project.orByEmail': 'Eller en, der ikke er her endnu:',
|
||||||
|
'project.invited': 'Inviteret, ikke kommet endnu',
|
||||||
|
'project.withdrawInvite': 'Træk invitationen til {email} tilbage',
|
||||||
'project.inviteByEmail': 'Inviter via e-mail',
|
'project.inviteByEmail': 'Inviter via e-mail',
|
||||||
'project.emailAddress': 'e-mailadresse',
|
'project.emailAddress': 'e-mailadresse',
|
||||||
'project.role': 'Rolle',
|
'project.role': 'Rolle',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const en = {
|
|||||||
'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.noteShared': '{name} shared a note with you',
|
||||||
|
'notif.projectShared': '{name} shared a project with you',
|
||||||
'notif.mention': '{name} mentioned you in a note',
|
'notif.mention': '{name} mentioned you in a note',
|
||||||
'notif.dailyPlan': "Today's plan",
|
'notif.dailyPlan': "Today's plan",
|
||||||
'notif.update': 'A new version is out',
|
'notif.update': 'A new version is out',
|
||||||
@@ -902,6 +903,14 @@ export const en = {
|
|||||||
'project.canEdit': 'Can edit',
|
'project.canEdit': 'Can edit',
|
||||||
'project.canView': 'Can only view',
|
'project.canView': 'Can only view',
|
||||||
'project.invite': 'Invite',
|
'project.invite': 'Invite',
|
||||||
|
'project.pickPerson': 'Pick somebody…',
|
||||||
|
'project.leave': 'Leave the project',
|
||||||
|
'project.leaveSure': 'Leave {name}? You lose access, and only its owner can let you back in.',
|
||||||
|
'project.roleForPicked': 'Role for the person picked',
|
||||||
|
'project.shareWith': 'Share',
|
||||||
|
'project.orByEmail': 'Or somebody who is not here yet:',
|
||||||
|
'project.invited': 'Invited, not arrived yet',
|
||||||
|
'project.withdrawInvite': 'Withdraw the invitation to {email}',
|
||||||
'project.inviteByEmail': 'Invite by email',
|
'project.inviteByEmail': 'Invite by email',
|
||||||
'project.emailAddress': 'email address',
|
'project.emailAddress': 'email address',
|
||||||
'project.role': 'Role',
|
'project.role': 'Role',
|
||||||
|
|||||||
@@ -41,6 +41,13 @@
|
|||||||
let inviteRole = $state('editor');
|
let inviteRole = $state('editor');
|
||||||
let inviteLink = $state('');
|
let inviteLink = $state('');
|
||||||
let inviteError = $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);
|
let id = $derived($page.params.id);
|
||||||
|
|
||||||
@@ -101,7 +108,7 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (showShare && project) {
|
if (showShare && project) {
|
||||||
api.listMembers(project.id).then((r) => (members = r.members));
|
api.listMembers(project.id).then(readMembers);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -512,23 +519,61 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function invite(event) {
|
// Ét sted at læse svaret, fordi der nu er tre lister i det og fire steder, der
|
||||||
event.preventDefault();
|
// 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 = '';
|
inviteError = '';
|
||||||
inviteLink = '';
|
inviteLink = '';
|
||||||
try {
|
try {
|
||||||
const result = await api.invite(project.id, inviteEmail, inviteRole);
|
const result = await api.invite(project.id, who, role);
|
||||||
inviteEmail = '';
|
|
||||||
if (result.link && !result.emailed) {
|
if (result.link && !result.emailed) {
|
||||||
// No mail server, or delivery failed. The link is the only way this
|
// No mail server, or delivery failed. The link is the only way this
|
||||||
// invite reaches anybody, so it is shown rather than swallowed.
|
// invite reaches anybody, so it is shown rather than swallowed.
|
||||||
inviteLink = result.link;
|
inviteLink = result.link;
|
||||||
}
|
}
|
||||||
members = (await api.listMembers(project.id)).members;
|
readMembers(await api.listMembers(project.id));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
inviteError = humanMessage(e);
|
inviteError = humanMessage(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function invite(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const email = inviteEmail.trim();
|
||||||
|
if (!email) return;
|
||||||
|
await share({ email }, inviteRole);
|
||||||
|
if (!inviteError) inviteEmail = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Udgangen. Man bliver lukket ind i et projekt uden at blive spurgt, så der skal
|
||||||
|
// være en vej ud, der ikke går gennem at bede ejeren om det.
|
||||||
|
async function leave() {
|
||||||
|
if (!confirm(t('project.leaveSure', { name: project.name }))) return;
|
||||||
|
try {
|
||||||
|
await api.removeMember(project.id, app.user.id);
|
||||||
|
// Væk fra en side, man ikke længere må se: bliver man stående, er det
|
||||||
|
// næste, siden gør, at hente den og få 404.
|
||||||
|
await goto('/');
|
||||||
|
await app.refreshProjects();
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withdraw(invite) {
|
||||||
|
try {
|
||||||
|
await api.withdrawInvite(project.id, invite.id);
|
||||||
|
readMembers(await api.listMembers(project.id));
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Wide for a board and a month grid, narrow for a list. Decided here rather than
|
<!-- Wide for a board and a month grid, narrow for a list. Decided here rather than
|
||||||
@@ -765,7 +810,7 @@
|
|||||||
class="remove"
|
class="remove"
|
||||||
onclick={async () => {
|
onclick={async () => {
|
||||||
await api.removeMember(project.id, member.user_id);
|
await api.removeMember(project.id, member.user_id);
|
||||||
members = (await api.listMembers(project.id)).members;
|
readMembers(await api.listMembers(project.id));
|
||||||
}}
|
}}
|
||||||
aria-label={t('project.removeMember', { name: member.name })}>×</button
|
aria-label={t('project.removeMember', { name: member.name })}>×</button
|
||||||
>
|
>
|
||||||
@@ -774,7 +819,66 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<!-- De inviterede står ved siden af medlemmerne. Uden dem er en invitation
|
||||||
|
sendt i går usynlig: personen har ingen konto, så de er hverken
|
||||||
|
medlem eller nogen, man kan vælge — og så bliver den sendt igen. -->
|
||||||
|
{#if pending.length}
|
||||||
|
<ul class="members invited">
|
||||||
|
{#each pending as invite (invite.id)}
|
||||||
|
<li>
|
||||||
|
<span class="avatar waiting">@</span>
|
||||||
|
<span class="member-name">{invite.email}</span>
|
||||||
|
<span class="role">{t('project.invited')}</span>
|
||||||
|
{#if isOwner}
|
||||||
|
<button
|
||||||
|
class="remove"
|
||||||
|
onclick={() => withdraw(invite)}
|
||||||
|
aria-label={t('project.withdrawInvite', { email: invite.email })}>×</button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Kun for dem, der er lukket ind. Ejeren kan ikke gå fra sit eget
|
||||||
|
projekt — det ville være en overdragelse, ikke en udgang. -->
|
||||||
|
{#if !isOwner}
|
||||||
|
<button class="leave" onclick={leave}>{t('project.leave')}</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if isOwner}
|
{#if isOwner}
|
||||||
|
<!-- Den, der allerede er her, vælges. Indtil nu kunne et projekt kun
|
||||||
|
deles ved at stave en e-mailadresse, altså ved at kende adressen på
|
||||||
|
en kollega, man kender navnet på — og konti her bliver kun til ved
|
||||||
|
invitation, så listen er en adressebog og ikke en udlevering. -->
|
||||||
|
{#if candidates.length}
|
||||||
|
<div class="addshare">
|
||||||
|
<select bind:value={sharePick} aria-label={t('project.pickPerson')}>
|
||||||
|
<option value="">{t('project.pickPerson')}</option>
|
||||||
|
{#each candidates as person (person.id)}
|
||||||
|
<option value={person.id}>{person.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<select bind:value={pickRole} aria-label={t('project.roleForPicked')}>
|
||||||
|
<option value="editor">{t('project.canEdit')}</option>
|
||||||
|
<option value="viewer">{t('project.canView')}</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
disabled={!sharePick}
|
||||||
|
onclick={async () => {
|
||||||
|
const id = sharePick;
|
||||||
|
sharePick = '';
|
||||||
|
await share({ user_id: id }, pickRole);
|
||||||
|
}}>{t('project.shareWith')}</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Og den, der ikke er her endnu. Samme felt til begge udfald: har
|
||||||
|
adressen en konto, bliver det en deling med det samme, og ellers en
|
||||||
|
invitation. Hvem der har en konto, skal man ikke vide for at dele. -->
|
||||||
|
<p class="byemail-hint">{t('project.orByEmail')}</p>
|
||||||
<form onsubmit={invite}>
|
<form onsubmit={invite}>
|
||||||
<input
|
<input
|
||||||
bind:value={inviteEmail}
|
bind:value={inviteEmail}
|
||||||
@@ -1396,6 +1500,45 @@
|
|||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* De inviterede står under medlemmerne med en tynd streg imellem: de er på vej
|
||||||
|
ind, ikke inde, og en liste uden skel læses som om de allerede var det. */
|
||||||
|
.invited {
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding-top: var(--s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ingen farve at give dem: en invitation har en adresse og ellers ingenting,
|
||||||
|
og en tilfældig farve ville love et menneske, der ikke findes endnu. */
|
||||||
|
.avatar.waiting {
|
||||||
|
background: var(--surface-sunken);
|
||||||
|
border: 1px dashed var(--line);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.addshare {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--s2);
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* I fareskrift og alene: det er den eneste knap i panelet, der handler om en
|
||||||
|
selv, og den skal ikke kunne forveksles med at fjerne en anden. */
|
||||||
|
.leave {
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: var(--s2) var(--s3);
|
||||||
|
border: 1px solid var(--danger);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.byemail-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
|
|||||||
Reference in New Issue
Block a user