Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 828cf87656 | |||
| ac9af8e4f5 | |||
| 8869ac864f | |||
| 0677e74cf8 | |||
| 56b8191a78 | |||
| 93761056eb | |||
| a92da7dcc0 | |||
| b39aac36b7 | |||
| 19f168ab7e | |||
| d827ceedff |
@@ -51,7 +51,7 @@ archive), who is on call, the alert feed, and changing your own password. It is
|
|||||||
built for a phone first. On a phone it has a bottom tab bar and a sticky action
|
built for a phone first. On a phone it has a bottom tab bar and a sticky action
|
||||||
bar, it follows the system's dark mode, and it can be added to the home screen.
|
bar, it follows the system's dark mode, and it can be added to the home screen.
|
||||||
From 900px wide it switches to a sidebar with the queue and the incident side by
|
From 900px wide it switches to a sidebar with the queue and the incident side by
|
||||||
side. Schedule editing, statistics and user management remain in
|
side. Statistics remain in
|
||||||
[terdut-tui](https://github.com/yeniklas/terdut-tui) for now.
|
[terdut-tui](https://github.com/yeniklas/terdut-tui) for now.
|
||||||
|
|
||||||
You sign in with a username and password. Users have no password until one is
|
You sign in with a username and password. Users have no password until one is
|
||||||
@@ -93,7 +93,19 @@ between them at the top.
|
|||||||
|
|
||||||
The **Admin** tab appears only for a system administrator, and holds what
|
The **Admin** tab appears only for a system administrator, and holds what
|
||||||
belongs to the whole server rather than to one team: every team, every user, and
|
belongs to the whole server rather than to one team: every team, every user, and
|
||||||
the settings that used to be environment variables.
|
the settings that used to be environment variables. Adding somebody is minting
|
||||||
|
them an invite link into a team, rather than creating a bare account: the person
|
||||||
|
who accepts it picks their own password, so one never passes through an
|
||||||
|
administrator, and the link carries the team, so they land somewhere with a
|
||||||
|
queue in it.
|
||||||
|
|
||||||
|
A name in that list opens **that person's page**, at `/admin/users/{id}`: their
|
||||||
|
email and when they joined, where their notifications go, whether they are an
|
||||||
|
administrator, whether the account is disabled, the teams they are in with their
|
||||||
|
role in each, a password field for a first or forgotten one, and deletion. It is
|
||||||
|
the one place membership is edited from the person's side — the Team tab answers
|
||||||
|
"who is in this team", and answering "which teams is this person in" there means
|
||||||
|
visiting each team in turn.
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
@@ -550,6 +562,25 @@ granting the flag itself. Everybody else works incidents — acknowledging,
|
|||||||
assigning, snoozing, resolving, noting — and manages their own account and
|
assigning, snoozing, resolving, noting — and manages their own account and
|
||||||
nobody else's. An API key carries exactly the rights of the user it belongs to.
|
nobody else's. An API key carries exactly the rights of the user it belongs to.
|
||||||
|
|
||||||
|
**Getting an account.** The first one comes from `/api/bootstrap`. After that
|
||||||
|
it depends on `signup_mode`, an administrator setting:
|
||||||
|
|
||||||
|
- `invite_only` (the default) — a team owner mints a link with
|
||||||
|
`POST /api/teams/{teamID}/invites`, and the person who opens it picks a
|
||||||
|
username and password and lands in that team with the role the link carries.
|
||||||
|
Links are single-use unless told otherwise, expire after seven days, and can
|
||||||
|
be revoked before that.
|
||||||
|
- `open` — anybody who can reach the server can create an account, and must
|
||||||
|
name a team, which they then own.
|
||||||
|
|
||||||
|
Invites are **links, not email**: this server has no SMTP, and adding it to send
|
||||||
|
one message would be a subsystem to run, secure and monitor. Send the link
|
||||||
|
however you already talk to the person.
|
||||||
|
|
||||||
|
A domain-restricted third mode was considered and dropped: with no email there
|
||||||
|
is nothing to verify an address against, so it would only check the domain of a
|
||||||
|
string somebody typed.
|
||||||
|
|
||||||
The first user, from `/api/bootstrap`, is an administrator. Users created
|
The first user, from `/api/bootstrap`, is an administrator. Users created
|
||||||
afterwards are not, until an administrator says so. An install always keeps at
|
afterwards are not, until an administrator says so. An install always keeps at
|
||||||
least one: the last administrator can be neither deleted nor demoted, and
|
least one: the last administrator can be neither deleted nor demoted, and
|
||||||
@@ -560,10 +591,16 @@ Endpoints that require the flag answer `403` with
|
|||||||
|
|
||||||
**Teams** are the unit of tenancy, and are a separate axis from the administrator
|
**Teams** are the unit of tenancy, and are a separate axis from the administrator
|
||||||
flag. A team owns its incidents, alerts, schedule and integrations, and a user
|
flag. A team owns its incidents, alerts, schedule and integrations, and a user
|
||||||
sees exactly the teams they belong to — an administrator is not implicitly in
|
sees exactly the teams they belong to. Within a team an **owner** configures it
|
||||||
every team, because administration is about accounts, not about reading other
|
(schedule, integrations, membership) and a **member** works its incidents.
|
||||||
people's incidents. Within a team an **owner** configures it (schedule,
|
|
||||||
integrations, membership) and a **member** works its incidents.
|
An administrator crosses that line in one direction only. They **configure any
|
||||||
|
team** without being in it — every owner-only endpoint accepts the flag, because
|
||||||
|
otherwise a team whose last owner left could never be repaired. They do **not
|
||||||
|
read any team**: the queue, the alerts and the incidents are filtered by real
|
||||||
|
membership, so an administrator sees a team's work only by joining it, which is
|
||||||
|
a membership change and shows up as one. Administration is about accounts and
|
||||||
|
the shape of a team, not about reading other people's incidents.
|
||||||
|
|
||||||
Anything belonging to a team you are not in answers `404`, not `403`: whether an
|
Anything belonging to a team you are not in answers `404`, not `403`: whether an
|
||||||
incident exists is itself something only its team should learn.
|
incident exists is itself something only its team should learn.
|
||||||
@@ -584,8 +621,11 @@ on anybody's.
|
|||||||
|
|
||||||
| Method | Path | Who | Description |
|
| Method | Path | Who | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
|
| `GET` | `/api/signup` | — | Whether sign-up is open, and whether `?invite=` is usable. No session needed: the caller has no account yet |
|
||||||
|
| `POST` | `/api/signup` | — | Create an account `{"username","email","password","invite"?,"team_name"?}` and sign in. `403` without a usable invite when the mode is invite-only |
|
||||||
| `POST` | `/api/bootstrap` | — | Create first user + API key `{"username","email","password"?}` (only works on empty DB). The user is an administrator |
|
| `POST` | `/api/bootstrap` | — | Create first user + API key `{"username","email","password"?}` (only works on empty DB). The user is an administrator |
|
||||||
| `GET` | `/api/users` | any | List users. Open to everybody: the queue's assignment control and the schedule both have to name people |
|
| `GET` | `/api/users` | any | List users. Open to everybody: the queue's assignment control and the schedule both have to name people |
|
||||||
|
| `GET` | `/api/users/{id}/teams` | self or admin | The teams that user is in, each with their role. `/api/teams` is always about the caller; this one answers it about somebody else, for the admin page's per-user view. `404` for a user who does not exist, so "no teams" and "no such person" are distinguishable |
|
||||||
| `POST` | `/api/users` | **admin** | Create user `{"username","email"}`. Not an administrator |
|
| `POST` | `/api/users` | **admin** | Create user `{"username","email"}`. Not an administrator |
|
||||||
| `DELETE` | `/api/users/{id}` | **admin** | Delete user (cascades to keys). `409` for yourself or the last administrator |
|
| `DELETE` | `/api/users/{id}` | **admin** | Delete user (cascades to keys). `409` for yourself or the last administrator |
|
||||||
| `PUT` | `/api/users/{id}/admin` | **admin** | Grant or revoke the administrator flag `{"is_admin"}`. `409` for yourself or the last administrator |
|
| `PUT` | `/api/users/{id}/admin` | **admin** | Grant or revoke the administrator flag `{"is_admin"}`. `409` for yourself or the last administrator |
|
||||||
@@ -601,7 +641,7 @@ on anybody's.
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `GET` | `/api/admin/teams` | **admin** | Every team on the server, with its member and open-incident counts. `/api/teams` answers "what am I in"; this answers "what is there" |
|
| `GET` | `/api/admin/teams` | **admin** | Every team on the server, with its member and open-incident counts. `/api/teams` answers "what am I in"; this answers "what is there" |
|
||||||
| `GET` | `/api/admin/settings` | **admin** | The editable settings with their bounds, plus the environment-configured ones, read-only. Never credentials |
|
| `GET` | `/api/admin/settings` | **admin** | The editable settings with their bounds, plus the environment-configured ones, read-only. Never credentials |
|
||||||
| `PUT` | `/api/admin/settings` | **admin** | Change one or more `{"key": seconds}`. `400` for an unknown key or a value outside its bounds |
|
| `PUT` | `/api/admin/settings` | **admin** | Change one or more `{"key": seconds}`, or `{"signup_mode": "open"\|"invite_only"}`. `400` for an unknown key or a value outside its bounds |
|
||||||
|
|
||||||
### Alert ingestion
|
### Alert ingestion
|
||||||
|
|
||||||
@@ -620,6 +660,11 @@ and was removed in v0.13.0 once senders had moved onto keys.
|
|||||||
|
|
||||||
### Teams
|
### Teams
|
||||||
|
|
||||||
|
**owner** below means an owner of that team *or* a system administrator, who
|
||||||
|
passes every one of these without being a member — see
|
||||||
|
[Authentication](#authentication). **member** means membership and nothing else: an
|
||||||
|
administrator who is not in the team gets the same `404` as anybody else.
|
||||||
|
|
||||||
| Method | Path | Who | Description |
|
| Method | Path | Who | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `GET` | `/api/teams` | any | The caller's own teams, each with their role |
|
| `GET` | `/api/teams` | any | The caller's own teams, each with their role |
|
||||||
@@ -632,6 +677,9 @@ and was removed in v0.13.0 once senders had moved onto keys.
|
|||||||
| `GET` | `/api/teams/{teamID}/integrations` | member | List integrations. Never returns keys |
|
| `GET` | `/api/teams/{teamID}/integrations` | member | List integrations. Never returns keys |
|
||||||
| `POST` | `/api/teams/{teamID}/integrations` | **owner** | Mint an integration `{"name","kind"}` — key and URL shown once |
|
| `POST` | `/api/teams/{teamID}/integrations` | **owner** | Mint an integration `{"name","kind"}` — key and URL shown once |
|
||||||
| `DELETE` | `/api/teams/{teamID}/integrations/{integrationID}` | **owner** | Revoke an integration |
|
| `DELETE` | `/api/teams/{teamID}/integrations/{integrationID}` | **owner** | Revoke an integration |
|
||||||
|
| `GET` | `/api/teams/{teamID}/invites` | **owner** | The team's invite links, with their uses and expiry. Never the tokens |
|
||||||
|
| `POST` | `/api/teams/{teamID}/invites` | **owner** | Mint one `{"role","max_uses"}` — the full URL is returned once |
|
||||||
|
| `DELETE` | `/api/teams/{teamID}/invites/{inviteID}` | **owner** | Revoke a link before it expires |
|
||||||
| `GET` | `/api/teams/{teamID}/escalation` | member | The team's [escalation ladder](#escalation) `{repeat_count, fallback_topic, levels[]}`. Empty levels means the team has none |
|
| `GET` | `/api/teams/{teamID}/escalation` | member | The team's [escalation ladder](#escalation) `{repeat_count, fallback_topic, levels[]}`. Empty levels means the team has none |
|
||||||
| `PUT` | `/api/teams/{teamID}/escalation` | **owner** | Replace it wholesale. `400` for a level with no targets or no timeout — a rung that pages nobody is a silence with a number on it |
|
| `PUT` | `/api/teams/{teamID}/escalation` | **owner** | Replace it wholesale. `400` for a level with no targets or no timeout — a rung that pages nobody is a silence with a number on it |
|
||||||
| `GET` | `/api/teams/{teamID}/deadman` | member | The team's [dead man's switch](#dead-mans-switch) configuration `{matchers, timeout_seconds, severity}` |
|
| `GET` | `/api/teams/{teamID}/deadman` | member | The team's [dead man's switch](#dead-mans-switch) configuration `{matchers, timeout_seconds, severity}` |
|
||||||
|
|||||||
@@ -15,5 +15,5 @@ type: application
|
|||||||
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
|
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
|
||||||
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
|
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
|
||||||
# metadata and drives nothing.
|
# metadata and drives nothing.
|
||||||
version: 0.14.0
|
version: 0.16.0
|
||||||
appVersion: "v0.14.0"
|
appVersion: "v0.16.0"
|
||||||
|
|||||||
@@ -237,6 +237,125 @@ func TestAdmin_GrantAndRevokeChangeWhatIsAllowed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An administrator passes every team-owner check without being in the team,
|
||||||
|
// which is what lets them repair a team whose owner has left. It has been true
|
||||||
|
// since teams landed and nothing pinned it, so a later reading of the epic's
|
||||||
|
// "an admin is not implicitly in every team" could quietly take it away.
|
||||||
|
//
|
||||||
|
// The line it draws: configuring a team, yes; reading what the team owns, no.
|
||||||
|
// The queue below is the half that stays shut.
|
||||||
|
func TestAdmin_ConfiguresATeamTheyAreNotIn(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
|
||||||
|
// A team the admin is deliberately not a member of. It is created by
|
||||||
|
// somebody else, so the admin's only claim on it is the flag.
|
||||||
|
_, call := member(t, s, "founder")
|
||||||
|
var team struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
}
|
||||||
|
decode(t, call(http.MethodPost, "/api/teams", map[string]string{"name": "theirs"}), &team)
|
||||||
|
if team.ID == 0 {
|
||||||
|
t.Fatal("no team was created")
|
||||||
|
}
|
||||||
|
|
||||||
|
var mine []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/teams", nil), &mine)
|
||||||
|
for _, m := range mine {
|
||||||
|
if m.ID == team.ID {
|
||||||
|
t.Fatalf("the admin should not be a member of team %d", team.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
path := "/api/teams/" + id64(team.ID)
|
||||||
|
for _, c := range []struct {
|
||||||
|
name string
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
body any
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"rename it", http.MethodPut, path,
|
||||||
|
map[string]string{"name": "theirs, renamed"}, http.StatusNoContent},
|
||||||
|
{"mint an invite", http.MethodPost, path + "/invites",
|
||||||
|
map[string]any{"role": "member", "max_uses": 1}, http.StatusCreated},
|
||||||
|
{"add a member", http.MethodPost, path + "/members",
|
||||||
|
map[string]any{"user_id": 1, "role": "member"}, http.StatusNoContent},
|
||||||
|
{"remove a member", http.MethodDelete, path + "/members/1", nil, http.StatusNoContent},
|
||||||
|
} {
|
||||||
|
resp := s.req(t, c.method, c.path, c.body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != c.want {
|
||||||
|
t.Errorf("%s: expected %d, got %d", c.name, c.want, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The other half of the rule. An incident in that team is not the admin's
|
||||||
|
// to read, because administration is about accounts — and the last case
|
||||||
|
// above has just taken the admin back out of the membership.
|
||||||
|
var integration struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
}
|
||||||
|
decode(t, call(http.MethodPost, path+"/integrations",
|
||||||
|
map[string]string{"name": "theirs alertmanager"}), &integration)
|
||||||
|
postToIntegration(t, s, integration.Key, "fp-theirs", "TheirDiskFull")
|
||||||
|
|
||||||
|
var incidents []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/incidents", nil), &incidents)
|
||||||
|
if len(incidents) != 0 {
|
||||||
|
t.Errorf("the admin should see none of that team's incidents, got %d", len(incidents))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The admin page's per-user view asks what somebody is in. Self or admin, like
|
||||||
|
// the rest of the per-user endpoints.
|
||||||
|
func TestUserTeams_SelfOrAdmin(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
memberID, call := member(t, s, "joiner")
|
||||||
|
path := "/api/users/" + id64(memberID) + "/teams"
|
||||||
|
|
||||||
|
// member() puts them in the default team, so both readings agree on one.
|
||||||
|
for _, c := range []struct {
|
||||||
|
name string
|
||||||
|
do func() *http.Response
|
||||||
|
}{
|
||||||
|
{"the admin reading somebody else's", func() *http.Response { return s.req(t, http.MethodGet, path, nil) }},
|
||||||
|
{"the user reading their own", func() *http.Response { return call(http.MethodGet, path, nil) }},
|
||||||
|
} {
|
||||||
|
var teams []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
decode(t, c.do(), &teams)
|
||||||
|
if len(teams) != 1 {
|
||||||
|
t.Fatalf("%s: expected 1 team, got %d", c.name, len(teams))
|
||||||
|
}
|
||||||
|
if teams[0].Role != "member" {
|
||||||
|
t.Errorf("%s: expected role member, got %q", c.name, teams[0].Role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Somebody else's is not theirs to read.
|
||||||
|
otherID, _ := member(t, s, "nosy")
|
||||||
|
resp := call(http.MethodGet, "/api/users/"+id64(otherID)+"/teams", nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("reading another user's teams: expected 403, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A user who does not exist is a 404 rather than an empty list, which is
|
||||||
|
// how the page tells "no teams" from "no such person".
|
||||||
|
resp = s.req(t, http.MethodGet, "/api/users/9999/teams", nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("a missing user: expected 404, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The flag has to reach the client, or the web UI cannot decide what to show.
|
// The flag has to reach the client, or the web UI cannot decide what to show.
|
||||||
func TestAdmin_MeReportsTheFlag(t *testing.T) {
|
func TestAdmin_MeReportsTheFlag(t *testing.T) {
|
||||||
s := newTS(t)
|
s := newTS(t)
|
||||||
|
|||||||
+42
-22
@@ -139,6 +139,34 @@ func hashPassword(pw string) (string, error) {
|
|||||||
return string(h), err
|
return string(h), err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// startSession mints a session and sets the cookie. Shared by login and
|
||||||
|
// sign-up: somebody who has just chosen a password is signed in, rather than
|
||||||
|
// being sent to a form to type the same credential again.
|
||||||
|
func startSession(w http.ResponseWriter, r *http.Request, db *sql.DB, userID int64, publicURL string) error {
|
||||||
|
raw, tokenHash, err := randomToken()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if _, err := db.ExecContext(r.Context(), `
|
||||||
|
INSERT INTO sessions (token_hash, user_id, created_at, last_seen_at, expires_at, user_agent)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||||
|
tokenHash, userID, now.Unix(), now.Unix(), now.Add(sessionTTL).Unix(), r.UserAgent()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookie,
|
||||||
|
Value: raw,
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: int(sessionTTL.Seconds()),
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: cookieSecure(publicURL, r),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// handleLogin exchanges a username and password for a session cookie.
|
// handleLogin exchanges a username and password for a session cookie.
|
||||||
func handleLogin(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc {
|
func handleLogin(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -182,29 +210,10 @@ func handleLogin(db *sql.DB, limiter *loginLimiter, publicURL string) http.Handl
|
|||||||
}
|
}
|
||||||
limiter.clear(userKey)
|
limiter.clear(userKey)
|
||||||
|
|
||||||
raw, tokenHash, err := randomToken()
|
if err := startSession(w, r, db, userID, publicURL); err != nil {
|
||||||
if err != nil {
|
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
now := time.Now()
|
|
||||||
if _, err := db.ExecContext(r.Context(), `
|
|
||||||
INSERT INTO sessions (token_hash, user_id, created_at, last_seen_at, expires_at, user_agent)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
||||||
tokenHash, userID, now.Unix(), now.Unix(), now.Add(sessionTTL).Unix(), r.UserAgent()); err != nil {
|
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: sessionCookie,
|
|
||||||
Value: raw,
|
|
||||||
Path: "/",
|
|
||||||
MaxAge: int(sessionTTL.Seconds()),
|
|
||||||
HttpOnly: true,
|
|
||||||
Secure: cookieSecure(publicURL, r),
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
})
|
|
||||||
|
|
||||||
user, err := fetchUser(r.Context(), db, userID)
|
user, err := fetchUser(r.Context(), db, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -243,6 +252,11 @@ func handleLogout(db *sql.DB, publicURL string) http.HandlerFunc {
|
|||||||
type meResponse struct {
|
type meResponse struct {
|
||||||
User any `json:"user"`
|
User any `json:"user"`
|
||||||
HasPassword bool `json:"has_password"`
|
HasPassword bool `json:"has_password"`
|
||||||
|
|
||||||
|
// OnboardingDismissed is whether this person has put the first-run
|
||||||
|
// checklist away. Per user rather than per browser: somebody who finishes
|
||||||
|
// setting up on a laptop should not be nagged again on their phone.
|
||||||
|
OnboardingDismissed bool `json:"onboarding_dismissed"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleMe says who the caller is. The web UI calls it on load to decide
|
// handleMe says who the caller is. The web UI calls it on load to decide
|
||||||
@@ -256,9 +270,15 @@ func handleMe(db *sql.DB) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var hash sql.NullString
|
var hash sql.NullString
|
||||||
|
var dismissed *int64
|
||||||
db.QueryRowContext(r.Context(),
|
db.QueryRowContext(r.Context(),
|
||||||
"SELECT password_hash FROM users WHERE id = $1", caller.ID).Scan(&hash)
|
"SELECT password_hash, onboarding_dismissed_at FROM users WHERE id = $1",
|
||||||
respond(w, http.StatusOK, meResponse{User: user, HasPassword: hash.Valid})
|
caller.ID).Scan(&hash, &dismissed)
|
||||||
|
respond(w, http.StatusOK, meResponse{
|
||||||
|
User: user,
|
||||||
|
HasPassword: hash.Valid,
|
||||||
|
OnboardingDismissed: dismissed != nil,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+24
-1
@@ -15,6 +15,12 @@ import (
|
|||||||
// notify disables notifications. Dead man's switches are per team and read from
|
// notify disables notifications. Dead man's switches are per team and read from
|
||||||
// the database, so nothing about them is wired in here.
|
// the database, so nothing about them is wired in here.
|
||||||
func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler {
|
func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler {
|
||||||
|
// One limiter each, both process-wide for the life of the router: login
|
||||||
|
// counts failed passwords, sign-up counts account creation, and mixing the
|
||||||
|
// two would let a burst of sign-ups lock somebody out of logging in.
|
||||||
|
loginLimit := newLoginLimiter()
|
||||||
|
signupLimiter := newLoginLimiter()
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(middleware.Logger)
|
r.Use(middleware.Logger)
|
||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
@@ -39,9 +45,16 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
|
|||||||
// JSON 404 every unknown /api path gets.
|
// JSON 404 every unknown /api path gets.
|
||||||
r.Post("/api/integrations/{key}/alertmanager", handleIntegrationWebhook(db, notify))
|
r.Post("/api/integrations/{key}/alertmanager", handleIntegrationWebhook(db, notify))
|
||||||
|
|
||||||
|
// Signing up. Both are unauthenticated by necessity: the caller has no
|
||||||
|
// account yet. The info endpoint says whether the door is open and whether
|
||||||
|
// an invite link is good, so the form can say so before somebody picks a
|
||||||
|
// password.
|
||||||
|
r.Get("/api/signup", handleSignupInfo(db))
|
||||||
|
r.Post("/api/signup", handleSignup(db, signupLimiter, notify.PublicURL))
|
||||||
|
|
||||||
// Signing in to the web UI. Login trades a password for a session cookie,
|
// Signing in to the web UI. Login trades a password for a session cookie,
|
||||||
// which AuthMiddleware accepts in place of an API key.
|
// which AuthMiddleware accepts in place of an API key.
|
||||||
r.Post("/api/login", handleLogin(db, newLoginLimiter(), notify.PublicURL))
|
r.Post("/api/login", handleLogin(db, loginLimit, notify.PublicURL))
|
||||||
r.Post("/api/logout", handleLogout(db, notify.PublicURL))
|
r.Post("/api/logout", handleLogout(db, notify.PublicURL))
|
||||||
|
|
||||||
// All other /api routes require a valid API key.
|
// All other /api routes require a valid API key.
|
||||||
@@ -49,6 +62,10 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
|
|||||||
r.Use(AuthMiddleware(db))
|
r.Use(AuthMiddleware(db))
|
||||||
|
|
||||||
r.Get("/api/me", handleMe(db))
|
r.Get("/api/me", handleMe(db))
|
||||||
|
r.Put("/api/me/onboarding", handleDismissOnboarding(db))
|
||||||
|
// Proves the topic works, which is the only part of "notifications are
|
||||||
|
// set up" that the person holding the phone can confirm.
|
||||||
|
r.Post("/api/me/notify/test", handleTestNotification(notify, db))
|
||||||
|
|
||||||
// Readable by anyone signed in: the queue's assignment control and the
|
// Readable by anyone signed in: the queue's assignment control and the
|
||||||
// on-call schedule both need to name people.
|
// on-call schedule both need to name people.
|
||||||
@@ -57,6 +74,7 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
|
|||||||
// Your own account, or anybody's if you are an admin. The handlers call
|
// Your own account, or anybody's if you are an admin. The handlers call
|
||||||
// requireSelfOrAdmin rather than sitting behind AdminOnly, because
|
// requireSelfOrAdmin rather than sitting behind AdminOnly, because
|
||||||
// which rule applies depends on the {id} in the path.
|
// which rule applies depends on the {id} in the path.
|
||||||
|
r.Get("/api/users/{id}/teams", handleUserTeams(db))
|
||||||
r.Put("/api/users/{id}/notify", handleSetNotifyTarget(db))
|
r.Put("/api/users/{id}/notify", handleSetNotifyTarget(db))
|
||||||
r.Put("/api/users/{id}/password", handleSetPassword(db))
|
r.Put("/api/users/{id}/password", handleSetPassword(db))
|
||||||
r.Post("/api/users/{id}/api-keys", handleCreateAPIKey(db))
|
r.Post("/api/users/{id}/api-keys", handleCreateAPIKey(db))
|
||||||
@@ -109,6 +127,11 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
|
|||||||
r.Post("/api/teams/{teamID}/members", handleAddTeamMember(db))
|
r.Post("/api/teams/{teamID}/members", handleAddTeamMember(db))
|
||||||
r.Delete("/api/teams/{teamID}/members/{userID}", handleRemoveTeamMember(db))
|
r.Delete("/api/teams/{teamID}/members/{userID}", handleRemoveTeamMember(db))
|
||||||
|
|
||||||
|
// Invite links into this team.
|
||||||
|
r.Get("/api/teams/{teamID}/invites", handleListInvites(db))
|
||||||
|
r.Post("/api/teams/{teamID}/invites", handleCreateInvite(db, notify.PublicURL))
|
||||||
|
r.Delete("/api/teams/{teamID}/invites/{inviteID}", handleRevokeInvite(db))
|
||||||
|
|
||||||
// A team's escalation ladder: who is paged when nobody answers.
|
// A team's escalation ladder: who is paged when nobody answers.
|
||||||
r.Get("/api/teams/{teamID}/escalation", handleGetEscalation(db))
|
r.Get("/api/teams/{teamID}/escalation", handleGetEscalation(db))
|
||||||
r.Put("/api/teams/{teamID}/escalation", handleSetEscalation(db))
|
r.Put("/api/teams/{teamID}/escalation", handleSetEscalation(db))
|
||||||
|
|||||||
@@ -90,6 +90,16 @@ func SeedSettings(ctx context.Context, db *sql.DB, cfg config.Config) error {
|
|||||||
type settingsResponse struct {
|
type settingsResponse struct {
|
||||||
Editable map[string]settingValue `json:"editable"`
|
Editable map[string]settingValue `json:"editable"`
|
||||||
FromEnv map[string]string `json:"from_env"`
|
FromEnv map[string]string `json:"from_env"`
|
||||||
|
|
||||||
|
// Choices are settings that are a word from a fixed list rather than a
|
||||||
|
// duration. One so far: who may create an account.
|
||||||
|
Choices map[string]choiceValue `json:"choices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type choiceValue struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
Options []string `json:"options"`
|
||||||
|
Description string `json:"description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type settingValue struct {
|
type settingValue struct {
|
||||||
@@ -104,6 +114,14 @@ func handleGetSettings(db *sql.DB, cfg config.Config) http.HandlerFunc {
|
|||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
out := settingsResponse{
|
out := settingsResponse{
|
||||||
Editable: map[string]settingValue{},
|
Editable: map[string]settingValue{},
|
||||||
|
Choices: map[string]choiceValue{
|
||||||
|
SettingSignupMode: {
|
||||||
|
Value: signupMode(r.Context(), db),
|
||||||
|
Options: []string{SignupInviteOnly, SignupOpen},
|
||||||
|
Description: "who may create an account: invite_only means a link from a team owner, " +
|
||||||
|
"open means anybody who can reach this server",
|
||||||
|
},
|
||||||
|
},
|
||||||
FromEnv: map[string]string{
|
FromEnv: map[string]string{
|
||||||
// Never the ntfy token or the DSN: both are credentials, and an
|
// Never the ntfy token or the DSN: both are credentials, and an
|
||||||
// admin page that renders them turns a browser tab into a place
|
// admin page that renders them turns a browser tab into a place
|
||||||
@@ -137,7 +155,7 @@ func handleGetSettings(db *sql.DB, cfg config.Config) http.HandlerFunc {
|
|||||||
// sit in the table looking like configuration and doing nothing.
|
// sit in the table looking like configuration and doing nothing.
|
||||||
func handleSetSettings(db *sql.DB) http.HandlerFunc {
|
func handleSetSettings(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
var req map[string]int64
|
var req map[string]any
|
||||||
if err := decodeJSON(r, &req); err != nil {
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||||
return
|
return
|
||||||
@@ -147,18 +165,38 @@ func handleSetSettings(db *sql.DB) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for key, secs := range req {
|
// Validate everything before writing anything: a request that sets two
|
||||||
|
// settings and gets one wrong should change neither.
|
||||||
|
values := map[string]string{}
|
||||||
|
for key, raw := range req {
|
||||||
|
switch key {
|
||||||
|
case SettingSignupMode:
|
||||||
|
mode, _ := raw.(string)
|
||||||
|
if mode != SignupOpen && mode != SignupInviteOnly {
|
||||||
|
respond(w, http.StatusBadRequest,
|
||||||
|
errResp("signup_mode must be "+SignupInviteOnly+" or "+SignupOpen))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
values[key] = mode
|
||||||
|
default:
|
||||||
b, known := settingBounds[key]
|
b, known := settingBounds[key]
|
||||||
if !known {
|
if !known {
|
||||||
respond(w, http.StatusBadRequest, errResp("unknown setting: "+key))
|
respond(w, http.StatusBadRequest, errResp("unknown setting: "+key))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
d := time.Duration(secs) * time.Second
|
secs, ok := raw.(float64) // JSON numbers decode as float64
|
||||||
|
if !ok {
|
||||||
|
respond(w, http.StatusBadRequest, errResp(key+" must be a number of seconds"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d := time.Duration(int64(secs)) * time.Second
|
||||||
if d < b.min || d > b.max {
|
if d < b.min || d > b.max {
|
||||||
respond(w, http.StatusBadRequest, errResp(
|
respond(w, http.StatusBadRequest, errResp(
|
||||||
key+" must be between "+b.min.String()+" and "+b.max.String()))
|
key+" must be between "+b.min.String()+" and "+b.max.String()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
values[key] = strconv.FormatInt(int64(secs), 10)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := db.BeginTx(r.Context(), nil)
|
tx, err := db.BeginTx(r.Context(), nil)
|
||||||
@@ -168,13 +206,13 @@ func handleSetSettings(db *sql.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
defer tx.Rollback() //nolint:errcheck
|
defer tx.Rollback() //nolint:errcheck
|
||||||
|
|
||||||
for key, secs := range req {
|
for key, value := range values {
|
||||||
if _, err := tx.ExecContext(r.Context(), `
|
if _, err := tx.ExecContext(r.Context(), `
|
||||||
INSERT INTO settings (key, value, updated_at)
|
INSERT INTO settings (key, value, updated_at)
|
||||||
VALUES ($1, $2, `+nowEpoch+`)
|
VALUES ($1, $2, `+nowEpoch+`)
|
||||||
ON CONFLICT (key) DO UPDATE SET
|
ON CONFLICT (key) DO UPDATE SET
|
||||||
value = excluded.value, updated_at = excluded.updated_at`,
|
value = excluded.value, updated_at = excluded.updated_at`,
|
||||||
key, strconv.FormatInt(secs, 10)); err != nil {
|
key, value); err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,489 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SettingSignupMode says who may create an account. It lives in the settings
|
||||||
|
// table with the other behaviour settings, so an administrator changes it in
|
||||||
|
// the admin page rather than in a chart.
|
||||||
|
//
|
||||||
|
// Two modes, not three. A domain-restricted mode was considered and dropped:
|
||||||
|
// with no email in this server there is nothing to verify an address against,
|
||||||
|
// so it would check the domain of a string somebody typed — a speed bump
|
||||||
|
// dressed as a control.
|
||||||
|
const (
|
||||||
|
SettingSignupMode = "signup_mode"
|
||||||
|
|
||||||
|
SignupInviteOnly = "invite_only"
|
||||||
|
SignupOpen = "open"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultSignupMode is invite-only. An install that gets a public hostname
|
||||||
|
// before anybody has thought about sign-up should not be collecting accounts
|
||||||
|
// from the internet by default.
|
||||||
|
const defaultSignupMode = SignupInviteOnly
|
||||||
|
|
||||||
|
// inviteTTL is how long a new invite link lives. Long enough to send it and be
|
||||||
|
// read tomorrow, short enough that a link in an old chat log stops working.
|
||||||
|
const inviteTTL = 7 * 24 * time.Hour
|
||||||
|
|
||||||
|
// signupMode reads the current mode, falling back to invite-only for a missing
|
||||||
|
// or unrecognised value: the failure mode of a typo in this setting should be
|
||||||
|
// the closed door, not the open one.
|
||||||
|
func signupMode(ctx context.Context, db *sql.DB) string {
|
||||||
|
var raw string
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
"SELECT value FROM settings WHERE key = $1", SettingSignupMode).Scan(&raw); err != nil {
|
||||||
|
return defaultSignupMode
|
||||||
|
}
|
||||||
|
if raw != SignupOpen && raw != SignupInviteOnly {
|
||||||
|
return defaultSignupMode
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSignupInfo tells the sign-up page what it may offer, without requiring
|
||||||
|
// a session: whether open sign-up is on, and whether the invite in the URL is
|
||||||
|
// any good. A bad invite is better reported before somebody picks a password.
|
||||||
|
func handleSignupInfo(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
out := map[string]any{"mode": signupMode(r.Context(), db)}
|
||||||
|
|
||||||
|
if token := r.URL.Query().Get("invite"); token != "" {
|
||||||
|
inv, err := loadInvite(r.Context(), db, token)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
out["invite_valid"] = true
|
||||||
|
out["invite_team"] = inv.teamName
|
||||||
|
default:
|
||||||
|
// Deliberately one answer for expired, revoked, used up and
|
||||||
|
// never existed. Telling a stranger which it was tells them
|
||||||
|
// something about links they do not hold.
|
||||||
|
out["invite_valid"] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type invite struct {
|
||||||
|
id int64
|
||||||
|
teamID int64
|
||||||
|
teamName string
|
||||||
|
role string
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadInvite resolves a raw token to a usable invite, or an error. Usable means
|
||||||
|
// it exists, has not been revoked, has not expired and has uses left.
|
||||||
|
func loadInvite(ctx context.Context, q querier, token string) (invite, error) {
|
||||||
|
var inv invite
|
||||||
|
err := q.QueryRowContext(ctx, `
|
||||||
|
SELECT i.id, i.team_id, t.name, i.role
|
||||||
|
FROM invites i
|
||||||
|
JOIN teams t ON t.id = i.team_id
|
||||||
|
WHERE i.token_hash = $1
|
||||||
|
AND i.revoked_at IS NULL
|
||||||
|
AND i.expires_at > `+nowEpoch+`
|
||||||
|
AND i.uses < i.max_uses`, hashToken(token)).
|
||||||
|
Scan(&inv.id, &inv.teamID, &inv.teamName, &inv.role)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return invite{}, errInviteUnusable
|
||||||
|
}
|
||||||
|
return inv, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var errInviteUnusable = errors.New("invite is not usable")
|
||||||
|
|
||||||
|
// handleSignup creates an account, and puts it somewhere.
|
||||||
|
//
|
||||||
|
// Rate-limited on the same limiter as login, by address: sign-up is the other
|
||||||
|
// unauthenticated endpoint that writes, and an open install without this is a
|
||||||
|
// way to fill somebody's user table.
|
||||||
|
func handleSignup(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
addr := clientAddr(r)
|
||||||
|
if limiter.blocked("signup:"+addr, maxSignupsPerAddr) {
|
||||||
|
respond(w, http.StatusTooManyRequests, errResp("too many sign-ups from this address"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Invite string `json:"invite"`
|
||||||
|
TeamName string `json:"team_name"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Username = strings.TrimSpace(req.Username)
|
||||||
|
req.Email = strings.TrimSpace(req.Email)
|
||||||
|
req.TeamName = strings.TrimSpace(req.TeamName)
|
||||||
|
|
||||||
|
if req.Username == "" || req.Email == "" {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("username and email are required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if msg := validatePassword(req.Password); msg != "" {
|
||||||
|
respond(w, http.StatusBadRequest, errResp(msg))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mode := signupMode(r.Context(), db)
|
||||||
|
var inv invite
|
||||||
|
hasInvite := false
|
||||||
|
if req.Invite != "" {
|
||||||
|
var err error
|
||||||
|
inv, err = loadInvite(r.Context(), db, req.Invite)
|
||||||
|
if err != nil {
|
||||||
|
limiter.fail("signup:" + addr)
|
||||||
|
respond(w, http.StatusForbidden, errResp("this invite link is not usable"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hasInvite = true
|
||||||
|
}
|
||||||
|
if !hasInvite && mode != SignupOpen {
|
||||||
|
// No invite and the door is shut. Not 404: the endpoint exists and
|
||||||
|
// saying so is how somebody knows to ask for a link.
|
||||||
|
respond(w, http.StatusForbidden,
|
||||||
|
errResp("sign-up is invite-only on this server"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !hasInvite && req.TeamName == "" {
|
||||||
|
// Open sign-up with no team would create an account that sees an
|
||||||
|
// empty queue and can be paged by nobody.
|
||||||
|
respond(w, http.StatusBadRequest, errResp("team_name is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := hashPassword(req.Password)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := db.BeginTx(r.Context(), nil)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck
|
||||||
|
|
||||||
|
var userID int64
|
||||||
|
var invitedVia *int64
|
||||||
|
if hasInvite {
|
||||||
|
invitedVia = &inv.id
|
||||||
|
}
|
||||||
|
if err := tx.QueryRowContext(r.Context(), `
|
||||||
|
INSERT INTO users (username, email, password_hash, invited_via)
|
||||||
|
VALUES ($1, $2, $3, $4) RETURNING id`,
|
||||||
|
req.Username, req.Email, hash, invitedVia).Scan(&userID); err != nil {
|
||||||
|
if isUniqueViolation(err) {
|
||||||
|
respond(w, http.StatusConflict, errResp("username or email already exists"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
teamID, role := inv.teamID, inv.role
|
||||||
|
if !hasInvite {
|
||||||
|
// Open sign-up makes a team, and its creator owns it.
|
||||||
|
if err := tx.QueryRowContext(r.Context(),
|
||||||
|
"INSERT INTO teams (name) VALUES ($1) RETURNING id", req.TeamName).Scan(&teamID); err != nil {
|
||||||
|
if isUniqueViolation(err) {
|
||||||
|
respond(w, http.StatusConflict, errResp("a team with that name already exists"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
role = models.RoleOwner
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(r.Context(),
|
||||||
|
"INSERT INTO team_members (team_id, user_id, role) VALUES ($1, $2, $3)",
|
||||||
|
teamID, userID, role); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasInvite {
|
||||||
|
// Counted inside the transaction, so two people redeeming the last
|
||||||
|
// use of a link at once cannot both get in.
|
||||||
|
res, err := tx.ExecContext(r.Context(),
|
||||||
|
"UPDATE invites SET uses = uses + 1 WHERE id = $1 AND uses < max_uses", inv.id)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
respond(w, http.StatusForbidden, errResp("this invite link is not usable"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signed in immediately: the alternative is a form that says "now go
|
||||||
|
// and log in", which is the same credential typed twice.
|
||||||
|
if err := startSession(w, r, db, userID, publicURL); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, _ := fetchUser(r.Context(), db, userID)
|
||||||
|
respond(w, http.StatusCreated, meResponse{User: user, HasPassword: true})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxSignupsPerAddr is looser than the login limit: several people joining from
|
||||||
|
// one office share an address, and the thing being limited is account creation
|
||||||
|
// rather than password guessing.
|
||||||
|
const maxSignupsPerAddr = 10
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Invites
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type inviteJSON struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
TeamID int64 `json:"team_id"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
MaxUses int64 `json:"max_uses"`
|
||||||
|
Uses int64 `json:"uses"`
|
||||||
|
Revoked bool `json:"revoked"`
|
||||||
|
|
||||||
|
// URL is the whole link, returned once when the invite is created. Like an
|
||||||
|
// integration key, only its hash is stored.
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleListInvites(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
teamID, ok := teamParam(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireTeamOwner(w, r, teamID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.QueryContext(r.Context(), `
|
||||||
|
SELECT id, team_id, role, created_at, expires_at, max_uses, uses, revoked_at
|
||||||
|
FROM invites
|
||||||
|
WHERE team_id = $1
|
||||||
|
ORDER BY id DESC`, teamID)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []inviteJSON{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i inviteJSON
|
||||||
|
var created, expires int64
|
||||||
|
var revoked *int64
|
||||||
|
if err := rows.Scan(&i.ID, &i.TeamID, &i.Role, &created, &expires,
|
||||||
|
&i.MaxUses, &i.Uses, &revoked); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
i.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
|
i.ExpiresAt = time.Unix(expires, 0).UTC()
|
||||||
|
i.Revoked = revoked != nil
|
||||||
|
out = append(out, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreateInvite mints a link into this team. Owner-only, like the rest of
|
||||||
|
// a team's configuration: deciding who joins is configuring the team.
|
||||||
|
func handleCreateInvite(db *sql.DB, publicURL string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
teamID, ok := teamParam(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireTeamOwner(w, r, teamID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
MaxUses int64 `json:"max_uses"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Role == "" {
|
||||||
|
req.Role = models.RoleMember
|
||||||
|
}
|
||||||
|
if req.Role != models.RoleOwner && req.Role != models.RoleMember {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("role must be owner or member"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.MaxUses == 0 {
|
||||||
|
req.MaxUses = 1
|
||||||
|
}
|
||||||
|
if req.MaxUses < 1 || req.MaxUses > 100 {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("max_uses must be between 1 and 100"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, hash, err := randomToken()
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
caller, _ := userFromContext(r.Context())
|
||||||
|
expires := time.Now().Add(inviteTTL)
|
||||||
|
|
||||||
|
var out inviteJSON
|
||||||
|
var created, expiresAt int64
|
||||||
|
if err := db.QueryRowContext(r.Context(), `
|
||||||
|
INSERT INTO invites (token_hash, team_id, role, created_by, expires_at, max_uses)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id, team_id, role, created_at, expires_at, max_uses, uses`,
|
||||||
|
hash, teamID, req.Role, caller.ID, expires.Unix(), req.MaxUses).
|
||||||
|
Scan(&out.ID, &out.TeamID, &out.Role, &created, &expiresAt, &out.MaxUses, &out.Uses); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
|
out.ExpiresAt = time.Unix(expiresAt, 0).UTC()
|
||||||
|
out.URL = strings.TrimSuffix(publicURL, "/") + "/signup?invite=" + raw
|
||||||
|
respond(w, http.StatusCreated, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRevokeInvite stops a link working without waiting for it to expire.
|
||||||
|
func handleRevokeInvite(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
teamID, ok := teamParam(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireTeamOwner(w, r, teamID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "inviteID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid invite id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := db.ExecContext(r.Context(),
|
||||||
|
"UPDATE invites SET revoked_at = "+nowEpoch+
|
||||||
|
" WHERE id = $1 AND team_id = $2 AND revoked_at IS NULL", id, teamID)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
respond(w, http.StatusNotFound, errResp("not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Onboarding
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// handleTestNotification publishes one push to the caller's own topic.
|
||||||
|
//
|
||||||
|
// The point of the first-run checklist's notification step is not that a topic
|
||||||
|
// string has been typed but that a phone buzzes, and only the person holding it
|
||||||
|
// can tell whether it did. Published directly rather than through the outbox:
|
||||||
|
// the outbox row requires an incident, and this deliberately belongs to no
|
||||||
|
// incident.
|
||||||
|
func handleTestNotification(cfg NotifyConfig, db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if cfg.BaseURL == "" {
|
||||||
|
respond(w, http.StatusServiceUnavailable,
|
||||||
|
errResp("this server has no ntfy configured, so it can send nothing"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
caller, _ := userFromContext(r.Context())
|
||||||
|
|
||||||
|
var topic *string
|
||||||
|
if err := db.QueryRowContext(r.Context(),
|
||||||
|
"SELECT ntfy_topic FROM users WHERE id = $1", caller.ID).Scan(&topic); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if topic == nil || *topic == "" {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("set a notification topic first"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := publish(r.Context(), cfg, ntfyMessage{
|
||||||
|
Topic: *topic,
|
||||||
|
Title: "terdut test",
|
||||||
|
Message: "If this arrived, your notifications work.",
|
||||||
|
Tags: []string{"white_check_mark"},
|
||||||
|
}); err != nil {
|
||||||
|
// The failure is the useful part here: a wrong topic, a token the
|
||||||
|
// ntfy server rejects, or an ntfy that is down all look the same
|
||||||
|
// from the phone, which is silence.
|
||||||
|
respond(w, http.StatusBadGateway, errResp("ntfy rejected the test: "+err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDismissOnboarding hides the first-run checklist, or brings it back.
|
||||||
|
// Stored per user rather than in the browser: somebody who finishes setting up
|
||||||
|
// on a laptop should not be nagged again on their phone.
|
||||||
|
func handleDismissOnboarding(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Dismissed *bool `json:"dismissed"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil || req.Dismissed == nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("dismissed is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
caller, _ := userFromContext(r.Context())
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if *req.Dismissed {
|
||||||
|
_, err = db.ExecContext(r.Context(),
|
||||||
|
"UPDATE users SET onboarding_dismissed_at = "+nowEpoch+" WHERE id = $1", caller.ID)
|
||||||
|
} else {
|
||||||
|
_, err = db.ExecContext(r.Context(),
|
||||||
|
"UPDATE users SET onboarding_dismissed_at = NULL WHERE id = $1", caller.ID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// signup posts to the unauthenticated sign-up endpoint, the way the form does,
|
||||||
|
// and returns the response and a client holding whatever cookie came back.
|
||||||
|
func signup(t *testing.T, s *ts, body map[string]any) (*http.Response, *http.Client) {
|
||||||
|
t.Helper()
|
||||||
|
data, _ := json.Marshal(body)
|
||||||
|
jar, _ := cookiejar.New(nil)
|
||||||
|
client := &http.Client{Jar: jar}
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, s.URL+"/api/signup", bytes.NewReader(data))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("signup: %v", err)
|
||||||
|
}
|
||||||
|
return resp, client
|
||||||
|
}
|
||||||
|
|
||||||
|
// invite mints a link into the default team and returns its raw token.
|
||||||
|
func invite(t *testing.T, s *ts, role string, maxUses int64) string {
|
||||||
|
t.Helper()
|
||||||
|
var out struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/invites",
|
||||||
|
map[string]any{"role": role, "max_uses": maxUses}), &out)
|
||||||
|
if out.URL == "" {
|
||||||
|
t.Fatal("no invite URL returned")
|
||||||
|
}
|
||||||
|
// ...?invite=<token>
|
||||||
|
i := len(out.URL) - 1
|
||||||
|
for ; i >= 0 && out.URL[i] != '='; i-- {
|
||||||
|
}
|
||||||
|
return out.URL[i+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSignupMode(t *testing.T, s *ts, mode string) {
|
||||||
|
t.Helper()
|
||||||
|
resp := s.req(t, http.MethodPut, "/api/admin/settings", map[string]any{"signup_mode": mode})
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("set signup mode: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The default is the closed door. An install that gets a public hostname before
|
||||||
|
// anybody has thought about sign-up should not be collecting accounts.
|
||||||
|
func TestSignup_InviteOnlyByDefault(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
|
||||||
|
var info map[string]any
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/signup", nil), &info)
|
||||||
|
if info["mode"] != "invite_only" {
|
||||||
|
t.Errorf("default sign-up mode is %v, want invite_only", info["mode"])
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, _ := signup(t, s, map[string]any{
|
||||||
|
"username": "stranger", "email": "s@test.com", "password": "correct-horse-battery",
|
||||||
|
})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("sign-up without an invite: expected 403, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An invite carries the team and the role, so redeeming one lands somewhere
|
||||||
|
// usable rather than in an account that sees an empty queue.
|
||||||
|
func TestSignup_InviteCreatesAMemberOfThatTeam(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
token := invite(t, s, "member", 1)
|
||||||
|
|
||||||
|
// The form checks the link before asking for a password.
|
||||||
|
var info map[string]any
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/signup?invite="+token, nil), &info)
|
||||||
|
if info["invite_valid"] != true {
|
||||||
|
t.Fatalf("a fresh invite should be valid: %v", info)
|
||||||
|
}
|
||||||
|
if info["invite_team"] != "Default" {
|
||||||
|
t.Errorf("the form should name the team: %v", info["invite_team"])
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, client := signup(t, s, map[string]any{
|
||||||
|
"username": "newcomer", "email": "n@test.com",
|
||||||
|
"password": "correct-horse-battery", "invite": token,
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("redeeming an invite: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var me struct {
|
||||||
|
User struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
IsAdmin bool `json:"is_admin"`
|
||||||
|
} `json:"user"`
|
||||||
|
}
|
||||||
|
decode(t, resp, &me)
|
||||||
|
if me.User.IsAdmin {
|
||||||
|
t.Error("somebody who signs up must not be an administrator")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signed in already: the cookie came back with the response.
|
||||||
|
got, err := client.Get(s.URL + "/api/teams")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
teams := list(t, got)
|
||||||
|
if len(teams) != 1 || teams[0]["name"] != "Default" || teams[0]["role"] != "member" {
|
||||||
|
t.Errorf("expected membership of Default as member, got %v", teams)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single-use link is single-use, and the check is inside the transaction so
|
||||||
|
// two people redeeming the last use at once cannot both get in.
|
||||||
|
func TestSignup_InviteCannotBeUsedTwice(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
token := invite(t, s, "member", 1)
|
||||||
|
|
||||||
|
first, _ := signup(t, s, map[string]any{
|
||||||
|
"username": "first", "email": "f@test.com",
|
||||||
|
"password": "correct-horse-battery", "invite": token,
|
||||||
|
})
|
||||||
|
first.Body.Close()
|
||||||
|
if first.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("first redemption: %d", first.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
second, _ := signup(t, s, map[string]any{
|
||||||
|
"username": "second", "email": "s@test.com",
|
||||||
|
"password": "correct-horse-battery", "invite": token,
|
||||||
|
})
|
||||||
|
second.Body.Close()
|
||||||
|
if second.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("second redemption: expected 403, got %d", second.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the link reports itself unusable before anybody types a password.
|
||||||
|
var info map[string]any
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/signup?invite="+token, nil), &info)
|
||||||
|
if info["invite_valid"] != false {
|
||||||
|
t.Error("a used-up invite should report itself invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoking stops a link without waiting for it to expire.
|
||||||
|
func TestSignup_RevokedInviteStopsWorking(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
token := invite(t, s, "member", 5)
|
||||||
|
|
||||||
|
invites := list(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/invites", nil))
|
||||||
|
if len(invites) != 1 {
|
||||||
|
t.Fatalf("expected one invite, got %d", len(invites))
|
||||||
|
}
|
||||||
|
id := int64(invites[0]["id"].(float64))
|
||||||
|
|
||||||
|
resp := s.req(t, http.MethodDelete, "/api/teams/"+defaultTeam+"/invites/"+id64(id), nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("revoke: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
used, _ := signup(t, s, map[string]any{
|
||||||
|
"username": "late", "email": "l@test.com",
|
||||||
|
"password": "correct-horse-battery", "invite": token,
|
||||||
|
})
|
||||||
|
used.Body.Close()
|
||||||
|
if used.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("a revoked invite: expected 403, got %d", used.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open sign-up makes a team, because an account in no team sees an empty queue
|
||||||
|
// and can be paged by nobody.
|
||||||
|
func TestSignup_OpenModeMakesATeam(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
setSignupMode(t, s, "open")
|
||||||
|
|
||||||
|
missing, _ := signup(t, s, map[string]any{
|
||||||
|
"username": "solo", "email": "s@test.com", "password": "correct-horse-battery",
|
||||||
|
})
|
||||||
|
missing.Body.Close()
|
||||||
|
if missing.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("open sign-up with no team name: expected 400, got %d", missing.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, client := signup(t, s, map[string]any{
|
||||||
|
"username": "solo", "email": "s@test.com",
|
||||||
|
"password": "correct-horse-battery", "team_name": "Solo",
|
||||||
|
})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("open sign-up: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := client.Get(s.URL + "/api/teams")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
teams := list(t, got)
|
||||||
|
if len(teams) != 1 || teams[0]["name"] != "Solo" || teams[0]["role"] != "owner" {
|
||||||
|
t.Errorf("the creator should own their new team, got %v", teams)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switching the mode is an administrator's decision, and it takes effect at
|
||||||
|
// once rather than at the next restart.
|
||||||
|
func TestSignup_ModeIsAnAdminSetting(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
_, call := member(t, s, "plain")
|
||||||
|
|
||||||
|
resp := call(http.MethodPut, "/api/admin/settings", map[string]any{"signup_mode": "open"})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("a member changing the mode: expected 403, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
bad := s.req(t, http.MethodPut, "/api/admin/settings", map[string]any{"signup_mode": "everybody"})
|
||||||
|
bad.Body.Close()
|
||||||
|
if bad.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("an unknown mode: expected 400, got %d", bad.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
setSignupMode(t, s, "open")
|
||||||
|
var info map[string]any
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/signup", nil), &info)
|
||||||
|
if info["mode"] != "open" {
|
||||||
|
t.Errorf("the change should be visible at once, got %v", info["mode"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minting a link is configuring the team, so it is an owner's job.
|
||||||
|
func TestSignup_InvitesAreOwnerOnly(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
_, call := member(t, s, "plain")
|
||||||
|
|
||||||
|
resp := call(http.MethodPost, "/api/teams/"+defaultTeam+"/invites", map[string]any{"role": "member"})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("a member minting an invite: expected 403, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A password still has to be a password, and a taken username is still taken.
|
||||||
|
func TestSignup_ValidatesLikeTheRestOfTheServer(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
token := invite(t, s, "member", 5)
|
||||||
|
|
||||||
|
short, _ := signup(t, s, map[string]any{
|
||||||
|
"username": "shorty", "email": "sh@test.com", "password": "abc", "invite": token,
|
||||||
|
})
|
||||||
|
short.Body.Close()
|
||||||
|
if short.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("a short password: expected 400, got %d", short.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
taken, _ := signup(t, s, map[string]any{
|
||||||
|
"username": "admin", "email": "other@test.com",
|
||||||
|
"password": "correct-horse-battery", "invite": token,
|
||||||
|
})
|
||||||
|
taken.Body.Close()
|
||||||
|
if taken.StatusCode != http.StatusConflict {
|
||||||
|
t.Errorf("an existing username: expected 409, got %d", taken.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,68 @@ func handleListTeams(db *sql.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleUserTeams lists one user's teams, for the admin page's per-user view:
|
||||||
|
// "what is this person in", which /api/teams cannot answer because it is always
|
||||||
|
// about the caller.
|
||||||
|
//
|
||||||
|
// Self or admin, matching the other per-user endpoints. It says which teams
|
||||||
|
// somebody belongs to and in what role — not anything those teams own, so it
|
||||||
|
// stays on the accounts side of the line the administrator flag draws.
|
||||||
|
func handleUserTeams(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireSelfOrAdmin(w, r, id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// A user with no teams and a user who does not exist both list nothing,
|
||||||
|
// so the existence check is what tells them apart.
|
||||||
|
var exists bool
|
||||||
|
if err := db.QueryRowContext(r.Context(),
|
||||||
|
"SELECT EXISTS (SELECT 1 FROM users WHERE id = $1)", id).Scan(&exists); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
respond(w, http.StatusNotFound, errResp("user not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.QueryContext(r.Context(), `
|
||||||
|
SELECT t.id, t.name, t.created_at, m.role
|
||||||
|
FROM teams t
|
||||||
|
JOIN team_members m ON m.team_id = t.id
|
||||||
|
WHERE m.user_id = $1
|
||||||
|
ORDER BY t.name`, id)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
teams := []models.Team{}
|
||||||
|
for rows.Next() {
|
||||||
|
var t models.Team
|
||||||
|
var created int64
|
||||||
|
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Role); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
|
teams = append(teams, t)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, teams)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// handleCreateTeam creates a team and makes its creator the first owner. A team
|
// handleCreateTeam creates a team and makes its creator the first owner. A team
|
||||||
// with no owner would need an administrator to repair before anybody could use
|
// with no owner would need an administrator to repair before anybody could use
|
||||||
// it, so the two happen in one transaction.
|
// it, so the two happen in one transaction.
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
-- Self-service sign-up, and the invite links that make it useful.
|
||||||
|
--
|
||||||
|
-- Until now the only way to get an account was for somebody who already had one
|
||||||
|
-- to create it, and the login page told people to "ask an admin". That is a
|
||||||
|
-- workable arrangement for one operator and an impossible one for a team.
|
||||||
|
--
|
||||||
|
-- An invite is a link, not an email: this server has no SMTP and adding it to
|
||||||
|
-- send one message would be a new subsystem to run, secure and monitor. The
|
||||||
|
-- person inviting sends the link however they already talk to the person they
|
||||||
|
-- are inviting.
|
||||||
|
CREATE TABLE invites (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
|
||||||
|
-- SHA-256 of the raw token, like api_keys, the integration keys and the
|
||||||
|
-- acknowledgement tokens. A leaked database hands nobody an account.
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
|
||||||
|
-- Which team the invitee lands in, and as what. An invite always names a
|
||||||
|
-- team: an account in no team sees an empty queue and can be paged by
|
||||||
|
-- nobody, which is not a state to invite somebody into.
|
||||||
|
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('owner', 'member')),
|
||||||
|
|
||||||
|
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
|
||||||
|
|
||||||
|
-- Invites expire. A link that works forever is a credential nobody
|
||||||
|
-- remembers issuing, sitting in a chat log.
|
||||||
|
expires_at BIGINT NOT NULL,
|
||||||
|
|
||||||
|
-- Single-use by default: max_uses 1. A team onboarding six people at once
|
||||||
|
-- can raise it rather than minting six links.
|
||||||
|
max_uses BIGINT NOT NULL DEFAULT 1 CHECK (max_uses > 0 AND max_uses <= 100),
|
||||||
|
uses BIGINT NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Revoked by hand, separately from expiry, so "this link is no longer
|
||||||
|
-- wanted" and "this link timed out" stay distinguishable in the listing.
|
||||||
|
revoked_at BIGINT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX invites_team_idx ON invites(team_id);
|
||||||
|
|
||||||
|
-- Who redeemed which invite. Kept after the invite is gone — the answer to "how
|
||||||
|
-- did this account get here" should outlive the link that made it.
|
||||||
|
ALTER TABLE users ADD COLUMN invited_via BIGINT REFERENCES invites(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Where a person is in the first-run checklist, so it can be resumed and
|
||||||
|
-- dismissed rather than nagging forever. One row per user, created on demand.
|
||||||
|
ALTER TABLE users ADD COLUMN onboarding_dismissed_at BIGINT;
|
||||||
@@ -199,7 +199,12 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
|||||||
/* Bottom tab bar on phones. */
|
/* Bottom tab bar on phones. */
|
||||||
.nav {
|
.nav {
|
||||||
position: fixed; left: 0; right: 0; bottom: 0; z-index: 20;
|
position: fixed; left: 0; right: 0; bottom: 0; z-index: 20;
|
||||||
display: grid; grid-template-columns: repeat(4, 1fr);
|
/* One column per link, however many there are. This was repeat(4, 1fr) when
|
||||||
|
there were four tabs; Team and Admin arriving pushed six items into four
|
||||||
|
columns, which on a phone is how they stopped fitting. Auto columns mean
|
||||||
|
the next tab cannot break the row either — and Admin is only rendered for
|
||||||
|
an administrator, so the count genuinely varies between viewers. */
|
||||||
|
display: grid; grid-auto-flow: column; grid-auto-columns: 1fr;
|
||||||
height: calc(var(--tabbar-h) + var(--safe-bottom));
|
height: calc(var(--tabbar-h) + var(--safe-bottom));
|
||||||
padding-bottom: var(--safe-bottom);
|
padding-bottom: var(--safe-bottom);
|
||||||
background: color-mix(in srgb, var(--surface) 92%, transparent);
|
background: color-mix(in srgb, var(--surface) 92%, transparent);
|
||||||
@@ -212,8 +217,25 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
|||||||
position: relative;
|
position: relative;
|
||||||
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
|
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
|
||||||
color: var(--faint); font-size: 11px; font-weight: 600;
|
color: var(--faint); font-size: 11px; font-weight: 600;
|
||||||
|
/* min-width lets a column shrink below its label's natural width, which is
|
||||||
|
what stops six tabs widening the bar past the screen. */
|
||||||
|
min-width: 0; padding: 0 2px;
|
||||||
}
|
}
|
||||||
.nav-link svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
.nav-label {
|
||||||
|
max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.nav-link svg { width: 24px; height: 24px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||||||
|
|
||||||
|
/* Narrow phones, where six tabs each get about 55-65px. Tightening is enough:
|
||||||
|
the widest label, "On-call", is about 38px at this size, so nothing has to
|
||||||
|
be hidden. The ellipsis above is the backstop if a future tab is named
|
||||||
|
something longer. */
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.nav-link { font-size: 10px; gap: 1px; }
|
||||||
|
.nav-link svg { width: 21px; height: 21px; }
|
||||||
|
.nav-badge { left: calc(50% + 4px); min-width: 16px; height: 16px; font-size: 10px; line-height: 16px; }
|
||||||
|
}
|
||||||
|
|
||||||
.nav-link[aria-current="page"] { color: var(--accent); }
|
.nav-link[aria-current="page"] { color: var(--accent); }
|
||||||
.nav-badge {
|
.nav-badge {
|
||||||
position: absolute; top: 6px; left: calc(50% + 6px);
|
position: absolute; top: 6px; left: calc(50% + 6px);
|
||||||
@@ -658,6 +680,42 @@ kbd {
|
|||||||
.admin-settings button[type="submit"] { margin-top: 12px; }
|
.admin-settings button[type="submit"] { margin-top: 12px; }
|
||||||
.small { font-size: 13px; }
|
.small { font-size: 13px; }
|
||||||
|
|
||||||
|
/* The name in the user list is the way to that person's page. */
|
||||||
|
.user-link { color: var(--text); font-weight: 650; text-decoration: none; }
|
||||||
|
.user-link:hover { color: var(--accent); text-decoration: underline; }
|
||||||
|
|
||||||
|
.invite-block { margin-top: 20px; border-top: 1px solid var(--border); padding-top: 12px; }
|
||||||
|
.invite-block h3 { margin: 0 0 4px; font-size: 14px; }
|
||||||
|
/* The link is shown once and never stored, so it has to be selectable and
|
||||||
|
wrap rather than scroll off the side of a phone. */
|
||||||
|
.invite-out { margin-top: 12px; font-size: 13px; }
|
||||||
|
.invite-link {
|
||||||
|
display: block; margin-top: 6px; padding: 8px; border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface-2); font-family: var(--mono); font-size: 12px;
|
||||||
|
word-break: break-all; user-select: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- one user ------------------------------------------------------------ */
|
||||||
|
.back-link {
|
||||||
|
display: inline-flex; align-items: center; gap: 2px; margin-bottom: 12px;
|
||||||
|
color: var(--muted); font-size: 14px; text-decoration: none;
|
||||||
|
}
|
||||||
|
.back-link:hover { color: var(--text); }
|
||||||
|
.back-link svg { width: 18px; height: 18px; }
|
||||||
|
|
||||||
|
.user-head { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.user-head h2 { margin: 0; }
|
||||||
|
|
||||||
|
.user-facts {
|
||||||
|
display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px;
|
||||||
|
margin: 12px 0 0; font-size: 14px;
|
||||||
|
}
|
||||||
|
.user-facts dt { color: var(--muted); }
|
||||||
|
.user-facts dd { margin: 0; overflow-wrap: anywhere; }
|
||||||
|
|
||||||
|
.row-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
|
||||||
|
.admin-table .row-actions { margin-top: 0; gap: 6px; }
|
||||||
|
|
||||||
/* --- team settings -------------------------------------------------------
|
/* --- team settings -------------------------------------------------------
|
||||||
Forms with a label above each control, rather than the queue's rows of
|
Forms with a label above each control, rather than the queue's rows of
|
||||||
links. The escalation ladder is the only nested structure in the app, so it
|
links. The escalation ladder is the only nested structure in the app, so it
|
||||||
@@ -687,3 +745,23 @@ kbd {
|
|||||||
border-radius: 6px; padding: 8px; font-size: 12px;
|
border-radius: 6px; padding: 8px; font-size: 12px;
|
||||||
}
|
}
|
||||||
.key-url code { word-break: break-all; }
|
.key-url code { word-break: break-all; }
|
||||||
|
|
||||||
|
/* --- onboarding checklist ------------------------------------------------
|
||||||
|
Sits above the queue until it is finished or hidden. Deliberately plain:
|
||||||
|
it is a list of things to do, not a celebration. */
|
||||||
|
.onboarding { border-left: 3px solid var(--accent); }
|
||||||
|
.onboarding-head { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.onboarding-head h2 { flex: 1; margin: 0; }
|
||||||
|
.checklist { list-style: none; margin: 12px 0 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.checklist .step { display: flex; gap: 10px; align-items: flex-start; }
|
||||||
|
.checklist .step p { margin: 2px 0 0; }
|
||||||
|
.step-mark {
|
||||||
|
flex: none; width: 20px; height: 20px; border-radius: 50%;
|
||||||
|
border: 1px solid var(--border-strong); color: var(--accent);
|
||||||
|
display: flex; align-items: center; justify-content: center; font-size: 13px;
|
||||||
|
}
|
||||||
|
.step.done .step-mark { border-color: var(--accent); }
|
||||||
|
.step.done > div > strong { color: var(--muted); text-decoration: line-through; }
|
||||||
|
.step-actions { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
.signup-intro { margin: 0 0 4px; font-size: 14px; color: var(--muted); }
|
||||||
|
|||||||
@@ -37,6 +37,37 @@
|
|||||||
<button class="btn btn-primary btn-block" type="submit">Sign in</button>
|
<button class="btn btn-primary btn-block" type="submit">Sign in</button>
|
||||||
<p class="login-hint">No password yet? Ask an admin to set one, or run
|
<p class="login-hint">No password yet? Ask an admin to set one, or run
|
||||||
<code>PUT /api/users/{id}/password</code> with your API key.</p>
|
<code>PUT /api/users/{id}/password</code> with your API key.</p>
|
||||||
|
<p class="login-hint" id="signup-link" hidden>
|
||||||
|
No account? <a href="/signup">Create one</a>.</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Sign-up. Shown instead of the login card at /signup, and only offers
|
||||||
|
what the server allows: an invite link, or open sign-up. -->
|
||||||
|
<form id="signup-form" class="login-card" autocomplete="on" hidden>
|
||||||
|
<div class="login-brand">
|
||||||
|
<img src="/icon.svg" alt="" width="40" height="40">
|
||||||
|
<h1>terdut</h1>
|
||||||
|
</div>
|
||||||
|
<p class="signup-intro" id="signup-intro"></p>
|
||||||
|
<label>
|
||||||
|
<span>Username</span>
|
||||||
|
<input name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Email</span>
|
||||||
|
<input name="email" type="email" autocomplete="email" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Password</span>
|
||||||
|
<input name="password" type="password" autocomplete="new-password" minlength="10" required>
|
||||||
|
</label>
|
||||||
|
<label id="signup-team-label" hidden>
|
||||||
|
<span>Team name</span>
|
||||||
|
<input name="team_name" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
<p class="form-error" role="alert" hidden></p>
|
||||||
|
<button class="btn btn-primary btn-block" type="submit">Create account</button>
|
||||||
|
<p class="login-hint">Already have one? <a href="/">Sign in</a>.</p>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -46,31 +77,31 @@
|
|||||||
<img src="/icon.svg" alt="" width="28" height="28">
|
<img src="/icon.svg" alt="" width="28" height="28">
|
||||||
<span>terdut</span>
|
<span>terdut</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="nav-link" href="/" data-section="queue">
|
<a class="nav-link" href="/" data-section="queue" aria-label="Queue">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10"/></svg>
|
||||||
<span class="nav-label">Queue</span>
|
<span class="nav-label">Queue</span>
|
||||||
<span class="nav-badge" data-badge hidden></span>
|
<span class="nav-badge" data-badge hidden></span>
|
||||||
</a>
|
</a>
|
||||||
<a class="nav-link" href="/oncall" data-section="oncall">
|
<a class="nav-link" href="/oncall" data-section="oncall" aria-label="On-call">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="5" width="17" height="15" rx="2"/><path d="M3.5 10h17M8 3v4M16 3v4"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="5" width="17" height="15" rx="2"/><path d="M3.5 10h17M8 3v4M16 3v4"/></svg>
|
||||||
<span class="nav-label">On-call</span>
|
<span class="nav-label">On-call</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="nav-link" href="/alerts" data-section="alerts">
|
<a class="nav-link" href="/alerts" data-section="alerts" aria-label="Alerts">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z"/><path d="M10 20.5a2 2 0 0 0 4 0"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z"/><path d="M10 20.5a2 2 0 0 0 4 0"/></svg>
|
||||||
<span class="nav-label">Alerts</span>
|
<span class="nav-label">Alerts</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="nav-link" href="/team" data-section="team">
|
<a class="nav-link" href="/team" data-section="team" aria-label="Team">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="9" cy="8" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M3 19a6 6 0 0 1 12 0M15 19a5 5 0 0 1 6-4"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="9" cy="8" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M3 19a6 6 0 0 1 12 0M15 19a5 5 0 0 1 6-4"/></svg>
|
||||||
<span class="nav-label">Team</span>
|
<span class="nav-label">Team</span>
|
||||||
</a>
|
</a>
|
||||||
<!-- Hidden unless the signed-in user is a system administrator; app.js
|
<!-- Hidden unless the signed-in user is a system administrator; app.js
|
||||||
unhides it once /api/me says so. The server refuses every admin
|
unhides it once /api/me says so. The server refuses every admin
|
||||||
endpoint regardless, so this is a courtesy and not a gate. -->
|
endpoint regardless, so this is a courtesy and not a gate. -->
|
||||||
<a class="nav-link" href="/admin" data-section="admin" id="nav-admin" hidden>
|
<a class="nav-link" href="/admin" data-section="admin" aria-label="Admin" id="nav-admin" hidden>
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6z"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6z"/></svg>
|
||||||
<span class="nav-label">Admin</span>
|
<span class="nav-label">Admin</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="nav-link" href="/more" data-section="more">
|
<a class="nav-link" href="/more" data-section="more" aria-label="Account">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="3.5"/><path d="M5 20a7 7 0 0 1 14 0"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="3.5"/><path d="M5 20a7 7 0 0 1 14 0"/></svg>
|
||||||
<span class="nav-label">Account</span>
|
<span class="nav-label">Account</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -93,6 +124,9 @@
|
|||||||
<section id="view-alerts" class="view view-page" data-view="alerts" hidden></section>
|
<section id="view-alerts" class="view view-page" data-view="alerts" hidden></section>
|
||||||
<section id="view-team" class="view view-page" data-view="team" hidden></section>
|
<section id="view-team" class="view view-page" data-view="team" hidden></section>
|
||||||
<section id="view-admin" class="view view-page" data-view="admin" hidden></section>
|
<section id="view-admin" class="view view-page" data-view="admin" hidden></section>
|
||||||
|
<!-- One person, at /admin/users/{id}: reached from the Admin tab's user
|
||||||
|
list, and a section of its own so a deep link survives a reload. -->
|
||||||
|
<section id="view-adminuser" class="view view-page" data-view="adminuser" hidden></section>
|
||||||
<section id="view-more" class="view view-page" data-view="more" hidden></section>
|
<section id="view-more" class="view view-page" data-view="more" hidden></section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -155,7 +155,9 @@ function usersCard() {
|
|||||||
const self = u.id === myID();
|
const self = u.id === myID();
|
||||||
return h('tr', { class: u.disabled_at ? 'disabled-row' : '' },
|
return h('tr', { class: u.disabled_at ? 'disabled-row' : '' },
|
||||||
h('td', {},
|
h('td', {},
|
||||||
h('strong', { text: u.username }),
|
// The name is the way in: everything about one person lives on their
|
||||||
|
// own page, and this table stays a list rather than becoming a form.
|
||||||
|
h('a', { class: 'user-link', href: `/admin/users/${u.id}`, text: u.username }),
|
||||||
u.disabled_at && h('span', { class: 'row-team', text: 'disabled' }),
|
u.disabled_at && h('span', { class: 'row-team', text: 'disabled' }),
|
||||||
self && h('span', { class: 'you', text: 'you' })),
|
self && h('span', { class: 'you', text: 'you' })),
|
||||||
h('td', { class: 'muted', text: u.email }),
|
h('td', { class: 'muted', text: u.email }),
|
||||||
@@ -184,7 +186,8 @@ function usersCard() {
|
|||||||
h('h2', { text: 'Users' }),
|
h('h2', { text: 'Users' }),
|
||||||
h('p', { class: 'muted small' },
|
h('p', { class: 'muted small' },
|
||||||
'Disabling an account stops it signing in and stops its API keys, and keeps ',
|
'Disabling an account stops it signing in and stops its API keys, and keeps ',
|
||||||
'its acknowledgements and timeline entries. Deleting a user erases those.'),
|
'its acknowledgements and timeline entries. Deleting a user erases those. ',
|
||||||
|
'Open a name for their teams, their password and the rest.'),
|
||||||
h('table', { class: 'admin-table' },
|
h('table', { class: 'admin-table' },
|
||||||
h('thead', {}, h('tr', {},
|
h('thead', {}, h('tr', {},
|
||||||
h('th', { text: 'User' }),
|
h('th', { text: 'User' }),
|
||||||
@@ -192,6 +195,57 @@ function usersCard() {
|
|||||||
h('th', { text: '' }),
|
h('th', { text: '' }),
|
||||||
h('th', { text: '' }))),
|
h('th', { text: '' }))),
|
||||||
h('tbody', {}, rows)),
|
h('tbody', {}, rows)),
|
||||||
|
inviteForm(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adding a person is minting them an invite, not creating a row. The account
|
||||||
|
// is created by whoever accepts it, so they pick their own password and it
|
||||||
|
// never passes through an administrator — and the link carries the team, which
|
||||||
|
// a bare POST /api/users cannot, leaving an account with nothing to work on.
|
||||||
|
//
|
||||||
|
// Minting for a team the administrator is not in is allowed: the flag passes
|
||||||
|
// every team-owner check, so a server administrator can staff any team. The
|
||||||
|
// link shows up in that team's own invite list, where an owner can revoke it.
|
||||||
|
function inviteForm() {
|
||||||
|
const team = h('select', {},
|
||||||
|
...data.teams.map((t) => h('option', { value: String(t.id), text: t.name })));
|
||||||
|
const role = h('select', {},
|
||||||
|
h('option', { value: 'member', text: 'member' }),
|
||||||
|
h('option', { value: 'owner', text: 'owner' }));
|
||||||
|
const out = h('p', { class: 'invite-out', hidden: true });
|
||||||
|
|
||||||
|
const form = h('form', { class: 'inline-form' }, team, role,
|
||||||
|
h('button', { class: 'btn', type: 'submit', text: 'Create invite' }));
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const inv = await api.createInvite(Number(team.value), role.value, 1);
|
||||||
|
// Shown once and never stored, so it is put on the page to be copied
|
||||||
|
// rather than toasted away after three seconds.
|
||||||
|
clear(out, h('strong', { text: 'Send them this link. It is shown once.' }),
|
||||||
|
h('code', { class: 'invite-link', text: inv.url }));
|
||||||
|
out.hidden = false;
|
||||||
|
error = null;
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return h('div', { class: 'invite-block' },
|
||||||
|
h('h3', { text: 'Add someone' }),
|
||||||
|
h('p', { class: 'muted small' },
|
||||||
|
'An invite link puts them in a team and lets them choose their own ',
|
||||||
|
'password. It lasts a week and can be used once.'),
|
||||||
|
data.teams.length > 0 ? form
|
||||||
|
: h('p', { class: 'muted small', text: 'Create a team first — an invite has to lead somewhere.' }),
|
||||||
|
out,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
// One person, at /admin/users/{id}: what they are, what they are in, and the
|
||||||
|
// levers an administrator has over the account.
|
||||||
|
//
|
||||||
|
// A section of its own rather than an expanding row in the Admin tab's table,
|
||||||
|
// because memberships and the account actions together are more than a row can
|
||||||
|
// hold and still be read on a phone.
|
||||||
|
//
|
||||||
|
// Like the Admin tab, this hides nothing the server would allow and shows
|
||||||
|
// nothing it would refuse: every write here is an endpoint that answers 403
|
||||||
|
// without the flag, so the view is a description of the rules rather than an
|
||||||
|
// enforcement of them.
|
||||||
|
|
||||||
|
import * as api from './api.js';
|
||||||
|
import { h, clear, spinner, confirm, toast, icon } from './ui.js';
|
||||||
|
import { state, myID } from './state.js';
|
||||||
|
import { navigate } from './app.js';
|
||||||
|
import { when } from './format.js';
|
||||||
|
|
||||||
|
const view = () => document.getElementById('view-adminuser');
|
||||||
|
|
||||||
|
let userID = null;
|
||||||
|
let data = null; // { user, teams, allTeams }
|
||||||
|
let error = null;
|
||||||
|
let busy = false;
|
||||||
|
|
||||||
|
export function show(route) {
|
||||||
|
const next = route && route.user != null ? route.user : null;
|
||||||
|
if (next !== userID) {
|
||||||
|
userID = next;
|
||||||
|
data = null;
|
||||||
|
error = null;
|
||||||
|
}
|
||||||
|
if (!data) clear(view(), spinner());
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refresh() {
|
||||||
|
if (userID == null || !state.me?.user?.is_admin) {
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// The user comes from the list rather than a show endpoint: there is no
|
||||||
|
// GET /api/users/{id}, and adding one for a row the list already carries
|
||||||
|
// would be a second way to say the same thing.
|
||||||
|
const [users, teams, allTeams] = await Promise.all([
|
||||||
|
api.users(),
|
||||||
|
api.userTeams(userID),
|
||||||
|
api.adminTeams(),
|
||||||
|
]);
|
||||||
|
const user = users.find((u) => u.id === userID) || null;
|
||||||
|
data = { user, teams, allTeams };
|
||||||
|
error = null;
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const el = view();
|
||||||
|
if (!state.me?.user?.is_admin) {
|
||||||
|
clear(el, backLink(), h('div', { class: 'card' },
|
||||||
|
h('p', { class: 'muted', text: 'Administration is for system administrators. Ask one for access.' })));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data) {
|
||||||
|
clear(el, backLink(), error ? h('div', { class: 'load-error', text: error }) : spinner());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data.user) {
|
||||||
|
clear(el, backLink(), h('div', { class: 'card' },
|
||||||
|
h('p', { class: 'muted', text: 'No such user. They may have just been deleted.' })));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clear(el,
|
||||||
|
backLink(),
|
||||||
|
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||||
|
identityCard(),
|
||||||
|
teamsCard(),
|
||||||
|
accountCard(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function backLink() {
|
||||||
|
return h('a', { class: 'back-link', href: '/admin' }, icon('chevronLeft'), h('span', { text: 'Admin' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- identity --------------------------------------------------------------
|
||||||
|
|
||||||
|
function identityCard() {
|
||||||
|
const u = data.user;
|
||||||
|
const self = u.id === myID();
|
||||||
|
|
||||||
|
return h('div', { class: 'card' },
|
||||||
|
h('div', { class: 'user-head' },
|
||||||
|
h('h2', { text: u.username }),
|
||||||
|
u.is_admin && h('span', { class: 'row-team', text: 'admin' }),
|
||||||
|
u.disabled_at && h('span', { class: 'row-team', text: 'disabled' }),
|
||||||
|
self && h('span', { class: 'you', text: 'you' })),
|
||||||
|
h('dl', { class: 'user-facts' },
|
||||||
|
fact('Email', u.email),
|
||||||
|
fact('Joined', when(u.created_at)),
|
||||||
|
fact('Notifications', u.ntfy_topic ? `ntfy: ${u.ntfy_topic}` : 'None of their own'),
|
||||||
|
u.disabled_at && fact('Disabled', when(u.disabled_at)),
|
||||||
|
),
|
||||||
|
// Both of these refuse your own account, and the last administrator's. An
|
||||||
|
// enabled button that always fails is worse than no button.
|
||||||
|
h('div', { class: 'row-actions' },
|
||||||
|
!self && h('button', {
|
||||||
|
class: 'btn', type: 'button',
|
||||||
|
text: u.is_admin ? 'Revoke admin' : 'Make admin',
|
||||||
|
onclick: () => setAdmin(!u.is_admin),
|
||||||
|
}),
|
||||||
|
!self && h('button', {
|
||||||
|
class: 'btn', type: 'button',
|
||||||
|
text: u.disabled_at ? 'Enable account' : 'Disable account',
|
||||||
|
onclick: () => setDisabled(!u.disabled_at),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
self && h('p', { class: 'muted small' },
|
||||||
|
'You cannot change your own administrator flag or disable yourself — ',
|
||||||
|
'that is how an install ends up with nobody who can administer it.'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fact(label, value) {
|
||||||
|
return [h('dt', { text: label }), h('dd', { text: value })];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setAdmin(next) {
|
||||||
|
if (next && !(await confirm({
|
||||||
|
title: `Make ${data.user.username} an administrator?`,
|
||||||
|
text: 'They will be able to manage every account, configure any team, and grant this to others.',
|
||||||
|
confirmLabel: 'Make admin',
|
||||||
|
}))) return;
|
||||||
|
await act(() => api.setUserAdmin(userID, next));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setDisabled(next) {
|
||||||
|
if (next && !(await confirm({
|
||||||
|
title: `Disable ${data.user.username}?`,
|
||||||
|
text: 'They cannot sign in and their API keys stop working. Their acknowledgements and timeline entries stay.',
|
||||||
|
confirmLabel: 'Disable',
|
||||||
|
danger: true,
|
||||||
|
}))) return;
|
||||||
|
await act(() => api.setUserDisabled(userID, next));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- teams -----------------------------------------------------------------
|
||||||
|
|
||||||
|
// An administrator passes every team-owner check without being in the team,
|
||||||
|
// which is what lets them repair a team whose owner has left. So this card
|
||||||
|
// edits, rather than reporting what somebody else would have to do.
|
||||||
|
//
|
||||||
|
// It is the one place membership can be changed from the person's side: the
|
||||||
|
// Team tab asks "who is in this team", and answering "which teams is this
|
||||||
|
// person in" there means visiting each team in turn.
|
||||||
|
function teamsCard() {
|
||||||
|
const rows = data.teams.map((t) =>
|
||||||
|
h('tr', {},
|
||||||
|
// Not a link: the Team tab always shows the viewer's own team, so
|
||||||
|
// sending them there from somebody else's membership would be a lie.
|
||||||
|
h('td', {}, h('strong', { text: t.name })),
|
||||||
|
h('td', { class: 'muted small', text: t.role }),
|
||||||
|
h('td', { class: 'row-actions' },
|
||||||
|
h('button', {
|
||||||
|
class: 'btn-sm', type: 'button',
|
||||||
|
text: t.role === 'owner' ? 'Make member' : 'Make owner',
|
||||||
|
onclick: () => act(() =>
|
||||||
|
api.addTeamMember(t.id, userID, t.role === 'owner' ? 'member' : 'owner')),
|
||||||
|
}),
|
||||||
|
h('button', {
|
||||||
|
class: 'btn-sm danger', type: 'button', text: 'Remove',
|
||||||
|
// The server refuses the last owner with a 409, which act() shows.
|
||||||
|
onclick: () => act(() => api.removeTeamMember(t.id, userID)),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
|
const inTeam = new Set(data.teams.map((t) => t.id));
|
||||||
|
const candidates = (data.allTeams || []).filter((t) => !inTeam.has(t.id));
|
||||||
|
const pick = h('select', {},
|
||||||
|
...candidates.map((t) => h('option', { value: String(t.id), text: t.name })));
|
||||||
|
const role = h('select', {},
|
||||||
|
h('option', { value: 'member', text: 'member' }),
|
||||||
|
h('option', { value: 'owner', text: 'owner' }));
|
||||||
|
const form = h('form', { class: 'inline-form' }, pick, role,
|
||||||
|
h('button', { class: 'btn', type: 'submit', text: 'Add' }));
|
||||||
|
form.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
act(() => api.addTeamMember(Number(pick.value), userID, role.value));
|
||||||
|
});
|
||||||
|
|
||||||
|
return h('div', { class: 'card' },
|
||||||
|
h('h2', { text: 'Teams' }),
|
||||||
|
data.teams.length === 0 && h('p', { class: 'muted small' },
|
||||||
|
'In no team. They can sign in, but there is no queue for them to work ',
|
||||||
|
'and nothing to page them about.'),
|
||||||
|
data.teams.length > 0 && h('table', { class: 'admin-table' }, h('tbody', {}, rows)),
|
||||||
|
candidates.length > 0 && form,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- account ---------------------------------------------------------------
|
||||||
|
|
||||||
|
function accountCard() {
|
||||||
|
const u = data.user;
|
||||||
|
const self = u.id === myID();
|
||||||
|
|
||||||
|
const pw = h('input', {
|
||||||
|
type: 'password', name: 'password', autocomplete: 'new-password',
|
||||||
|
minlength: '10', required: true, placeholder: 'At least 10 characters',
|
||||||
|
});
|
||||||
|
const form = h('form', { class: 'inline-form' }, pw,
|
||||||
|
h('button', { class: 'btn', type: 'submit', text: 'Set password' }));
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
// No current password: that check is for changing your own, and an
|
||||||
|
// administrator setting somebody else's does not know it by design.
|
||||||
|
await api.setPassword(userID, pw.value);
|
||||||
|
pw.value = '';
|
||||||
|
toast(`Password set for ${u.username}. Their other sessions are signed out.`);
|
||||||
|
error = null;
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
return h('div', { class: 'card' },
|
||||||
|
h('h2', { text: 'Account' }),
|
||||||
|
h('p', { class: 'muted small' },
|
||||||
|
'Setting a password here is how somebody gets their first one, or a new ',
|
||||||
|
'one after forgetting it. It signs them out everywhere else. They change ',
|
||||||
|
'it themselves under Account afterwards.'),
|
||||||
|
self ? h('p', { class: 'muted small' },
|
||||||
|
'Change your own password under Account, where the current one is asked for.')
|
||||||
|
: form,
|
||||||
|
h('h3', { text: 'Delete' }),
|
||||||
|
h('p', { class: 'muted small' },
|
||||||
|
'Deleting erases their acknowledgements and timeline entries — incidents ',
|
||||||
|
'they handled stop saying who did. Disabling keeps the history and is ',
|
||||||
|
'almost always what is meant.'),
|
||||||
|
h('button', {
|
||||||
|
class: 'btn btn-danger', type: 'button', text: `Delete ${u.username}`,
|
||||||
|
disabled: self,
|
||||||
|
title: self ? 'You cannot delete your own account' : '',
|
||||||
|
onclick: deleteUser,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUser() {
|
||||||
|
if (!(await confirm({
|
||||||
|
title: `Delete ${data.user.username}?`,
|
||||||
|
text: 'Their API keys go with them, and their name comes off every incident they acknowledged. This cannot be undone.',
|
||||||
|
confirmLabel: 'Delete',
|
||||||
|
danger: true,
|
||||||
|
}))) return;
|
||||||
|
try {
|
||||||
|
await api.deleteUser(userID);
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast('User deleted.');
|
||||||
|
navigate('/admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- plumbing --------------------------------------------------------------
|
||||||
|
|
||||||
|
// act runs a write and reloads. Errors are shown rather than thrown away: the
|
||||||
|
// 409 from the last-owner or last-administrator guard is the server explaining
|
||||||
|
// itself, and the reader needs to see it.
|
||||||
|
async function act(fn) {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
error = null;
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
@@ -67,6 +67,10 @@ export const setPassword = (userID, password, currentPassword) =>
|
|||||||
// users
|
// users
|
||||||
export const users = () => call('GET', '/users');
|
export const users = () => call('GET', '/users');
|
||||||
|
|
||||||
|
// What one person is in. /teams answers "what am I in" and cannot be asked
|
||||||
|
// about anybody else, which is what the admin page's per-user view needs.
|
||||||
|
export const userTeams = (id) => call('GET', `/users/${id}/teams`);
|
||||||
|
|
||||||
// incidents
|
// incidents
|
||||||
export const incidents = (query, opts) => call('GET', '/incidents', { query, ...opts });
|
export const incidents = (query, opts) => call('GET', '/incidents', { query, ...opts });
|
||||||
export const incident = (id) => call('GET', `/incidents/${id}`);
|
export const incident = (id) => call('GET', `/incidents/${id}`);
|
||||||
@@ -87,6 +91,20 @@ export const deleteNote = (id, eventID) => call('DELETE', `/incidents/${id}/note
|
|||||||
export const alerts = (query, opts) => call('GET', '/alerts', { query, ...opts });
|
export const alerts = (query, opts) => call('GET', '/alerts', { query, ...opts });
|
||||||
|
|
||||||
// schedule
|
// schedule
|
||||||
|
// Sign-up, both halves unauthenticated: the caller has no account yet.
|
||||||
|
export const signupInfo = (invite) =>
|
||||||
|
call('GET', '/signup', { query: invite ? { invite } : {} });
|
||||||
|
export const signup = (body) => call('POST', '/signup', { body });
|
||||||
|
|
||||||
|
export const invites = (id) => call('GET', `/teams/${id}/invites`);
|
||||||
|
export const createInvite = (id, role, maxUses) =>
|
||||||
|
call('POST', `/teams/${id}/invites`, { body: { role, max_uses: maxUses } });
|
||||||
|
export const revokeInvite = (id, inviteID) => call('DELETE', `/teams/${id}/invites/${inviteID}`);
|
||||||
|
|
||||||
|
export const testNotification = () => call('POST', '/me/notify/test');
|
||||||
|
export const dismissOnboarding = (dismissed) =>
|
||||||
|
call('PUT', '/me/onboarding', { body: { dismissed } });
|
||||||
|
|
||||||
export const teams = () => call('GET', '/teams');
|
export const teams = () => call('GET', '/teams');
|
||||||
export const createTeam = (name) => call('POST', '/teams', { body: { name } });
|
export const createTeam = (name) => call('POST', '/teams', { body: { name } });
|
||||||
export const renameTeam = (id, name) => call('PUT', `/teams/${id}`, { body: { name } });
|
export const renameTeam = (id, name) => call('PUT', `/teams/${id}`, { body: { name } });
|
||||||
@@ -125,6 +143,7 @@ export const setUserAdmin = (id, isAdmin) =>
|
|||||||
call('PUT', `/users/${id}/admin`, { body: { is_admin: isAdmin } });
|
call('PUT', `/users/${id}/admin`, { body: { is_admin: isAdmin } });
|
||||||
export const setUserDisabled = (id, disabled) =>
|
export const setUserDisabled = (id, disabled) =>
|
||||||
call('PUT', `/users/${id}/disabled`, { body: { disabled } });
|
call('PUT', `/users/${id}/disabled`, { body: { disabled } });
|
||||||
|
export const deleteUser = (id) => call('DELETE', `/users/${id}`);
|
||||||
export const schedule = (teamID, from, to) =>
|
export const schedule = (teamID, from, to) =>
|
||||||
call('GET', `/teams/${teamID}/schedule`, { query: { from, to } });
|
call('GET', `/teams/${teamID}/schedule`, { query: { from, to } });
|
||||||
|
|
||||||
|
|||||||
@@ -11,22 +11,28 @@ import * as alerts from './alerts.js';
|
|||||||
import * as account from './account.js';
|
import * as account from './account.js';
|
||||||
import * as team from './team.js';
|
import * as team from './team.js';
|
||||||
import * as admin from './admin.js';
|
import * as admin from './admin.js';
|
||||||
|
import * as adminuser from './adminuser.js';
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
// One route per section; /incidents/{id} is the queue with a detail open.
|
// One route per section; /incidents/{id} is the queue with a detail open, and
|
||||||
|
// /admin/users/{id} is a section of its own rather than a mode of the Admin
|
||||||
|
// tab, because it replaces the page rather than opening beside it.
|
||||||
const SECTIONS = {
|
const SECTIONS = {
|
||||||
queue: { title: 'Queue', view: queue },
|
queue: { title: 'Queue', view: queue },
|
||||||
oncall: { title: 'On-call', view: oncall },
|
oncall: { title: 'On-call', view: oncall },
|
||||||
alerts: { title: 'Alerts', view: alerts },
|
alerts: { title: 'Alerts', view: alerts },
|
||||||
team: { title: 'Team', view: team },
|
team: { title: 'Team', view: team },
|
||||||
admin: { title: 'Admin', view: admin },
|
admin: { title: 'Admin', view: admin },
|
||||||
|
adminuser: { title: 'User', view: adminuser, nav: 'admin' },
|
||||||
more: { title: 'Account', view: account },
|
more: { title: 'Account', view: account },
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseRoute(pathname) {
|
function parseRoute(pathname) {
|
||||||
const m = pathname.match(/^\/incidents\/(\d+)\/?$/);
|
const m = pathname.match(/^\/incidents\/(\d+)\/?$/);
|
||||||
if (m) return { section: 'queue', incident: Number(m[1]) };
|
if (m) return { section: 'queue', incident: Number(m[1]) };
|
||||||
|
const u = pathname.match(/^\/admin\/users\/(\d+)\/?$/);
|
||||||
|
if (u) return { section: 'adminuser', user: Number(u[1]) };
|
||||||
const name = pathname.replace(/^\/|\/$/g, '');
|
const name = pathname.replace(/^\/|\/$/g, '');
|
||||||
if (name === 'oncall' || name === 'alerts' || name === 'team' || name === 'admin' || name === 'more') return { section: name };
|
if (name === 'oncall' || name === 'alerts' || name === 'team' || name === 'admin' || name === 'more') return { section: name };
|
||||||
return { section: 'queue', incident: null };
|
return { section: 'queue', incident: null };
|
||||||
@@ -69,8 +75,11 @@ function render() {
|
|||||||
el.hidden = name !== route.section;
|
el.hidden = name !== route.section;
|
||||||
if (name === route.section) $('topbar-title').textContent = s.title;
|
if (name === route.section) $('topbar-title').textContent = s.title;
|
||||||
}
|
}
|
||||||
|
// A section may light up somebody else's tab: /admin/users/{id} is still the
|
||||||
|
// Admin tab as far as the nav is concerned, since there is no tab of its own.
|
||||||
|
const current = SECTIONS[route.section].nav || route.section;
|
||||||
for (const link of document.querySelectorAll('.nav-link')) {
|
for (const link of document.querySelectorAll('.nav-link')) {
|
||||||
if (link.dataset.section === route.section) link.setAttribute('aria-current', 'page');
|
if (link.dataset.section === current) link.setAttribute('aria-current', 'page');
|
||||||
else link.removeAttribute('aria-current');
|
else link.removeAttribute('aria-current');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +94,7 @@ function render() {
|
|||||||
incident.show(route.incident);
|
incident.show(route.incident);
|
||||||
} else {
|
} else {
|
||||||
incident.show(null);
|
incident.show(null);
|
||||||
SECTIONS[route.section].view.show();
|
SECTIONS[route.section].view.show(route);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (detailOpen && !wasOpen) window.scrollTo(0, 0);
|
if (detailOpen && !wasOpen) window.scrollTo(0, 0);
|
||||||
@@ -142,6 +151,14 @@ async function boot() {
|
|||||||
document.addEventListener('click', interceptLinks);
|
document.addEventListener('click', interceptLinks);
|
||||||
document.addEventListener('keydown', onKey);
|
document.addEventListener('keydown', onKey);
|
||||||
$('login-form').addEventListener('submit', onLogin);
|
$('login-form').addEventListener('submit', onLogin);
|
||||||
|
$('signup-form').addEventListener('submit', onSignup);
|
||||||
|
|
||||||
|
// /signup is the one route that works without a session.
|
||||||
|
if (location.pathname.replace(/\/$/, '') === '/signup') {
|
||||||
|
$('boot').hidden = true;
|
||||||
|
await showSignup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
state.me = await api.me();
|
state.me = await api.me();
|
||||||
@@ -161,6 +178,84 @@ function showBootError(err) {
|
|||||||
$('boot').append(ui.h('button', { class: 'btn', onclick: () => location.reload(), text: 'Retry' }));
|
$('boot').append(ui.h('button', { class: 'btn', onclick: () => location.reload(), text: 'Retry' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The sign-up screen. Reached at /signup, with an optional ?invite= that the
|
||||||
|
// server has already judged — the form says whether the link is good before
|
||||||
|
// somebody picks a password, rather than after.
|
||||||
|
async function showSignup() {
|
||||||
|
poll.stop();
|
||||||
|
ui.closeSheet(null);
|
||||||
|
reset();
|
||||||
|
$('boot').hidden = true;
|
||||||
|
$('app').hidden = true;
|
||||||
|
$('login').hidden = false;
|
||||||
|
$('login-form').hidden = true;
|
||||||
|
$('signup-form').hidden = false;
|
||||||
|
|
||||||
|
const invite = new URLSearchParams(location.search).get('invite');
|
||||||
|
const intro = $('signup-intro');
|
||||||
|
const form = $('signup-form');
|
||||||
|
const teamLabel = $('signup-team-label');
|
||||||
|
form.querySelector('.form-error').hidden = true;
|
||||||
|
|
||||||
|
let info;
|
||||||
|
try {
|
||||||
|
info = await api.signupInfo(invite);
|
||||||
|
} catch (err) {
|
||||||
|
intro.textContent = err.message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invite && info.invite_valid) {
|
||||||
|
intro.textContent = `You have been invited to ${info.invite_team}.`;
|
||||||
|
teamLabel.hidden = true;
|
||||||
|
form.team_name.required = false;
|
||||||
|
} else if (invite) {
|
||||||
|
// One answer for expired, revoked, used up and never existed, matching the
|
||||||
|
// server: which it was is not a stranger's business.
|
||||||
|
intro.textContent = 'That invite link is not usable. Ask whoever sent it for a new one.';
|
||||||
|
form.querySelector('button[type=submit]').disabled = true;
|
||||||
|
} else if (info.mode === 'open') {
|
||||||
|
intro.textContent = 'Create an account and a team to put your alerts in.';
|
||||||
|
teamLabel.hidden = false;
|
||||||
|
form.team_name.required = true;
|
||||||
|
} else {
|
||||||
|
intro.textContent = 'Sign-up on this server is invite-only. Ask a team owner for a link.';
|
||||||
|
form.querySelector('button[type=submit]').disabled = true;
|
||||||
|
}
|
||||||
|
form.username.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSignup(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = e.currentTarget;
|
||||||
|
const err = form.querySelector('.form-error');
|
||||||
|
const btn = form.querySelector('button[type=submit]');
|
||||||
|
err.hidden = true;
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
state.me = await api.signup({
|
||||||
|
username: form.username.value.trim(),
|
||||||
|
email: form.email.value.trim(),
|
||||||
|
password: form.password.value,
|
||||||
|
invite: new URLSearchParams(location.search).get('invite') || undefined,
|
||||||
|
team_name: form.team_name.value.trim() || undefined,
|
||||||
|
});
|
||||||
|
form.password.value = '';
|
||||||
|
// Signing up signs you in, so go straight to the queue rather than to a
|
||||||
|
// login form asking for the credential just chosen.
|
||||||
|
history.replaceState({ depth: 0 }, '', '/');
|
||||||
|
route = parseRoute('/');
|
||||||
|
await loadTeams();
|
||||||
|
$('nav-admin').hidden = !state.me?.user?.is_admin;
|
||||||
|
showApp();
|
||||||
|
} catch (ex) {
|
||||||
|
err.textContent = ex.message;
|
||||||
|
err.hidden = false;
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function showLogin() {
|
function showLogin() {
|
||||||
poll.stop();
|
poll.stop();
|
||||||
ui.closeSheet(null);
|
ui.closeSheet(null);
|
||||||
@@ -168,8 +263,15 @@ function showLogin() {
|
|||||||
$('boot').hidden = true;
|
$('boot').hidden = true;
|
||||||
$('app').hidden = true;
|
$('app').hidden = true;
|
||||||
$('login').hidden = false;
|
$('login').hidden = false;
|
||||||
|
$('signup-form').hidden = true;
|
||||||
|
$('login-form').hidden = false;
|
||||||
const form = $('login-form');
|
const form = $('login-form');
|
||||||
form.querySelector('.form-error').hidden = true;
|
form.querySelector('.form-error').hidden = true;
|
||||||
|
// Only offer the door that is open. Somebody without an invite on an
|
||||||
|
// invite-only server should be told, not sent to a form that refuses them.
|
||||||
|
api.signupInfo().then((info) => {
|
||||||
|
$('signup-link').hidden = info.mode !== 'open';
|
||||||
|
}).catch(() => {});
|
||||||
form.password.value = '';
|
form.password.value = '';
|
||||||
(form.username.value ? form.password : form.username).focus();
|
(form.username.value ? form.password : form.username).focus();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// The first-run checklist: the four things a new install or a new person has
|
||||||
|
// to do before an alert reaches a phone.
|
||||||
|
//
|
||||||
|
// It is computed from what the server already knows rather than from stored
|
||||||
|
// progress — a topic is set or it is not, an integration exists or it does not
|
||||||
|
// — so it cannot claim a step is done when it is not, and it comes back by
|
||||||
|
// itself if somebody deletes their integration a month later.
|
||||||
|
//
|
||||||
|
// Dismissal is the one piece of state, kept per user so finishing on a laptop
|
||||||
|
// does not leave the phone nagging.
|
||||||
|
|
||||||
|
import * as api from './api.js';
|
||||||
|
import { h, clear, spinner } from './ui.js';
|
||||||
|
import { state, currentTeam } from './state.js';
|
||||||
|
import { navigate } from './app.js';
|
||||||
|
import { isoDate } from './format.js';
|
||||||
|
|
||||||
|
let steps = null;
|
||||||
|
let error = null;
|
||||||
|
let busy = false;
|
||||||
|
let testResult = null;
|
||||||
|
|
||||||
|
// done() is deliberately a question about the world, not a flag: each step asks
|
||||||
|
// the data whether it happened.
|
||||||
|
export async function load() {
|
||||||
|
const team = currentTeam();
|
||||||
|
if (!team) {
|
||||||
|
steps = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const [schedule, integrations, alerts] = await Promise.all([
|
||||||
|
api.schedule(team.id, isoDate(new Date()), isoDate(new Date())),
|
||||||
|
api.integrations(team.id),
|
||||||
|
api.alerts({ limit: 1 }),
|
||||||
|
]);
|
||||||
|
steps = [
|
||||||
|
{
|
||||||
|
id: 'topic',
|
||||||
|
title: 'Set where your pages go',
|
||||||
|
text: 'An ntfy topic on your account. Without one, incidents assigned to you page the team’s fallback topic instead of your phone.',
|
||||||
|
done: Boolean(state.me?.user?.ntfy_topic),
|
||||||
|
action: { label: 'Account', go: '/more' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rota',
|
||||||
|
title: 'Put somebody on call',
|
||||||
|
text: 'An incident opens assigned to whoever the rota says is on call today. With an empty rota it opens unassigned.',
|
||||||
|
done: (schedule || []).length > 0,
|
||||||
|
action: { label: 'Team', go: '/team' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'integration',
|
||||||
|
title: 'Create an alert source',
|
||||||
|
text: 'Alerts arrive on an integration key, which says which team they belong to. Nothing can reach this team without one.',
|
||||||
|
done: (integrations || []).length > 0,
|
||||||
|
action: { label: 'Team', go: '/team' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'alert',
|
||||||
|
title: 'Send a test alert',
|
||||||
|
text: 'Post to the integration URL and watch it appear in the queue. Until one arrives, none of the above is proven.',
|
||||||
|
done: (alerts || []).length > 0,
|
||||||
|
action: { label: 'How', go: '/team' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
error = null;
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// visible reports whether there is anything worth showing: something undone,
|
||||||
|
// and not dismissed.
|
||||||
|
export function visible() {
|
||||||
|
if (!steps || state.me?.onboarding_dismissed) return false;
|
||||||
|
return steps.some((s) => !s.done);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function card() {
|
||||||
|
if (!visible()) return null;
|
||||||
|
const remaining = steps.filter((s) => !s.done).length;
|
||||||
|
|
||||||
|
return h('div', { class: 'card onboarding' },
|
||||||
|
h('div', { class: 'onboarding-head' },
|
||||||
|
h('h2', { text: 'Finish setting up' }),
|
||||||
|
h('span', { class: 'muted small', text: `${remaining} left` }),
|
||||||
|
h('button', {
|
||||||
|
class: 'btn-sm', type: 'button', text: 'Hide',
|
||||||
|
title: 'Hide this checklist for good',
|
||||||
|
onclick: async () => {
|
||||||
|
try {
|
||||||
|
await api.dismissOnboarding(true);
|
||||||
|
if (state.me) state.me.onboarding_dismissed = true;
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
}
|
||||||
|
rerender();
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
error && h('p', { class: 'load-error', text: error }),
|
||||||
|
h('ol', { class: 'checklist' }, ...steps.map(stepRow)),
|
||||||
|
testResult && h('p', { class: testResult.ok ? 'muted small' : 'load-error', text: testResult.text }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepRow(step) {
|
||||||
|
return h('li', { class: step.done ? 'step done' : 'step' },
|
||||||
|
h('span', { class: 'step-mark', text: step.done ? '✓' : '' }),
|
||||||
|
h('div', {},
|
||||||
|
h('strong', { text: step.title }),
|
||||||
|
h('p', { class: 'muted small', text: step.text }),
|
||||||
|
!step.done && h('div', { class: 'step-actions' },
|
||||||
|
h('button', {
|
||||||
|
class: 'btn-sm', type: 'button', text: step.action.label,
|
||||||
|
onclick: () => navigate(step.action.go),
|
||||||
|
}),
|
||||||
|
// The topic step is the only one this page can finish by itself, and
|
||||||
|
// the only proof that matters is a phone buzzing.
|
||||||
|
step.id === 'topic' && state.me?.user?.ntfy_topic && h('button', {
|
||||||
|
class: 'btn-sm', type: 'button', text: 'Send a test push',
|
||||||
|
disabled: busy,
|
||||||
|
onclick: sendTest,
|
||||||
|
}),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendTest() {
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
await api.testNotification();
|
||||||
|
testResult = { ok: true, text: 'Sent. If nothing arrives, the topic is wrong or ntfy is not reachable.' };
|
||||||
|
} catch (err) {
|
||||||
|
testResult = { ok: false, text: err.message };
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
rerender();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The queue owns the card's place on the page, so ask it to redraw rather than
|
||||||
|
// reaching into its list.
|
||||||
|
let rerender = () => {};
|
||||||
|
export function onRerender(fn) {
|
||||||
|
rerender = fn;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import * as api from './api.js';
|
|||||||
import { h, clear, badge, emptyState, spinner } from './ui.js';
|
import { h, clear, badge, emptyState, spinner } from './ui.js';
|
||||||
import { age, until, isFuture, severityClass, labelSummary } from './format.js';
|
import { age, until, isFuture, severityClass, labelSummary } from './format.js';
|
||||||
import { state, myID } from './state.js';
|
import { state, myID } from './state.js';
|
||||||
|
import * as onboarding from './onboarding.js';
|
||||||
import { navigate } from './app.js';
|
import { navigate } from './app.js';
|
||||||
|
|
||||||
// The same filters as the TUI's `f` cycle, plus archived ones to get back to.
|
// The same filters as the TUI's `f` cycle, plus archived ones to get back to.
|
||||||
@@ -25,6 +26,8 @@ const EMPTY = {
|
|||||||
archived: ['Nothing archived', ''],
|
archived: ['Nothing archived', ''],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onboarding.onRerender(() => renderList());
|
||||||
|
|
||||||
let filter = loadFilter();
|
let filter = loadFilter();
|
||||||
let teamFilter = loadTeamFilter(); // '' for every team the viewer is in
|
let teamFilter = loadTeamFilter(); // '' for every team the viewer is in
|
||||||
let items = null; // null while loading
|
let items = null; // null while loading
|
||||||
@@ -89,6 +92,7 @@ export async function refresh({ fresh = false } = {}) {
|
|||||||
const query = teamFilter ? { ...f.query, team_id: teamFilter } : f.query;
|
const query = teamFilter ? { ...f.query, team_id: teamFilter } : f.query;
|
||||||
const cached = filter === 'open' && !fresh && !teamFilter;
|
const cached = filter === 'open' && !fresh && !teamFilter;
|
||||||
const result = cached ? state.open : await api.incidents(query);
|
const result = cached ? state.open : await api.incidents(query);
|
||||||
|
await onboarding.load();
|
||||||
if (requested !== filter) return;
|
if (requested !== filter) return;
|
||||||
items = result;
|
items = result;
|
||||||
error = null;
|
error = null;
|
||||||
@@ -153,20 +157,22 @@ function renderChips() {
|
|||||||
|
|
||||||
function renderList() {
|
function renderList() {
|
||||||
const el = document.getElementById('queue-list');
|
const el = document.getElementById('queue-list');
|
||||||
|
const checklist = onboarding.card();
|
||||||
if (error && !items) {
|
if (error && !items) {
|
||||||
clear(el, h('div', { class: 'load-error', text: error }));
|
clear(el, checklist, h('div', { class: 'load-error', text: error }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!items) {
|
if (!items) {
|
||||||
clear(el, spinner());
|
clear(el, checklist, spinner());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
const [title, text] = EMPTY[filter];
|
const [title, text] = EMPTY[filter];
|
||||||
clear(el, emptyState(title, text, filter === 'open' ? 'checkCircle' : null));
|
clear(el, checklist, emptyState(title, text, filter === 'open' ? 'checkCircle' : null));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
clear(el,
|
clear(el,
|
||||||
|
checklist,
|
||||||
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||||
items.map((inc, i) => row(inc, i)),
|
items.map((inc, i) => row(inc, i)),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user