Compare commits

...

7 Commits

Author SHA1 Message Date
Niklas Ye 6a03698f65 Set the chart's placeholder version to 0.18.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 14s
CI / test (push) Successful in 2m40s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 21s
Release / image (push) Successful in 58s
Release / scan-image (push) Successful in 23s
Cosmetic, and done anyway for the same reason as ee22eb0, 7b9a337 and
828cf87 before it: .gitea/workflows/release.yaml passes --version and
--app-version to `helm package` from the git tag, so neither line decides
anything about what is published. Being read is all they do, and a tree
heading for v0.18.0 that says 0.17.0 tells its reader something false.

appVersion keeps the v, per APPVERSION_PREFIX in .release.conf, and
image.tag in values.yaml stays "latest" -- that one is what a local
`helm install ./charts/terdut-server` actually pulls.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-22 09:13:51 +02:00
Niklas Ye 67d68ce058 Show the rota as a month rather than a list of dates
The Team tab printed the next thirty days as thirty rows of date, name and
a Clear button. That is a rota spelled out one day at a time, and it is the
one shape the question cannot be read in: what anybody wants from a rota is
who holds which stretch, and thirty names down a column hides a handover
between two rows that look the same. It was also the longest thing on the
page by a wide margin, so the escalation ladder and the alert sources sat
below a screen of dates.

It is a month now, Monday to Sunday, one coloured initial per day. A shift
becomes a run of one colour, which is the shape the answer actually has; a
gap becomes a hole you can see. The legend underneath says whose colour is
whose, and one line says how many days are left uncovered, counting only
from today -- an empty Tuesday last week is history, not a hole somebody
still has to fill.

Laid out like the on-call page's week, deliberately: heading and arrows
outside the card, days inside it. It is the same rota, and two pages
showing it two ways would be two things to learn.

Colours come from a person's place in the member list, so they hold still
as you page between months, and six of them repeat -- the initial inside
still tells two people apart, and a legend that has to explain nine hues is
not a legend. They are not the severity palette: nothing on a rota is
critical, and a red Thursday would read as one. --teal and --pink are new
in both themes for the two the palette was short.

The per-row Clear button had nowhere left to live, so a day opens the sheet
the app already uses for confirmations: who holds it, a picker, Assign and
Clear. That assign sends replace=true where the range form still asks
first, and the difference is the point -- the sheet has just named whoever
holds the day, so taking it from them is the thing that was asked for
rather than something to warn about. The range form is unchanged and folded
into a details, since filling a whole shift is what it is for; it opens on
the month above it rather than on today, so paging to March to fill March
does not hand you September.

The server is untouched. The month drawn is the month fetched -- the grid's
Monday overhang and its trailing days are real days and are fetched with
it -- so paging is one GET /api/teams/{id}/schedule per month with from and
to, where it used to be one fixed thirty-day window. No new endpoint, no
change to what the API returns, and terdut-tui is unaffected.

Nobody has looked at this in a browser either. What is checked is the
rendering: team.js's own refresh() was run against a stub fetch and a
pocket DOM for September 2026, and it produces 35 cells for a month whose
1st is a Tuesday, the right from/to on the schedule call, today marked on
the 22nd, three people in the legend with "you" on the viewer, the gap
count over a five-day hole, and -- as a member rather than an owner -- the
same grid as plain divs with no sheet and no range form. How it looks at
phone width, and whether the six colours hold up in dark mode, are not
checked.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-22 09:13:36 +02:00
Niklas Ye a6fa673e08 Give every team a page of its own
The Admin tab's team list was growing controls the way the user list did
before ac9af8e: a Rename button behind window.prompt, a Delete beside it,
and -- on the Users page, of all places -- an invite form with a team
picker in front of it. The picker was the admission that an invite is a
fact about a team rather than about the server, and a prompt() is the
wrong place to read a 409 about a name already taken.

So a team is now a subject with a page, at /admin/teams/{id}, the mirror
of /admin/users/{id}: when it was created, how many are in it and how
much is open, a field to rename it, the members with their roles, the
invites into it, and deletion. The list goes back to being a list, and
the name in it is the way in.

The member list is the one thing there that needed a new endpoint.
GET /api/teams/{id}/members is requireTeamMember and answers 404 to an
administrator who is not in the team, and that stays exactly as it is:
member means membership and nothing else. Reading a team's shape is a
different question from reading its work, so it gets an endpoint of its
own under AdminOnly -- GET /api/admin/teams/{id}, returning
{"team", "members"} -- rather than an exception carved into that rule. It
is a wrapper and not a team with the members hung off it, because
"members" already means a count on the list endpoint and one name must
not be a number in one answer and an array in the next. The query and its
ordering are copied from handleListTeamMembers so the two answers to "who
is in this team" cannot disagree.

An administrator still sees none of that team's incidents, alerts or
rota. Nothing about what the flag may do changed; it could already rename
and delete any team, and staff one it is not in.

Rename now trims what it is given, as creation has always trimmed. Before
this, " " was a legal name to rename a team to but not to create one
with, which is one rule stated twice and applied once.

Nobody has looked at this in a browser, the caveat ac9af8e and 07914d5
both carried. What is checked is the wiring: admin_test.go covers the new
endpoint for an administrator outside the team, the 404 the member-only
endpoint still gives that same administrator, the 403 for a member who is
not one, a 404 for a team that does not exist, a 400 for an id that is not
a number, and the trim; the module graph evaluates at /admin/teams/{id},
and the server serves index.html there, so a reload survives.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-22 09:13:13 +02:00
Niklas Ye ee22eb000c Set the chart's placeholder version to 0.17.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 13s
CI / test (push) Successful in 2m31s
Release / test (push) Successful in 8s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 19s
Release / image (push) Successful in 53s
Release / scan-image (push) Successful in 3s
Cosmetic, and done anyway for the same reason as 7b9a337, 828cf87 and
8869ac8 before it: .gitea/workflows/release.yaml passes --version and
--app-version to `helm package` from the git tag, so neither line decides
anything about what is published. Being read is all they do, and a tree
heading for v0.17.0 that says 0.16.1 tells its reader something false.

appVersion keeps the v, per APPVERSION_PREFIX in .release.conf, and
image.tag in values.yaml stays "latest" -- that one is what a local
`helm install ./charts/terdut-server` actually pulls.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-21 21:11:29 +02:00
Niklas Ye 07914d5cdb Give the Admin tab sub-sections of its own
Administration was one scrolling page with three cards on it: the teams,
the people, and the settings. There was no way to link somebody to the
settings, no way back to the top of the user list but scrolling, and the
poll loop refetched all three endpoints every tick however little of the
page you were looking at.

Each is now a route -- /admin/teams, /admin/users, /admin/settings --
reached from a strip across the top, with /admin an overview that says
how many of each there are. The three cards themselves are untouched;
they are simply rendered one at a time, so a tab fetches only what it
shows. The Users page is the exception and fetches the teams too, since
its invite form has to offer a team to invite somebody into.

The strip is ordinary links rather than chips. Chips filter what a page
already shows, here and in the queue, and these four go somewhere: the
browser's Back walks them, a reload lands where you were, and the click
is intercepted by the same handler every other link in the app uses.
The current one is marked with aria-current="page", the convention the
tab bar has used since it existed, so the state lives on the attribute
and not in a class.

admin.js owns the table of the four routes, because it also builds the
strip that links to them; app.js parses against that table rather than
keeping a second list to drift from it. Adding a fifth sub-section is
one line.

The bottom tab bar still has six items. 56b8191 made it count-agnostic
when Admin arriving pushed it past four, and the note there records that
six at 420px already leaves 55-65px each -- so the sub-sections went
inside the Admin page rather than beside it.

A person's page keeps its own route at /admin/users/{id}; its back link
now returns to the user list rather than to the top of everything.

Nobody has looked at this in a browser, the same caveat ac9af8e carried.
What is checked is the wiring: the module graph evaluates at every admin
URL, all four tabs render against live server responses with one
aria-current each and the fetches the table above describes, the
non-administrator branch still refuses without fetching, and the server
serves index.html for each new path so a reload survives. The strip's
appearance at phone and desktop width is not checked.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-21 21:11:08 +02:00
Niklas Ye 7b9a337d25 Set the chart's placeholder version to 0.16.1
CI / chart (push) Successful in 1s
CI / security (push) Successful in 16s
CI / test (push) Successful in 2m33s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 4s
Release / binaries (push) Successful in 28s
Release / image (push) Successful in 1m5s
Release / scan-image (push) Successful in 2s
Cosmetic, and done anyway. release.yaml passes --version and --app-version
from the git tag when it packages, so neither line decides anything about
what is published; they exist to be read by somebody looking at the tree
before the tag does. A tree heading for v0.16.1 that says 0.16.0 tells that
reader something false.

Its own commit, like 828cf87, 8869ac8 and 9376105 before it, so the feature
commit's diff stays the feature.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-21 15:51:26 +02:00
Niklas Ye fc8b0c8d58 Let people set their own ntfy topic under Account
The first-run checklist's first step is "Set where your pages go", and its
button navigated to /more — which had no field for it. Every new user was
sent to a page that could not do the thing it sent them there for, and the
only ways to actually set a topic were curl or asking an administrator.
That has been true since the checklist shipped in v0.15.0.

Account now has a Notifications section above the password form: the topic,
prefilled and saved through the endpoint that already existed, and a Send a
test push button. The test is offered only once a topic is saved, because
it publishes what the server has stored rather than what is half-typed in
the field, and a button that silently tested the previous value would be
worse than no button.

Saving assigns the response to state.me.user, so the checklist stops asking
and the test button appears without a reload. Clearing works by saving an
empty topic: the server treats that as "no topic of their own" rather than
an error, and returns a user with ntfy_topic absent — it is omitempty — so
the form reads the cleared state from the response rather than assuming it.

The copy says the topic is a shared secret, because people reach for their
own name and it is the only thing between a stranger and their pages. Same
reason the topic stays out of an incident's timeline, which every API key
can read.

No server change: PUT /api/users/{id}/notify has been self-or-admin since
#3 and needed nothing. Only the ntfy topic is per-person — the server is
the install's one TERDUT_NTFY_URL and is not something a user picks.

Also drops a line on that page still sending people to terdut-tui for user
management, which stopped being true one release ago.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-21 15:51:16 +02:00
14 changed files with 1156 additions and 164 deletions
+44 -14
View File
@@ -47,8 +47,8 @@ curl -H "Authorization: Bearer $KEY" http://localhost:8080/api/users
The server serves a web UI at `/`: the incident queue, each incident's alerts
and timeline with every action (acknowledge, assign, snooze, note, resolve,
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
archive), who is on call, the alert feed, and an *Account* tab for your own
password and the ntfy topic your pages go to. It is 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.
From 900px wide it switches to a sidebar with the queue and the incident side by
side. Statistics remain in
@@ -91,15 +91,34 @@ membership. An owner edits it; a member sees the same page read-only, because
the server refuses their writes anyway. Somebody in more than one team picks
between them at the top.
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
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.
The rota is a month at a time, one coloured initial per day with a legend
underneath, and it says how many days are left uncovered — the question a rota
is read for is who holds which stretch, and a run of one colour answers it
where a list of dates does not. An owner taps a day to hand it to somebody or
empty it, and fills a whole shift from the range form folded in below.
A name in that list opens **that person's page**, at `/admin/users/{id}`: their
The **Admin** tab appears only for a system administrator, and holds what
belongs to the whole server rather than to one team. It has three sub-sections,
each with a URL of its own and a strip across the top to move between them:
every team (`/admin/teams`), every user (`/admin/users`), and the settings that
used to be environment variables (`/admin/settings`). `/admin` itself is an
overview — how many of each, and what each section is for. 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. That happens on the team's own page, since an invite is a fact
about a team; the user list points there rather than asking which team beside a
form.
A name in the team list opens **that team's page**, at `/admin/teams/{id}`: when it
was created, how many are in it and how much is open, a field to rename it, the
members with their roles, the invites into it, and deletion. The member list is the
one thing there that needed a new endpoint — `GET /api/teams/{id}/members` is
member-only and answers `404` to an administrator who is not in the team, which is
the rule and not an oversight, so the page reads `GET /api/admin/teams/{id}` instead.
An administrator still sees none of that team's incidents, alerts or rota.
A name in the user 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
@@ -351,10 +370,20 @@ exactly as it was rather than with a hole in it.
### Push notifications
With `TERDUT_NTFY_URL` set, an incident that opens is pushed to the on-call
person's phone through [ntfy](https://ntfy.sh). Set each user's topic with
`PUT /api/users/{id}/notify`; a user with no topic falls back to
`TERDUT_NTFY_FALLBACK_TOPIC`, as does an incident that opens with nobody on call.
If neither yields a topic, nothing is queued.
person's phone through [ntfy](https://ntfy.sh). Everybody sets their own topic
under *Account* in the web UI, where a **Send a test push** button proves it
before an incident has to; `PUT /api/users/{id}/notify` is the same thing over
the API, and an administrator may set somebody else's. A user with no topic
falls back to `TERDUT_NTFY_FALLBACK_TOPIC`, as does an incident that opens with
nobody on call. If neither yields a topic, nothing is queued.
The **server** is the install's one ntfy, from `TERDUT_NTFY_URL`, and is not
something a user picks. Only the topic is per-person.
A topic is a shared secret with the ntfy server: anyone who knows it can both
read the pages and publish to it, so an unguessable one is worth the trouble.
That is also why the topic never appears in an incident's timeline, which every
API key can read.
Three things get pushed:
@@ -640,6 +669,7 @@ on anybody's.
| Method | Path | Who | Description |
|---|---|---|---|
| `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/{teamID}` | **admin** | One team and who is in it: `{"team", "members"}`. `404` for a team that does not exist. `GET /api/teams/{teamID}/members` is **member**-only and still `404`s an administrator from outside the team — reading a team's shape and reading its work are different questions, so they are different endpoints |
| `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}`, or `{"signup_mode": "open"\|"invite_only"}`. `400` for an unknown key or a value outside its bounds |
+2 -2
View File
@@ -15,5 +15,5 @@ type: application
# 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
# metadata and drives nothing.
version: 0.16.0
appVersion: "v0.16.0"
version: 0.18.0
appVersion: "v0.18.0"
+121
View File
@@ -310,6 +310,127 @@ func TestAdmin_ConfiguresATeamTheyAreNotIn(t *testing.T) {
}
}
// The team page at /admin/teams/{id} needs the one question the test above
// leaves shut: who is in a team the administrator is not in.
//
// It is answered by a separate endpoint under AdminOnly rather than by letting
// the admin flag through requireTeamMember, and the second half of this test is
// the reason — /api/teams/{id}/members must keep answering 404, so that "member
// means membership and nothing else" stays true of the endpoint it was said
// about. Reading a team's shape and reading a team's work are different things.
func TestAdminGetTeam_ReadsAnyTeamWithoutJoiningIt(t *testing.T) {
s := newTS(t)
founderID, 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")
}
// The admin reads it whole, without being in it.
var got struct {
Team struct {
ID int64 `json:"id"`
Name string `json:"name"`
Members int64 `json:"members"`
OpenIncidents int64 `json:"open_incidents"`
} `json:"team"`
Members []struct {
UserID int64 `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
} `json:"members"`
}
decode(t, s.req(t, http.MethodGet, "/api/admin/teams/"+id64(team.ID), nil), &got)
if got.Team.ID != team.ID || got.Team.Name != "theirs" {
t.Errorf("expected team %d named theirs, got %d named %q", team.ID, got.Team.ID, got.Team.Name)
}
if got.Team.Members != 1 {
t.Errorf("expected a member count of 1, got %d", got.Team.Members)
}
if len(got.Members) != 1 {
t.Fatalf("expected one member, got %d", len(got.Members))
}
if got.Members[0].UserID != founderID || got.Members[0].Username != "founder" {
t.Errorf("expected founder (%d), got %q (%d)",
founderID, got.Members[0].Username, got.Members[0].UserID)
}
// Whoever creates a team owns it, and the page's role toggle depends on
// that being reported rather than assumed.
if got.Members[0].Role != "owner" {
t.Errorf("expected the creator to be owner, got %q", got.Members[0].Role)
}
// The rule this endpoint exists in order not to break. Same admin, same
// team, the member-only endpoint: still not found.
resp := s.req(t, http.MethodGet, "/api/teams/"+id64(team.ID)+"/members", nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("an admin outside the team must still get 404 from the member-only list, got %d",
resp.StatusCode)
}
// And the new one is administration, not membership: being in the team is
// not enough.
resp = call(http.MethodGet, "/api/admin/teams/"+id64(team.ID), nil)
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("a non-admin member must get 403, got %d", resp.StatusCode)
}
for _, c := range []struct {
name string
path string
want int
}{
{"a team that does not exist", "/api/admin/teams/999999", http.StatusNotFound},
{"a team id that is not a number", "/api/admin/teams/nonsense", http.StatusBadRequest},
} {
resp := s.req(t, http.MethodGet, c.path, nil)
resp.Body.Close()
if resp.StatusCode != c.want {
t.Errorf("%s: expected %d, got %d", c.name, c.want, resp.StatusCode)
}
}
}
// A team name is trimmed when it is created, and renaming had not been, so " "
// was a legal name to rename to and an illegal one to start with.
func TestRenameTeam_TrimsTheName(t *testing.T) {
s := newTS(t)
var team struct {
ID int64 `json:"id"`
}
decode(t, s.req(t, http.MethodPost, "/api/teams", map[string]string{"name": "trimmed"}), &team)
path := "/api/teams/" + id64(team.ID)
resp := s.req(t, http.MethodPut, path, map[string]string{"name": " "})
resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("a blank name must be refused, got %d", resp.StatusCode)
}
resp = s.req(t, http.MethodPut, path, map[string]string{"name": " padded "})
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("expected 204, got %d", resp.StatusCode)
}
var got struct {
Team struct {
Name string `json:"name"`
} `json:"team"`
}
decode(t, s.req(t, http.MethodGet, "/api/admin/teams/"+id64(team.ID), nil), &got)
if got.Team.Name != "padded" {
t.Errorf("expected the name to be trimmed to %q, got %q", "padded", got.Team.Name)
}
}
// 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) {
+5
View File
@@ -94,6 +94,11 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
// What exists on this server, and how it behaves. /api/teams
// answers "what am I in"; this one answers "what is there".
r.Get("/api/admin/teams", handleAdminListTeams(db))
// One team and who is in it. The member list under
// /api/teams/{id}/members stays member-only and still 404s
// an administrator from outside; this is a different
// question, so it is a different endpoint.
r.Get("/api/admin/teams/{teamID}", handleAdminGetTeam(db))
r.Get("/api/admin/settings", handleGetSettings(db, cfg))
r.Put("/api/admin/settings", handleSetSettings(db))
})
+95 -8
View File
@@ -6,9 +6,11 @@ import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"git.ryuvia.com/niklas/terdut-server/internal/config"
"git.ryuvia.com/niklas/terdut-server/internal/models"
"github.com/go-chi/chi/v5"
)
@@ -226,6 +228,17 @@ func handleSetSettings(db *sql.DB) http.HandlerFunc {
}
}
// adminTeam is a team as an administrator sees it: what it is, plus how big it
// is and how much is on fire in it. One definition, so a team in the list and a
// team on its own page cannot describe themselves differently.
type adminTeam struct {
ID int64 `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
Members int64 `json:"members"`
OpenIncidents int64 `json:"open_incidents"`
}
// handleAdminListTeams lists every team on the server, with its size. The
// ordinary /api/teams answers "what am I in"; this one answers "what exists",
// which only an administrator may ask.
@@ -244,13 +257,6 @@ func handleAdminListTeams(db *sql.DB) http.HandlerFunc {
}
defer rows.Close()
type adminTeam struct {
ID int64 `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
Members int64 `json:"members"`
OpenIncidents int64 `json:"open_incidents"`
}
teams := []adminTeam{}
for rows.Next() {
var t adminTeam
@@ -270,6 +276,79 @@ func handleAdminListTeams(db *sql.DB) http.HandlerFunc {
}
}
// handleAdminGetTeam answers "what is this team, and who is in it" for any team
// on the server, which is the one question an administrator could not ask.
//
// GET /api/teams/{id}/members is requireTeamMember and answers 404 to somebody
// outside the team, administrator or not, and that stays exactly as it is:
// member means membership and nothing else. Reading a team's shape is a
// different thing from reading its work, so it gets an endpoint of its own
// under AdminOnly rather than an exception carved into that rule. An
// administrator still sees none of the team's incidents, alerts or rota.
func handleAdminGetTeam(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
teamID, ok := teamParam(w, r)
if !ok {
return
}
var t adminTeam
var created int64
err := db.QueryRowContext(r.Context(), `
SELECT t.id, t.name, t.created_at,
(SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id),
(SELECT COUNT(*) FROM incidents i
WHERE i.team_id = t.id AND i.resolved_at IS NULL)
FROM teams t
WHERE t.id = $1`, teamID).
Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents)
if errors.Is(err, sql.ErrNoRows) {
respond(w, http.StatusNotFound, errResp("not found"))
return
}
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
t.CreatedAt = time.Unix(created, 0).UTC()
// Same query and same ordering as handleListTeamMembers, so the two
// answers to "who is in this team" cannot disagree about the answer.
rows, err := db.QueryContext(r.Context(), `
SELECT m.team_id, m.user_id, u.username, m.role, m.joined_at
FROM team_members m
JOIN users u ON u.id = m.user_id
WHERE m.team_id = $1
ORDER BY u.username`, teamID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
defer rows.Close()
members := []models.TeamMember{}
for rows.Next() {
var m models.TeamMember
var joined int64
if err := rows.Scan(&m.TeamID, &m.UserID, &m.Username, &m.Role, &joined); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
m.JoinedAt = time.Unix(joined, 0).UTC()
members = append(members, m)
}
if err := rows.Err(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
// A wrapper rather than a team with the members hung off it: "members"
// already means a count on the list endpoint, and one name must not be
// a number in one answer and an array in the next.
respond(w, http.StatusOK, map[string]any{"team": t, "members": members})
}
}
// handleRenameTeam renames a team. An owner's job, and an administrator's when
// a team has nobody left to do it.
func handleRenameTeam(db *sql.DB) http.HandlerFunc {
@@ -285,7 +364,15 @@ func handleRenameTeam(db *sql.DB) http.HandlerFunc {
var req struct {
Name string `json:"name"`
}
if err := decodeJSON(r, &req); err != nil || req.Name == "" {
// Trimmed, as handleCreateTeam trims: without it " " is a team name
// here but not at creation, which is one rule stated twice and only
// half applied.
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("name is required"))
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
respond(w, http.StatusBadRequest, errResp("name is required"))
return
}
+106 -4
View File
@@ -31,6 +31,14 @@
--snooze: #6b5bd2;
--snooze-soft: #efedfb;
/* Two hues that mean nothing on their own. The rota needs six colours to
tell six people apart and the palette above only has four that are not
already an alarm. */
--teal: #0f7d8c;
--teal-soft: #e3f4f6;
--pink: #b3427e;
--pink-soft: #fbe8f2;
--radius: 10px;
--radius-sm: 6px;
--shadow: 0 1px 2px rgb(16 24 40 / 6%), 0 1px 3px rgb(16 24 40 / 8%);
@@ -72,6 +80,11 @@
--snooze: #a89bff;
--snooze-soft: #262245;
--teal: #4fc2d4;
--teal-soft: #0f2e33;
--pink: #f07fb8;
--pink-soft: #3a1c2d;
--shadow: 0 1px 2px rgb(0 0 0 / 40%);
--shadow-lg: 0 16px 40px rgb(0 0 0 / 55%);
}
@@ -680,9 +693,10 @@ kbd {
.admin-settings button[type="submit"] { margin-top: 12px; }
.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; }
/* A name in an admin table is the way to that row's own page -- a person's or
a team's. */
.row-link { color: var(--text); font-weight: 650; text-decoration: none; }
.row-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; }
@@ -695,7 +709,9 @@ kbd {
word-break: break-all; user-select: all;
}
/* --- one user ------------------------------------------------------------ */
/* --- one user, one team --------------------------------------------------
Both subject pages share this: .user-head and .user-facts are generic
despite the names, and a team fills them with its own facts. */
.back-link {
display: inline-flex; align-items: center; gap: 2px; margin-bottom: 12px;
color: var(--muted); font-size: 14px; text-decoration: none;
@@ -726,6 +742,60 @@ kbd {
.stacked-form input.wide { min-width: min(420px, 100%); }
.team-picker { margin-top: 8px; max-width: 100%; }
/* The rota, a month at a time. A name is too wide to print thirty times and
too alike down a column to read, so a day carries an initial in that
person's colour and the legend underneath says whose. A shift is then a run
of one colour, which is the shape the question actually has. */
.rota-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; padding: 10px; }
.rota-wd {
padding-bottom: 4px; text-align: center;
color: var(--muted); font-size: 11px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.04em;
}
.rota-day {
display: flex; flex-direction: column; align-items: center; gap: 4px;
min-height: 52px; padding: 6px 0 8px;
border: 0; border-radius: var(--radius-sm); background: none;
font: inherit; color: inherit;
}
button.rota-day { cursor: pointer; }
button.rota-day:hover { background: var(--surface-2); }
.rota-num { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
.rota-day.today { background: var(--accent-soft); }
.rota-day.today .rota-num { color: var(--accent); font-weight: 700; }
.rota-day.past { opacity: 0.55; }
/* The days either side of the month are real days and are drawn, but they
belong to the month you are not looking at. */
.rota-day.outside { opacity: 0.35; }
.rota-chip {
display: grid; place-items: center;
width: 26px; height: 26px; border-radius: 50%;
font-size: 12px; font-weight: 750; text-transform: uppercase;
}
/* An empty day is a dot rather than a hole, and keeps the chip's box so the
rows stay on one baseline. */
.rota-chip.none { width: 8px; height: 8px; margin: 9px; background: var(--border-strong); }
/* Six colours, then they repeat; the initial inside still tells two people
apart. Deliberately not the severity palette — nothing here is critical. */
.rc1 { background: var(--accent-soft); color: var(--accent); }
.rc2 { background: var(--ok-soft); color: var(--ok); }
.rc3 { background: var(--snooze-soft); color: var(--snooze); }
.rc4 { background: var(--warn-soft); color: var(--warn); }
.rc5 { background: var(--teal-soft); color: var(--teal); }
.rc6 { background: var(--pink-soft); color: var(--pink); }
.rota-foot { padding: 12px 14px; border-top: 1px solid var(--border); }
.rota-legend { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; font-size: 14px; }
.rota-key { display: inline-flex; align-items: center; gap: 6px; }
.rota-key .rota-chip { width: 22px; height: 22px; font-size: 11px; }
.rota-note { margin: 10px 0 0; color: var(--muted); font-size: 13px; }
.rota-note:first-child { margin-top: 0; }
.rota-bulk { padding: 12px 14px; border-top: 1px solid var(--border); }
.rota-bulk .stacked-form { margin-top: 4px; }
.sheet-pick { display: flex; align-items: center; gap: 8px; font-size: 14px; }
.ladder-level {
border-left: 3px solid var(--border-strong);
padding: 8px 0 8px 12px; margin: 12px 0;
@@ -765,3 +835,35 @@ kbd {
.step-actions { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
.signup-intro { margin: 0 0 4px; font-size: 14px; color: var(--muted); }
/* --- admin sub-navigation ------------------------------------------------
A strip of links across the top of every admin page, one per sub-section.
Deliberately not .chip: chips filter what a page already shows, here and in
the queue, and these four go somewhere. Same aria-current convention as the
tab bar, so the state lives on the attribute rather than in a class. */
.subnav {
display: flex; gap: 2px;
margin: 12px auto 0;
border-bottom: 1px solid var(--border);
overflow-x: auto; scrollbar-width: none;
}
.subnav::-webkit-scrollbar { display: none; }
.subnav-link {
flex: none;
padding: 8px 12px; margin-bottom: -1px;
border-bottom: 2px solid transparent;
color: var(--muted); font-size: 14px; font-weight: 600; white-space: nowrap;
}
.subnav-link:hover { color: var(--text); }
.subnav-link[aria-current="page"] { color: var(--accent); border-bottom-color: var(--accent); }
/* The overview at /admin. The strip above already links to the three, so these
carry the counts, which is the part a menu cannot say. */
.admin-menu { display: grid; gap: 10px; margin-top: 16px; }
/* The grid's gap is the spacing here, so .card + .card must not add its own. */
.admin-menu .card + .card { margin-top: 0; }
.admin-menu-item { display: block; padding: 14px; }
.admin-menu-item:hover { background: var(--surface-hover); }
.admin-menu-head { display: flex; align-items: baseline; gap: 8px; }
.admin-menu-count { margin-left: auto; color: var(--muted); font-size: 18px; font-weight: 700; }
.admin-menu-item p { margin: 4px 0 0; }
+3
View File
@@ -127,6 +127,9 @@
<!-- 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>
<!-- One team, at /admin/teams/{id}: who is in it and the invites into it,
which the Team tab cannot show for a team you are not a member of. -->
<section id="view-adminteam" class="view view-page" data-view="adminteam" hidden></section>
<section id="view-more" class="view view-page" data-view="more" hidden></section>
</div>
+85 -1
View File
@@ -23,6 +23,9 @@ function render() {
h('div', { class: 'account-name', text: user.username }),
h('div', { class: 'account-email', text: user.email }))),
h('div', { class: 'page-head' }, h('h2', { text: 'Notifications' })),
notifyForm(user),
h('div', { class: 'page-head' }, h('h2', { text: hasPassword ? 'Change password' : 'Set a password' })),
passwordForm(user, hasPassword),
@@ -32,10 +35,91 @@ function render() {
h('div', { class: 'page-head' }),
h('button', { class: 'btn btn-block', type: 'button', onclick: signOut }, icon('logout'), 'Sign out'),
h('p', { class: 'foot-note', text: 'Schedule editing, statistics and user management are in terdut-tui for now.' }),
h('p', { class: 'foot-note', text: 'Statistics are in terdut-tui for now.' }),
);
}
// Where this user's pages go. The onboarding checklist's first step sends
// people here for it, and until now there was nothing here to send them to:
// the topic could only be set with curl or by an administrator.
//
// The topic is the whole address — the server it is published to is the
// install's one ntfy, set in the deployment and not something a user picks.
function notifyForm(user) {
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
const topic = h('input', {
name: 'ntfy_topic', type: 'text', autocomplete: 'off',
autocapitalize: 'none', spellcheck: false,
value: user.ntfy_topic || '',
placeholder: 'terdut-a7f3c91e',
});
const submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save topic' });
// Only offered once a topic is saved: the test publishes to whatever the
// server has stored, not to whatever is half-typed in the field.
const test = h('button', {
class: 'btn', type: 'button', text: 'Send a test push',
hidden: !user.ntfy_topic,
onclick: async () => {
err.hidden = true;
ok.hidden = true;
test.disabled = true;
try {
await api.testNotification();
ok.textContent = 'Sent. If nothing arrives, the topic is wrong or ntfy is not reachable.';
ok.hidden = false;
} catch (ex) {
err.textContent = ex.message;
err.hidden = false;
} finally {
test.disabled = false;
}
},
});
const form = h('form', { class: 'card pw-form' },
h('label', {},
h('span', { text: 'ntfy topic' }),
topic),
h('p', { class: 'muted small' },
'Subscribe to this topic in the ntfy app and incidents assigned to you ',
'reach your phone. Leave it empty and they page the team’s fallback ',
'topic instead.'),
// Worth saying plainly: people reach for their own name, and the topic is
// the only thing standing between a stranger and their pages.
h('p', { class: 'muted small' },
'Anyone who knows the topic can read your pages and publish to it, so ',
'pick something unguessable rather than your name.'),
err, ok,
h('div', { class: 'row-actions' }, submit, test),
);
form.addEventListener('submit', async (e) => {
e.preventDefault();
err.hidden = true;
ok.hidden = true;
submit.disabled = true;
try {
const updated = await api.setNotifyTarget(user.id, topic.value.trim());
// Keep the cached user in step, so the onboarding checklist stops
// asking for this and the test button appears without a reload.
state.me.user = updated;
ok.textContent = updated.ntfy_topic
? 'Topic saved.'
: 'Topic cleared. Your pages go to the team’s fallback topic.';
ok.hidden = false;
test.hidden = !updated.ntfy_topic;
} catch (ex) {
err.textContent = ex.message;
err.hidden = false;
} finally {
submit.disabled = false;
}
});
return form;
}
function passwordForm(user, hasPassword) {
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
+120 -105
View File
@@ -1,6 +1,12 @@
// Administration: the teams on this server, the people who can sign in, and
// the settings that change how the server behaves.
//
// Each of those three is a route of its own, reached from a strip across the
// top, with /admin itself an overview. They used to be three cards stacked on
// one page, which meant no way to link to the settings, no way back to the top
// of the user list but scrolling, and a poll that refetched all three endpoints
// however little of the page you were looking at.
//
// Only rendered for a system administrator. The server enforces that on every
// endpoint regardless — hiding a section is a courtesy to the reader, not a
// permission — so this view simply says so rather than pretending to be a
@@ -12,12 +18,34 @@ import { state, myID } from './state.js';
const view = () => document.getElementById('view-admin');
let data = null; // { teams, users, settings }
// The sub-sections, in the order the strip shows them. The overview is /admin
// itself, so it has no tab of its own. This table is the only place the four
// routes are written down: app.js parses against it and the strip is built
// from it, so adding a fifth is one line here.
export const TABS = [
{ tab: null, path: '/admin', label: 'Overview' },
{ tab: 'teams', path: '/admin/teams', label: 'Teams' },
{ tab: 'users', path: '/admin/users', label: 'Users' },
{ tab: 'settings', path: '/admin/settings', label: 'Settings' },
];
// Which sub-section is open. Remembered rather than passed, because the poll
// loop calls refresh() with no route — the same reason adminuser.js keeps its
// user ID in the module.
let tab = null;
let data = null; // whatever the current tab needs; the shape varies by tab
let error = null;
let busy = false;
export function show() {
if (!data) clear(view(), spinner());
export function show(route) {
const next = route?.tab ?? null;
// A different sub-section wants different data, so the old answer goes
// rather than being shown under the new heading until the fetch lands.
if (next !== tab) {
tab = next;
data = null;
}
if (!data) clear(view(), subnav(), spinner());
refresh();
}
@@ -28,12 +56,7 @@ export async function refresh() {
return;
}
try {
const [teams, users, settings] = await Promise.all([
api.adminTeams(),
api.users(),
api.adminSettings(),
]);
data = { teams, users, settings };
data = await load();
error = null;
} catch (err) {
error = err.message;
@@ -41,6 +64,16 @@ export async function refresh() {
render();
}
// Only what the open sub-section shows. Users is the one that needs two: it
// only points at Teams for an invite if there is a team to point at, and the
// overview counts both.
async function load() {
if (tab === 'teams') return { teams: await api.adminTeams() };
if (tab === 'settings') return { settings: await api.adminSettings() };
const [teams, users] = await Promise.all([api.adminTeams(), api.users()]);
return { teams, users };
}
function render() {
if (!state.me?.user?.is_admin) {
clear(view(), h('div', { class: 'card' },
@@ -48,14 +81,65 @@ function render() {
return;
}
if (!data) {
clear(view(), error ? h('div', { class: 'load-error', text: error }) : spinner());
clear(view(), subnav(), error ? h('div', { class: 'load-error', text: error }) : spinner());
return;
}
clear(view(),
subnav(),
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
teamsCard(),
usersCard(),
settingsCard(),
section(),
);
}
function section() {
if (tab === 'teams') return teamsCard();
if (tab === 'users') return usersCard();
if (tab === 'settings') return settingsCard();
return overview();
}
// The strip across the top of every admin page. Ordinary links rather than
// buttons, because these are four URLs: app.js intercepts the click, the
// browser's Back walks them, and a reload lands where you were.
function subnav() {
return h('nav', { class: 'subnav', 'aria-label': 'Administration' },
TABS.map((t) => h('a', {
class: 'subnav-link',
href: t.path,
text: t.label,
'aria-current': t.tab === tab ? 'page' : null,
})));
}
// --- overview --------------------------------------------------------------
// /admin itself. The strip already links to the three, so this earns its place
// by saying how much of each there is — the one thing a menu cannot.
function overview() {
const admins = data.users.filter((u) => u.is_admin).length;
const disabled = data.users.filter((u) => u.disabled_at).length;
const open = data.teams.reduce((n, t) => n + t.open_incidents, 0);
const people = [`${admins} ${admins === 1 ? 'administrator' : 'administrators'}`];
if (disabled > 0) people.push(`${disabled} disabled`);
return h('div', { class: 'admin-menu' },
menuItem('/admin/teams', 'Teams', data.teams.length,
open > 0
? `${open} open ${open === 1 ? 'incident' : 'incidents'} between them.`
: 'Nothing open anywhere.'),
menuItem('/admin/users', 'Users', data.users.length, `${people.join(', ')}.`),
menuItem('/admin/settings', 'Settings', null,
'How the server behaves, and where it is plugged in.'),
);
}
function menuItem(href, label, count, note) {
return h('a', { class: 'card admin-menu-item', href },
h('div', { class: 'admin-menu-head' },
h('strong', { text: label }),
count != null && h('span', { class: 'admin-menu-count', text: String(count) })),
h('p', { class: 'muted small', text: note }),
);
}
@@ -64,37 +148,24 @@ function render() {
function teamsCard() {
const rows = data.teams.map((t) =>
h('tr', {},
h('td', {}, h('strong', { text: t.name })),
// The name is the way in: everything about one team lives on its own
// page, and this table stays a list rather than becoming a form.
h('td', {}, h('a', { class: 'row-link', href: `/admin/teams/${t.id}`, text: t.name })),
h('td', { class: 'num', text: String(t.members) }),
h('td', { class: 'num', text: String(t.open_incidents) }),
h('td', {},
h('button', {
class: 'btn-sm',
type: 'button',
text: 'Rename',
onclick: () => renameTeam(t),
}),
// A team with open incidents cannot be deleted, and saying so before
// the click is kinder than a 409 afterwards.
h('button', {
class: 'btn-sm danger',
type: 'button',
text: 'Delete',
disabled: t.open_incidents > 0,
title: t.open_incidents > 0 ? 'Resolve its open incidents first' : '',
onclick: () => deleteTeam(t),
}),
),
));
return h('div', { class: 'card' },
h('h2', { text: 'Teams' }),
h('p', { class: 'muted small' },
'Open a team for who is in it, the invites into it, and renaming or ',
'deleting it. Deleting takes its alerts, incidents, schedule and ',
'integrations with it, and is refused while anything is still open.'),
h('table', { class: 'admin-table' },
h('thead', {}, h('tr', {},
h('th', { text: 'Name' }),
h('th', { class: 'num', text: 'Members' }),
h('th', { class: 'num', text: 'Open' }),
h('th', { text: '' }))),
h('th', { class: 'num', text: 'Open' }))),
h('tbody', {}, rows)),
newTeamForm(),
);
@@ -122,32 +193,6 @@ function newTeamForm() {
return form;
}
async function renameTeam(team) {
const next = window.prompt(`Rename ${team.name} to:`, team.name);
if (!next || next === team.name) return;
try {
await api.renameTeam(team.id, next);
} catch (err) {
error = err.message;
}
refresh();
}
async function deleteTeam(team) {
if (!(await confirm({
title: `Delete ${team.name}?`,
text: 'Its alerts, incidents, schedule and integrations go with it. This cannot be undone.',
confirmLabel: 'Delete',
danger: true,
}))) return;
try {
await api.deleteTeam(team.id);
} catch (err) {
error = err.message;
}
refresh();
}
// --- users -----------------------------------------------------------------
function usersCard() {
@@ -157,7 +202,7 @@ function usersCard() {
h('td', {},
// 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 }),
h('a', { class: 'row-link', href: `/admin/users/${u.id}`, text: u.username }),
u.disabled_at && h('span', { class: 'row-team', text: 'disabled' }),
self && h('span', { class: 'you', text: 'you' })),
h('td', { class: 'muted', text: u.email }),
@@ -195,57 +240,27 @@ function usersCard() {
h('th', { text: '' }),
h('th', { text: '' }))),
h('tbody', {}, rows)),
inviteForm(),
invitePointer(),
);
}
// 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.
// Adding a person is minting them an invite into a team, not creating a row:
// whoever accepts it picks their own password, so one never passes through an
// administrator, and the link carries the team, so they do not land on an empty
// queue.
//
// 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;
}
});
// The form for it lives on the team's own page. It always needed a team beside
// it, and a picker here was the admission that an invite is a fact about a team
// rather than about the server.
function invitePointer() {
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
data.teams.length > 0
? h('p', { class: 'muted small' },
'Open the team you want them in, under ',
h('a', { class: 'row-link', href: '/admin/teams', text: 'Teams' }),
', and mint an invite there.')
: h('p', { class: 'muted small', text: 'Create a team first — an invite has to lead somewhere.' }),
out,
);
}
+341
View File
@@ -0,0 +1,341 @@
// One team, at /admin/teams/{id}: what it is, who is in it, the invites into
// it, and the two destructive things an administrator can do to it.
//
// The mirror of adminuser.js. That page answers "which teams is this person
// in"; this one answers "who is in this team" for a team the administrator
// need not be a member of — which the Team tab cannot do, because it only
// offers teams the viewer is in.
//
// Only rendered for a system administrator. The server enforces that on every
// endpoint regardless, so this view says so rather than pretending to be a
// gate.
import * as api from './api.js';
import { h, clear, spinner, confirm, toast, icon } from './ui.js';
import { state } from './state.js';
import { navigate } from './app.js';
import { when } from './format.js';
const view = () => document.getElementById('view-adminteam');
let teamID = null;
let data = null; // { team, members, users, invites }
let error = null;
let busy = false;
// An invite link is shown once and never stored, so it lives here until the
// page is left rather than being toasted away after three seconds.
let freshInvite = null;
export function show(route) {
const next = route && route.team != null ? route.team : null;
if (next !== teamID) {
teamID = next;
data = null;
error = null;
freshInvite = null;
}
if (!data) clear(view(), spinner());
refresh();
}
export async function refresh() {
if (teamID == null || !state.me?.user?.is_admin) {
render();
return;
}
try {
// The team and its members come from the admin endpoint in one answer:
// /teams/{id}/members is member-only and 404s an administrator from
// outside the team, deliberately. users() is the add-a-member picker.
const [team, users, invites] = await Promise.all([
api.adminTeam(teamID),
api.users(),
api.invites(teamID),
]);
data = { team: team.team, members: team.members, users, invites };
error = null;
} catch (err) {
// A team that is gone answers 404, where a missing user is simply absent
// from a list adminuser.js already has. So the "no such team" state has to
// be recognised here; left to the error banner it would read as a fetch
// that failed, which is a different thing and invites a retry.
if (err.status === 404) {
data = { team: null, members: [], users: [], invites: [] };
error = null;
} else {
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.team) {
clear(el, backLink(), h('div', { class: 'card' },
h('p', { class: 'muted', text: 'No such team. It may have just been deleted.' })));
return;
}
clear(el,
backLink(),
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
identityCard(),
membersCard(),
invitesCard(),
dangerCard(),
);
}
function backLink() {
return h('a', { class: 'back-link', href: '/admin/teams' }, icon('chevronLeft'), h('span', { text: 'Teams' }));
}
// --- identity --------------------------------------------------------------
function identityCard() {
const t = data.team;
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
const name = h('input', {
name: 'name', type: 'text', value: t.name, required: true,
autocomplete: 'off', spellcheck: false,
});
const submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save name' });
// A field rather than the window.prompt this used to be. The server answers
// 409 for a name already taken, and a dialog is the wrong place to read that.
const form = h('form', { class: 'inline-form' }, name, submit);
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (busy) return;
err.hidden = true;
ok.hidden = true;
const next = name.value.trim();
if (!next || next === t.name) return;
busy = true;
submit.disabled = true;
try {
await api.renameTeam(teamID, next);
ok.textContent = 'Name saved.';
ok.hidden = false;
error = null;
} catch (ex) {
err.textContent = ex.message;
err.hidden = false;
busy = false;
submit.disabled = false;
return;
}
busy = false;
submit.disabled = false;
await refresh();
});
return h('div', { class: 'card' },
h('div', { class: 'user-head' }, h('h2', { text: t.name })),
h('dl', { class: 'user-facts' },
fact('Created', when(t.created_at)),
fact('Members', String(t.members)),
fact('Open incidents', String(t.open_incidents)),
),
form, err, ok,
);
}
function fact(label, value) {
return [h('dt', { text: label }), h('dd', { text: value })];
}
// --- members ---------------------------------------------------------------
// 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.
function membersCard() {
const rows = data.members.map((m) =>
h('tr', {},
// Unlike the Team tab's own member list, the name is a link: that
// person's page is where the rest of them lives.
h('td', {}, h('a', { class: 'row-link', href: `/admin/users/${m.user_id}`, text: m.username })),
h('td', { class: 'muted small', text: m.role }),
h('td', { class: 'row-actions' },
h('button', {
class: 'btn-sm', type: 'button',
text: m.role === 'owner' ? 'Make member' : 'Make owner',
// The same endpoint both ways: adding is an upsert on the role.
onclick: () => act(() =>
api.addTeamMember(teamID, m.user_id, m.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(teamID, m.user_id)),
}),
),
));
const inTeam = new Set(data.members.map((m) => m.user_id));
// A disabled account cannot sign in, so putting one on a rota would be
// staffing the team with somebody who cannot answer.
const candidates = data.users.filter((u) => !inTeam.has(u.id) && !u.disabled_at);
const pick = h('select', {},
...candidates.map((u) => h('option', { value: String(u.id), text: u.username })));
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(teamID, Number(pick.value), role.value));
});
return h('div', { class: 'card' },
h('h2', { text: 'Members' }),
data.members.length === 0 && h('p', { class: 'muted small' },
'Nobody is in this team. Its queue has no one to work it and its ',
'escalation has no one to reach — add somebody, or delete it.'),
data.members.length > 0 && h('table', { class: 'admin-table' }, h('tbody', {}, rows)),
candidates.length > 0 && form,
);
}
// --- invites ---------------------------------------------------------------
// Adding a person to the server is minting them an invite into a team, not
// creating a row: whoever accepts it picks their own password, so one never
// passes through an administrator, and the link carries the team, so they do
// not land on an empty queue.
//
// This lives on the team rather than on the Users page, where it used to be
// with a team picker beside it. The picker was the admission that an invite is
// a fact about a team.
function invitesCard() {
const role = h('select', {},
h('option', { value: 'member', text: 'member' }),
h('option', { value: 'owner', text: 'owner' }));
const form = h('form', { class: 'inline-form' }, 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(teamID, role.value, 1);
freshInvite = inv.url;
error = null;
} catch (err) {
error = err.message;
} finally {
busy = false;
}
await refresh();
});
// The server lists spent and revoked invites too, and they are worth seeing:
// "who was invited here" is part of the answer to "who is in this team".
// Only a live one can be revoked, so only a live one offers the button.
const rows = (data.invites || []).map((inv) => {
const state = inviteState(inv);
return h('tr', { class: state === 'live' ? '' : 'disabled-row' },
h('td', {}, h('strong', { text: inv.role })),
h('td', { class: 'muted small', text: `${inv.uses}/${inv.max_uses} used` }),
h('td', { class: 'muted small', text: state === 'live' ? `expires ${when(inv.expires_at)}` : state }),
h('td', { class: 'row-actions' },
state === 'live' && h('button', {
class: 'btn-sm danger', type: 'button', text: 'Revoke',
onclick: () => act(() => api.revokeInvite(teamID, inv.id)),
})),
);
});
return h('div', { class: 'card' },
h('h2', { text: 'Invites' }),
h('p', { class: 'muted small' },
'An invite link puts somebody in this team and lets them choose their ',
'own password. It lasts a week and can be used once.'),
rows.length > 0 && h('table', { class: 'admin-table' }, h('tbody', {}, rows)),
form,
// Shown once and never stored, so it goes on the page to be copied.
freshInvite && h('p', { class: 'invite-out' },
h('strong', { text: 'Send them this link. It is shown once.' }),
h('code', { class: 'invite-link', text: freshInvite })),
);
}
// Why a link no longer works, in the server's own order of precedence: revoked
// beats spent beats expired. Only 'live' is still usable.
function inviteState(inv) {
if (inv.revoked) return 'revoked';
if (inv.uses >= inv.max_uses) return 'used up';
if (new Date(inv.expires_at).getTime() <= Date.now()) return 'expired';
return 'live';
}
// --- delete ----------------------------------------------------------------
function dangerCard() {
const t = data.team;
const blocked = t.open_incidents > 0;
return h('div', { class: 'card' },
h('h2', { text: 'Delete' }),
h('p', { class: 'muted small' },
'Its alerts, incidents, schedule and integrations go with it. This ',
'cannot be undone. Everybody in it keeps their account and stays in ',
'whatever other teams they are in.'),
h('button', {
class: 'btn btn-danger', type: 'button', text: `Delete ${t.name}`,
// Saying so before the click is kinder than a 409 afterwards.
disabled: blocked,
title: blocked ? 'Resolve its open incidents first' : '',
onclick: deleteTeam,
}),
);
}
async function deleteTeam() {
if (!(await confirm({
title: `Delete ${data.team.name}?`,
text: 'Its alerts, incidents, schedule and integrations go with it. This cannot be undone.',
confirmLabel: 'Delete',
danger: true,
}))) return;
try {
await api.deleteTeam(teamID);
} catch (err) {
error = err.message;
render();
return;
}
toast('Team deleted.');
// Not act(): there is no longer a page here to refresh.
navigate('/admin/teams');
}
// --- plumbing --------------------------------------------------------------
// act runs a write and reloads. Errors are shown rather than thrown away: the
// 409 from the last-owner guard, and the one for a duplicate name, are 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();
}
+2 -2
View File
@@ -83,7 +83,7 @@ function render() {
}
function backLink() {
return h('a', { class: 'back-link', href: '/admin' }, icon('chevronLeft'), h('span', { text: 'Admin' }));
return h('a', { class: 'back-link', href: '/admin/users' }, icon('chevronLeft'), h('span', { text: 'Users' }));
}
// --- identity --------------------------------------------------------------
@@ -271,7 +271,7 @@ async function deleteUser() {
return;
}
toast('User deleted.');
navigate('/admin');
navigate('/admin/users');
}
// --- plumbing --------------------------------------------------------------
+9
View File
@@ -71,6 +71,11 @@ export const users = () => call('GET', '/users');
// about anybody else, which is what the admin page's per-user view needs.
export const userTeams = (id) => call('GET', `/users/${id}/teams`);
// Where this user's pages go. An empty topic clears it, which the server
// treats as "no topic of their own" rather than an error.
export const setNotifyTarget = (id, ntfyTopic) =>
call('PUT', `/users/${id}/notify`, { body: { ntfy_topic: ntfyTopic } });
// incidents
export const incidents = (query, opts) => call('GET', '/incidents', { query, ...opts });
export const incident = (id) => call('GET', `/incidents/${id}`);
@@ -137,6 +142,10 @@ export const unassignSchedule = (id, entryID) => call('DELETE', `/teams/${id}/sc
// Administration. Every one of these is refused with 403 for anybody without
// the flag, so the UI hides the section rather than guarding it.
export const adminTeams = () => call('GET', '/admin/teams');
// One team and who is in it: { team, members }. /teams/{id}/members is
// member-only and answers 404 to an administrator from outside the team, which
// is the rule rather than an oversight -- this asks the other question.
export const adminTeam = (id) => call('GET', `/admin/teams/${id}`);
export const adminSettings = () => call('GET', '/admin/settings');
export const setAdminSettings = (body) => call('PUT', '/admin/settings', { body });
export const setUserAdmin = (id, isAdmin) =>
+29 -5
View File
@@ -12,6 +12,7 @@ import * as account from './account.js';
import * as team from './team.js';
import * as admin from './admin.js';
import * as adminuser from './adminuser.js';
import * as adminteam from './adminteam.js';
const $ = (id) => document.getElementById(id);
@@ -25,6 +26,7 @@ const SECTIONS = {
team: { title: 'Team', view: team },
admin: { title: 'Admin', view: admin },
adminuser: { title: 'User', view: adminuser, nav: 'admin' },
adminteam: { title: 'Team', view: adminteam, nav: 'admin' },
more: { title: 'Account', view: account },
};
@@ -33,11 +35,29 @@ function parseRoute(pathname) {
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]) };
// Before the TABS lookup below, which matches a path exactly and would let
// /admin/teams/7 fall through to the queue.
const g = pathname.match(/^\/admin\/teams\/(\d+)\/?$/);
if (g) return { section: 'adminteam', team: Number(g[1]) };
const name = pathname.replace(/^\/|\/$/g, '');
if (name === 'oncall' || name === 'alerts' || name === 'team' || name === 'admin' || name === 'more') return { section: name };
// The Admin tab's sub-sections are routes of their own. admin.js owns the
// table of them, since it also builds the strip that links to them.
const t = admin.TABS.find((x) => x.path === `/${name}`);
if (t) return { section: 'admin', tab: t.tab };
if (name === 'oncall' || name === 'alerts' || name === 'team' || name === 'more') return { section: name };
return { section: 'queue', incident: null };
}
// What the top bar and the document title call this route. Admin's sub-sections
// are pages in their own right, so they say which one rather than "Admin" four
// times; the overview keeps the tab's own name.
function title(r) {
const t = r.section === 'admin' && r.tab
? admin.TABS.find((x) => x.tab === r.tab)
: null;
return t ? t.label : SECTIONS[r.section].title;
}
let route = parseRoute(location.pathname);
// How many in-app navigations deep we are, so Back can use the browser's
// history when there is somewhere to go back to, and the queue otherwise.
@@ -70,10 +90,10 @@ function render() {
route = parseRoute(location.pathname);
const app = $('app');
for (const [name, s] of Object.entries(SECTIONS)) {
for (const name of Object.keys(SECTIONS)) {
const el = $(`view-${name}`);
el.hidden = name !== route.section;
if (name === route.section) $('topbar-title').textContent = s.title;
if (name === route.section) $('topbar-title').textContent = title(route);
}
// 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.
@@ -99,7 +119,11 @@ function render() {
if (detailOpen && !wasOpen) window.scrollTo(0, 0);
else if (!detailOpen && wasOpen) requestAnimationFrame(() => window.scrollTo(0, listScroll));
else if (prev.section !== route.section) window.scrollTo(0, 0);
// A changed tab counts as a changed page: stepping from a long user list to
// the settings should not land you halfway down them. So does a changed
// subject — one team to the next is two pages, not one scrolled page.
else if (prev.section !== route.section || prev.tab !== route.tab
|| prev.user !== route.user || prev.team !== route.team) window.scrollTo(0, 0);
updateTitle();
}
@@ -138,7 +162,7 @@ function updateBadges() {
function updateTitle() {
const triggered = state.open.filter((i) => i.status === 'triggered').length;
const section = SECTIONS[route.section].title;
const section = title(route);
const base = route.section === 'queue' && route.incident == null ? 'terdut' : `${section} · terdut`;
document.title = triggered ? `(${triggered}) ${base}` : base;
}
+194 -23
View File
@@ -10,9 +10,9 @@
// than no form, but it is not the thing enforcing anything.
import * as api from './api.js';
import { h, clear, spinner, confirm } from './ui.js';
import { state, currentTeam, users as allUsers } from './state.js';
import { isoDate, addDays } from './format.js';
import { h, clear, spinner, confirm, icon, openSheet, closeSheet } from './ui.js';
import { state, currentTeam, users as allUsers, myID } from './state.js';
import { isoDate, addDays, mondayOf, initial } from './format.js';
const view = () => document.getElementById('view-team');
@@ -39,6 +39,7 @@ export async function refresh() {
return;
}
teamID = team.id;
const grid = gridDays();
try {
// A member may read all of this; only the writes are owner-only.
const [members, integrations, escalation, deadman, schedule, users] = await Promise.all([
@@ -46,7 +47,7 @@ export async function refresh() {
api.integrations(team.id),
api.escalation(team.id),
api.deadman(team.id),
api.schedule(team.id, isoDate(new Date()), isoDate(addDays(new Date(), 30))),
api.schedule(team.id, isoDate(grid.start), isoDate(addDays(grid.start, grid.count - 1))),
allUsers(),
]);
data = { team, members, integrations, escalation, deadman, schedule, users };
@@ -104,30 +105,200 @@ function teamPicker() {
// The rota is one person per UTC day. The on-call page shows it; this is where
// it is set, which until now was the TUI's job and the TUI cannot do it any
// more.
function scheduleCard() {
const rows = (data.schedule || []).map((e) =>
h('tr', {},
h('td', { text: e.date }),
h('td', {}, h('strong', { text: e.username })),
h('td', {}, isOwner() && h('button', {
class: 'btn-sm danger', type: 'button', text: 'Clear',
onclick: () => act(() => api.unassignSchedule(teamID, e.id)),
})),
));
//
// A month of it, as a grid. It used to be thirty rows of "date — username",
// which is a rota spelled out one day at a time: the question asked of it is
// "who has which stretch", and thirty names down a column is the one shape
// that answer cannot be read in. So each day carries a coloured initial
// instead, the legend says whose, and a shift becomes a run of one colour.
//
// The same month laid out the same way as the on-call page's week, because it
// is the same rota — heading and arrows outside the card, days inside it.
return h('div', { class: 'card' },
h('h2', { text: 'On-call rota' }),
h('p', { class: 'muted small', text: 'One person per UTC day, for the next 30 days.' }),
rows.length
? h('table', { class: 'admin-table' }, h('tbody', {}, rows))
: h('p', { class: 'muted', text: 'Nobody is scheduled.' }),
isOwner() && assignForm(),
);
const monthFmt = new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric' });
const weekdayFmt = new Intl.DateTimeFormat(undefined, { weekday: 'short' });
const longDayFmt = new Intl.DateTimeFormat(undefined, {
weekday: 'long', day: 'numeric', month: 'long',
});
let monthStart = firstOfMonth(new Date());
function firstOfMonth(d) {
return new Date(d.getFullYear(), d.getMonth(), 1);
}
// The grid runs Monday to Sunday, so it starts before the 1st and ends after
// the last. Both overhangs are fetched and drawn: a shift that begins on the
// 30th is a fact about this month even though the days it runs into are not.
function gridDays() {
const start = mondayOf(monthStart);
const last = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 0);
const span = Math.round((last - start) / 86400000) + 1;
return { start, count: Math.ceil(span / 7) * 7 };
}
function shiftMonth(n) {
monthStart = new Date(monthStart.getFullYear(), monthStart.getMonth() + n, 1);
refresh();
}
function scheduleCard() {
const { start, count } = gridDays();
const byDate = new Map((data.schedule || []).map((e) => [e.date, e]));
const today = isoDate(new Date());
const month = monthStart.getMonth();
// Whose colours to explain, in the order the month meets them. Only the days
// of this month count: a name that appears solely in the overhang belongs to
// the month next door and would be explaining a chip nobody asked about.
const seen = new Map();
const cells = [];
for (let i = 0; i < count; i++) {
const d = addDays(start, i);
const key = isoDate(d);
const e = byDate.get(key);
const inMonth = d.getMonth() === month;
if (inMonth && e && !seen.has(e.user_id)) seen.set(e.user_id, e.username);
cells.push(dayCell(d, key, e, inMonth, today));
}
const heads = [];
for (let i = 0; i < 7; i++) {
// Any Monday will do; this one is a Monday.
heads.push(h('span', { class: 'rota-wd', text: weekdayFmt.format(new Date(2024, 0, 1 + i)) }));
}
return [
h('div', { class: 'page-head' },
h('h2', { text: 'On-call rota' }),
h('div', { class: 'week-nav' },
h('button', {
class: 'btn btn-ghost btn-icon', type: 'button',
'aria-label': 'Previous month', onclick: () => shiftMonth(-1),
}, icon('chevronLeft')),
h('button', {
class: 'btn btn-ghost label', type: 'button',
title: 'Back to this month',
onclick: () => { monthStart = firstOfMonth(new Date()); refresh(); },
text: monthFmt.format(monthStart),
}),
h('button', {
class: 'btn btn-ghost btn-icon', type: 'button',
'aria-label': 'Next month', onclick: () => shiftMonth(1),
}, icon('chevronRight')),
),
),
h('div', { class: 'card' },
h('div', { class: 'rota-grid' }, heads, cells),
h('div', { class: 'rota-foot' }, legend(seen), coverNote(byDate)),
// The range form is the way to fill a whole shift at once, but it is not
// what the page is for, so it stays folded away under the month it edits.
isOwner() && h('details', { class: 'rota-bulk' },
h('summary', { text: 'Assign a range of days' }),
assignForm()),
),
];
}
function dayCell(d, key, e, inMonth, today) {
const cls = ['rota-day', !inMonth && 'outside', key === today && 'today', key < today && 'past']
.filter(Boolean).join(' ');
const label = `${key} · ${e ? e.username : 'nobody'}`;
const body = [
h('span', { class: 'rota-num', text: String(d.getDate()) }),
e
? h('span', { class: `rota-chip ${colorClass(e.user_id)}`, text: initial(e.username) })
: h('span', { class: 'rota-chip none' }),
];
// A member sees the same grid without the affordance, the way every other
// control on this page is hidden rather than shown and refused.
return isOwner()
? h('button', {
class: cls, type: 'button', title: label, 'aria-label': label,
onclick: () => daySheet(key, e),
}, body)
: h('div', { class: cls, title: label }, body);
}
// A colour per person, taken from their place in the member list so that it
// holds still as you page between months. Somebody who holds days but has
// since left the team is not in that list and falls back to their id.
function colorClass(userID) {
const i = (data.members || []).findIndex((m) => m.user_id === userID);
return `rc${((i < 0 ? userID : i) % 6) + 1}`;
}
function legend(seen) {
if (!seen.size) return null;
return h('div', { class: 'rota-legend' },
[...seen].map(([id, name]) => h('span', { class: 'rota-key' },
h('span', { class: `rota-chip ${colorClass(id)}`, text: initial(name) }),
h('span', { text: name }),
id === myID() && h('span', { class: 'you', text: 'you' }),
)));
}
// The gap count, which is the one thing the grid states only by omission. Days
// already past are not counted: an empty Tuesday last week is history, not a
// hole somebody still has to fill.
function coverNote(byDate) {
const today = isoDate(new Date());
const last = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 0).getDate();
let gaps = 0;
for (let day = 1; day <= last; day++) {
const key = isoDate(new Date(monthStart.getFullYear(), monthStart.getMonth(), day));
if (key >= today && !byDate.has(key)) gaps++;
}
if (gaps === 0) return h('p', { class: 'rota-note', text: 'Every day left this month has somebody on call.' });
return h('p', { class: 'rota-note' },
h('strong', { text: gaps === 1 ? '1 day' : `${gaps} days` }),
' left this month with nobody on call.');
}
// One day, in the sheet: who has it, who should, and the way to empty it. This
// is where the per-row Clear button went — the grid has no room for thirty of
// them, and the day you want to change is the one you just tapped.
function daySheet(date, entry) {
const who = memberSelect(entry ? entry.user_id : undefined);
openSheet(() => [
h('h2', { class: 'sheet-title', text: longDayFmt.format(parseISO(date)) }),
h('p', { class: 'sheet-text', text: entry ? `${entry.username} is on call.` : 'Nobody is on call.' }),
h('label', { class: 'sheet-pick' }, 'On call ', who),
h('div', { class: 'sheet-actions' },
entry && h('button', {
class: 'btn btn-danger', type: 'button', text: 'Clear',
onclick: () => { closeSheet(); act(() => api.unassignSchedule(teamID, entry.id)); },
}),
h('button', {
class: 'btn btn-primary', type: 'button', autofocus: true, text: 'Assign',
// replace, where the range form asks first: the sheet has just named
// whoever holds the day, so taking it from them is the thing that was
// asked for rather than something to be warned about.
onclick: () => {
closeSheet();
act(() => api.assignSchedule(teamID, Number(who.value), [date], true));
},
}),
),
]);
}
function parseISO(s) {
const [y, m, d] = s.split('-').map(Number);
return new Date(y, m - 1, d);
}
function assignForm() {
const who = memberSelect();
const from = h('input', { type: 'date', required: true, value: isoDate(new Date()) });
// The form opens on the month above it rather than on today: it is folded
// into that month's card, and paging to March to fill March and being handed
// today's date would be the card and the form disagreeing about the subject.
const now = new Date();
const sameMonth = monthStart.getFullYear() === now.getFullYear()
&& monthStart.getMonth() === now.getMonth();
const from = h('input', {
type: 'date', required: true, value: isoDate(sameMonth ? now : monthStart),
});
const days = h('input', { type: 'number', min: '1', max: '31', value: '1', class: 'setting-value' });
const replace = h('input', { type: 'checkbox' });