diff --git a/DECISIONS.md b/DECISIONS.md index 291201e..0ca61af 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -137,3 +137,16 @@ Spec-silent choices, recorded as required by the build instructions. - **`scripts/acceptance.sh`** automates the §13 checklist live (import → subdivide → extend → publish → claim/decline/approve → review → award → AI job via real Claude Code → breaker independence) and is re-run-safe. + +## Post-deploy additions + +- **WeKan connector**: `projectKey` is the board id; "assigned to the + consultant" means the consultant's WeKan username (from + `ticketingIdentities.wekan`) appears in a card's `assignees` (falling back + to `members` when no assignee is set). Archived cards are skipped. WeKan + has no epic/story hierarchy → all cards map to type `task`; the API offers + no server-side updated-since filter, so the connector filters on + `modifiedAt` client-side (one details request per card — fine for board + sizes WeKan handles). Auth: username+password login per sync with a 10-min + token reuse window, or a pre-issued token. Card attachments are not + imported in v1. diff --git a/PROGRESS.md b/PROGRESS.md index 773d723..8b59e61 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -14,3 +14,4 @@ - Phase 11 (mock services): services/atomizer (own Go module, ports 8090, bearer auth, OpenAI-compatible chat completions + native /v1/messages via LLM_API_STYLE, strict-JSON prompt, fence-stripping defensive parse, one retry, deterministic equal-split fallback, exact sum normalization); services/work-performer (Node 20, zero deps, single-concurrency queue, TASK.md + attachment download + optional git clone, claude -p --output-format json --dangerously-skip-permissions, simulated result when CLI unavailable, artifact serving, HMAC-signed callbacks with retry); compose profile mocks w/ healthchecks + ${HOME}/.claude mounts; contract tests pass live (WP ran real Claude Code via mounted host creds). UFW rule added for container→host callbacks. - Phase 12 (polish): seed script (make seed: demo customer + 1 admin + 2 consultants + 6 devs + pools + conversations, idempotent), forgot/reset password (SMTP-gated, one-shot TTL tokens, anti-enumeration), profile hover cards, keyboard shortcuts (g b / g m / g t / g h / focus search), bulk archive endpoint, hand-written api/openapi.yaml served at /api/docs, make backup/restore via mongodump, README w/ runbook + Caddy & nginx samples (WS + client_max_body_size), extended register→login→board smoke script — all tests green. - Phase 13 (test & deploy): full unit (10 pkgs) + integration (12 pkgs) + contract suites green; scripts/acceptance.sh automates the §13 checklist live and PASSES end-to-end incl. a real Claude Code AI work-performer run; readyz verified 503/200 across a Mongo stop/start; smoke PASS; stack left running with mocks profile. Fixes: APP_INTERNAL_URL for in-network callbacks/signed URLs, work-performer runs as node user (claude refuses root), sudo HOME gotcha documented, UFW rule for container→host callbacks. +- Post-deploy: ANTHROPIC_API_KEY wired into .env (gitignored) — atomizer now produces real claude-sonnet-4-6 subdivisions (verified live, sum=1.0). Added WeKan ticketing source: connector (login or pre-issued token, board=projectKey, cards assigned to consultant's wekan username w/ member fallback, archived skipped, modifiedAt since-filter, token reuse), fake-server unit tests, admin wizard fields, OpenAPI/README updates. diff --git a/README.md b/README.md index 725bafb..22fc83a 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Bounty Board A consulting work-management platform: imports tickets from Jira / Azure -DevOps / YouTrack (or an offline `demo` source), lets **consultants** atomize +DevOps / YouTrack / WeKan (or an offline `demo` source), lets **consultants** atomize them into small developer tasks with AI assistance, publishes them on a **bounty board** where **developers** claim work (or an **AI work performer** does it), and tracks review, approval, and bounty-based metrics. @@ -176,7 +176,9 @@ limits, and the mock services' Anthropic settings. Consultants map their ticketing identities in their profile via `users.extra.ticketingIdentities` (e.g. set by an admin): -`{"jira": "consultant@corp.com", "azure_devops": "consultant@corp.com", "youtrack": "consultant.login"}`. +`{"jira": "consultant@corp.com", "azure_devops": "consultant@corp.com", "youtrack": "consultant.login", "wekan": "wekan-username"}`. +For WeKan the customer's project key is the **board id** and cards assigned +to the consultant (assignees, falling back to members) are imported. The `demo` ticketing type needs no identity. ## Deliberately out of scope (documented stubs, §11.24) diff --git a/api/openapi.yaml b/api/openapi.yaml index 8b6955d..fa5e5a1 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -269,7 +269,7 @@ components: required: [name, type] properties: name: {type: string} - type: {type: string, enum: [jira, azure_devops, youtrack, demo]} + type: {type: string, enum: [jira, azure_devops, youtrack, wekan, demo]} baseUrl: {type: string} projectKey: {type: string} pollIntervalSec: {type: integer, minimum: 10} @@ -277,7 +277,7 @@ components: consultantIds: {type: array, items: {type: string}} credentials: type: object - description: "jira: {email, apiToken} · azure_devops: {organization, project, pat} · youtrack: {permanentToken} · demo: {}" + description: "jira: {email, apiToken} · azure_devops: {organization, project, pat} · youtrack: {permanentToken} · wekan: {username, password} (projectKey = board id) · demo: {}" schemas: Error: type: object diff --git a/internal/domain/customer.go b/internal/domain/customer.go index 4df3b1b..9dbd88b 100644 --- a/internal/domain/customer.go +++ b/internal/domain/customer.go @@ -8,6 +8,7 @@ const ( TicketingJira TicketingType = "jira" TicketingAzure TicketingType = "azure_devops" TicketingYouTrack TicketingType = "youtrack" + TicketingWekan TicketingType = "wekan" // TicketingDemo fabricates tickets locally so the whole flow works // offline (§11.11). TicketingDemo TicketingType = "demo" @@ -15,7 +16,7 @@ const ( func (t TicketingType) Valid() bool { switch t { - case TicketingJira, TicketingAzure, TicketingYouTrack, TicketingDemo: + case TicketingJira, TicketingAzure, TicketingYouTrack, TicketingWekan, TicketingDemo: return true } return false diff --git a/internal/http/admin_customers.go b/internal/http/admin_customers.go index e893004..1b7d712 100644 --- a/internal/http/admin_customers.go +++ b/internal/http/admin_customers.go @@ -119,7 +119,7 @@ func (s *Server) handleAdminCreateCustomer(w http.ResponseWriter, r *http.Reques return } if req.Type == nil || !req.Type.Valid() { - writeError(w, http.StatusBadRequest, "invalid_type", "ticketing type must be jira, azure_devops, youtrack or demo") + writeError(w, http.StatusBadRequest, "invalid_type", "ticketing type must be jira, azure_devops, youtrack, wekan or demo") return } settings, err := s.store.GetSettings(r.Context()) diff --git a/internal/sync/connector.go b/internal/sync/connector.go index a886217..0f8e045 100644 --- a/internal/sync/connector.go +++ b/internal/sync/connector.go @@ -71,6 +71,23 @@ func NewConnector(t domain.TicketingType, baseURL, projectKey string, creds Cred return nil, fmt.Errorf("youtrack requires permanentToken") } return &youtrackConnector{baseURL: baseURL, projectKey: projectKey, token: creds["permanentToken"]}, nil + case domain.TicketingWekan: + hasLogin := creds["username"] != "" && creds["password"] != "" + hasToken := creds["token"] != "" + if !hasLogin && !hasToken { + return nil, fmt.Errorf("wekan requires username+password (or a pre-issued token)") + } + if baseURL == "" { + return nil, fmt.Errorf("wekan requires baseUrl") + } + if projectKey == "" { + return nil, fmt.Errorf("wekan requires projectKey (the board id)") + } + return &wekanConnector{ + baseURL: baseURL, boardID: projectKey, + username: creds["username"], password: creds["password"], + token: creds["token"], + }, nil case domain.TicketingDemo: return &demoConnector{projectKey: projectKey}, nil default: diff --git a/internal/sync/wekan.go b/internal/sync/wekan.go new file mode 100644 index 0000000..6e03ad3 --- /dev/null +++ b/internal/sync/wekan.go @@ -0,0 +1,241 @@ +package sync + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "time" +) + +// wekanConnector imports cards from a WeKan board. projectKey is the board +// id; the consultant's identity (users.extra.ticketingIdentities.wekan) is +// their WeKan username — cards assigned to that user (assignees, falling +// back to members) become tasks. +// +// Credentials: {"username": "...", "password": "..."} — the connector logs +// in per sync (WeKan tokens are short-lived); alternatively a pre-issued +// {"token": "...", "userId": "..."} pair is used directly. +type wekanConnector struct { + baseURL string + boardID string + username string + password string + + mu sync.Mutex + token string + tokenAt time.Time +} + +const wekanTokenTTL = 10 * time.Minute // re-login window for password auth + +func (wk *wekanConnector) Authorize(req *http.Request) { + wk.mu.Lock() + defer wk.mu.Unlock() + if wk.token != "" { + req.Header.Set("Authorization", "Bearer "+wk.token) + } +} + +// ensureAuth logs in with username/password unless a still-fresh (or +// pre-issued) token is available. +func (wk *wekanConnector) ensureAuth(ctx context.Context) error { + wk.mu.Lock() + haveFresh := wk.token != "" && (wk.password == "" || time.Since(wk.tokenAt) < wekanTokenTTL) + wk.mu.Unlock() + if haveFresh { + return nil + } + body, _ := json.Marshal(map[string]string{"username": wk.username, "password": wk.password}) + var out struct { + ID string `json:"id"` + Token string `json:"token"` + } + err := doJSON(ctx, func(*http.Request) {}, http.MethodPost, wk.baseURL+"/users/login", body, &out) + if err != nil { + return fmt.Errorf("wekan login: %w", err) + } + if out.Token == "" { + return fmt.Errorf("wekan login: empty token in response") + } + wk.mu.Lock() + wk.token, wk.tokenAt = out.Token, time.Now() + wk.mu.Unlock() + return nil +} + +func (wk *wekanConnector) TestConnection(ctx context.Context) error { + if err := wk.ensureAuth(ctx); err != nil { + return err + } + var board struct { + Title string `json:"title"` + } + if err := getJSON(ctx, wk.Authorize, wk.baseURL+"/api/boards/"+wk.boardID, &board); err != nil { + return fmt.Errorf("wekan board %s: %w", wk.boardID, err) + } + if board.Title == "" { + return fmt.Errorf("wekan board %s: not found or no access", wk.boardID) + } + return nil +} + +type wekanBoard struct { + Title string `json:"title"` + Slug string `json:"slug"` + Members []struct { + UserID string `json:"userId"` + } `json:"members"` +} + +type wekanCardDetail struct { + ID string `json:"_id"` + Title string `json:"title"` + Description string `json:"description"` + ListID string `json:"listId"` + Assignees []string `json:"assignees"` + Members []string `json:"members"` + ModifiedAt string `json:"modifiedAt"` + DateLastActivity string `json:"dateLastActivity"` + Archived bool `json:"archived"` +} + +// resolveUserID maps the consultant's WeKan username to a board member's +// userId. +func (wk *wekanConnector) resolveUserID(ctx context.Context, board *wekanBoard, username string) (string, error) { + for _, m := range board.Members { + var u struct { + Username string `json:"username"` + } + if err := getJSON(ctx, wk.Authorize, wk.baseURL+"/api/users/"+m.UserID, &u); err != nil { + continue // non-admin tokens may not read every user; skip + } + if strings.EqualFold(u.Username, username) { + return m.UserID, nil + } + } + return "", fmt.Errorf("wekan user %q is not a member of board %s", username, wk.boardID) +} + +// assignedCards walks lists → cards → card details and returns the cards +// assigned to userID (assignees, else members). +func (wk *wekanConnector) assignedCards(ctx context.Context, userID string) ([]wekanCardDetail, map[string]string, error) { + var lists []struct { + ID string `json:"_id"` + Title string `json:"title"` + } + if err := getJSON(ctx, wk.Authorize, wk.baseURL+"/api/boards/"+wk.boardID+"/lists", &lists); err != nil { + return nil, nil, fmt.Errorf("wekan lists: %w", err) + } + listTitles := map[string]string{} + var out []wekanCardDetail + for _, list := range lists { + listTitles[list.ID] = list.Title + var cards []struct { + ID string `json:"_id"` + } + url := fmt.Sprintf("%s/api/boards/%s/lists/%s/cards", wk.baseURL, wk.boardID, list.ID) + if err := getJSON(ctx, wk.Authorize, url, &cards); err != nil { + return nil, nil, fmt.Errorf("wekan cards of list %s: %w", list.ID, err) + } + for _, c := range cards { + var detail wekanCardDetail + detailURL := fmt.Sprintf("%s/api/boards/%s/lists/%s/cards/%s", wk.baseURL, wk.boardID, list.ID, c.ID) + if err := getJSON(ctx, wk.Authorize, detailURL, &detail); err != nil { + return nil, nil, fmt.Errorf("wekan card %s: %w", c.ID, err) + } + if detail.Archived { + continue + } + assigned := detail.Assignees + if len(assigned) == 0 { + assigned = detail.Members + } + for _, a := range assigned { + if a == userID { + detail.ListID = list.ID + out = append(out, detail) + break + } + } + } + } + return out, listTitles, nil +} + +func (wk *wekanConnector) cardTime(c wekanCardDetail) time.Time { + for _, raw := range []string{c.ModifiedAt, c.DateLastActivity} { + if raw == "" { + continue + } + if ts, err := time.Parse(time.RFC3339, raw); err == nil { + return ts + } + } + return time.Time{} // unknown → treat as always-updated +} + +func (wk *wekanConnector) FetchUpdated(ctx context.Context, identity string, since time.Time) ([]Ticket, error) { + if err := wk.ensureAuth(ctx); err != nil { + return nil, err + } + var board wekanBoard + if err := getJSON(ctx, wk.Authorize, wk.baseURL+"/api/boards/"+wk.boardID, &board); err != nil { + return nil, fmt.Errorf("wekan board: %w", err) + } + userID, err := wk.resolveUserID(ctx, &board, identity) + if err != nil { + return nil, err + } + cards, listTitles, err := wk.assignedCards(ctx, userID) + if err != nil { + return nil, err + } + + slug := board.Slug + if slug == "" { + slug = "board" + } + out := []Ticket{} + for _, c := range cards { + if ts := wk.cardTime(c); !ts.IsZero() && ts.Before(since) { + continue + } + out = append(out, Ticket{ + Key: c.ID, + URL: fmt.Sprintf("%s/b/%s/%s/%s", wk.baseURL, wk.boardID, slug, c.ID), + Type: "task", // kanban cards carry no epic/story hierarchy + Title: c.Title, + Description: c.Description, + Raw: map[string]any{ + "board": board.Title, "list": listTitles[c.ListID], "cardId": c.ID, + }, + }) + } + return out, nil +} + +func (wk *wekanConnector) ListAssignedKeys(ctx context.Context, identity string) ([]string, error) { + if err := wk.ensureAuth(ctx); err != nil { + return nil, err + } + var board wekanBoard + if err := getJSON(ctx, wk.Authorize, wk.baseURL+"/api/boards/"+wk.boardID, &board); err != nil { + return nil, fmt.Errorf("wekan board: %w", err) + } + userID, err := wk.resolveUserID(ctx, &board, identity) + if err != nil { + return nil, err + } + cards, _, err := wk.assignedCards(ctx, userID) + if err != nil { + return nil, err + } + keys := make([]string, len(cards)) + for i, c := range cards { + keys[i] = c.ID + } + return keys, nil +} diff --git a/internal/sync/wekan_test.go b/internal/sync/wekan_test.go new file mode 100644 index 0000000..2f8c0b0 --- /dev/null +++ b/internal/sync/wekan_test.go @@ -0,0 +1,226 @@ +package sync + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "bountyboard/internal/domain" +) + +// fakeWekan implements just enough of the WeKan REST API: login, board with +// members, lists, cards, card details with assignees and timestamps. +func fakeWekan(t *testing.T, logins *atomic.Int64) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + authed := func(w http.ResponseWriter, r *http.Request) bool { + if r.Header.Get("Authorization") != "Bearer wekan-token-1" { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return false + } + return true + } + js := func(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(v) + } + + mux.HandleFunc("POST /users/login", func(w http.ResponseWriter, r *http.Request) { + var req struct{ Username, Password string } + json.NewDecoder(r.Body).Decode(&req) + if req.Username != "syncbot" || req.Password != "hunter2" { + http.Error(w, `{"error":"bad credentials"}`, http.StatusUnauthorized) + return + } + if logins != nil { + logins.Add(1) + } + js(w, map[string]string{"id": "u-syncbot", "token": "wekan-token-1"}) + }) + mux.HandleFunc("GET /api/boards/board1", func(w http.ResponseWriter, r *http.Request) { + if !authed(w, r) { + return + } + js(w, map[string]any{ + "title": "Sprint Board", "slug": "sprint-board", + "members": []map[string]any{ + {"userId": "u-clara"}, {"userId": "u-other"}, {"userId": "u-syncbot"}, + }, + }) + }) + mux.HandleFunc("GET /api/users/{id}", func(w http.ResponseWriter, r *http.Request) { + if !authed(w, r) { + return + } + names := map[string]string{"u-clara": "clara", "u-other": "othello", "u-syncbot": "syncbot"} + js(w, map[string]string{"username": names[r.PathValue("id")]}) + }) + mux.HandleFunc("GET /api/boards/board1/lists", func(w http.ResponseWriter, r *http.Request) { + if !authed(w, r) { + return + } + js(w, []map[string]string{{"_id": "l-todo", "title": "Todo"}, {"_id": "l-doing", "title": "Doing"}}) + }) + mux.HandleFunc("GET /api/boards/board1/lists/{list}/cards", func(w http.ResponseWriter, r *http.Request) { + if !authed(w, r) { + return + } + switch r.PathValue("list") { + case "l-todo": + js(w, []map[string]string{{"_id": "card-a"}, {"_id": "card-b"}, {"_id": "card-d"}}) + default: + js(w, []map[string]string{{"_id": "card-c"}}) + } + }) + cards := map[string]map[string]any{ + // assigned to clara, recently modified + "card-a": {"_id": "card-a", "title": "Fix the login flow", "description": "details A", + "assignees": []string{"u-clara"}, "modifiedAt": time.Now().UTC().Format(time.RFC3339)}, + // member-fallback assignment, old timestamp + "card-b": {"_id": "card-b", "title": "Old card", "description": "details B", + "assignees": []string{}, "members": []string{"u-clara"}, + "modifiedAt": "2020-01-01T00:00:00Z"}, + // assigned to someone else + "card-c": {"_id": "card-c", "title": "Not clara's", "description": "details C", + "assignees": []string{"u-other"}, "modifiedAt": time.Now().UTC().Format(time.RFC3339)}, + // clara's but archived + "card-d": {"_id": "card-d", "title": "Archived card", "description": "details D", + "assignees": []string{"u-clara"}, "archived": true, + "modifiedAt": time.Now().UTC().Format(time.RFC3339)}, + } + mux.HandleFunc("GET /api/boards/board1/lists/{list}/cards/{card}", func(w http.ResponseWriter, r *http.Request) { + if !authed(w, r) { + return + } + c, ok := cards[r.PathValue("card")] + if !ok { + http.NotFound(w, r) + return + } + js(w, c) + }) + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func newWekan(t *testing.T, srv *httptest.Server) Connector { + t.Helper() + conn, err := NewConnector(domain.TicketingWekan, srv.URL, "board1", + Credentials{"username": "syncbot", "password": "hunter2"}) + if err != nil { + t.Fatal(err) + } + return conn +} + +func TestWekanFactoryValidation(t *testing.T) { + if _, err := NewConnector(domain.TicketingWekan, "https://w.example", "board1", Credentials{}); err == nil { + t.Fatal("missing credentials must be rejected") + } + if _, err := NewConnector(domain.TicketingWekan, "", "board1", + Credentials{"username": "u", "password": "p"}); err == nil { + t.Fatal("missing baseUrl must be rejected") + } + if _, err := NewConnector(domain.TicketingWekan, "https://w.example", "", + Credentials{"username": "u", "password": "p"}); err == nil { + t.Fatal("missing board id must be rejected") + } + if _, err := NewConnector(domain.TicketingWekan, "https://w.example", "board1", + Credentials{"token": "tok", "userId": "u1"}); err != nil { + t.Fatalf("pre-issued token must be accepted: %v", err) + } +} + +func TestWekanTestConnection(t *testing.T) { + srv := fakeWekan(t, nil) + if err := newWekan(t, srv).TestConnection(context.Background()); err != nil { + t.Fatalf("test connection: %v", err) + } + + bad, err := NewConnector(domain.TicketingWekan, srv.URL, "board1", + Credentials{"username": "syncbot", "password": "wrong"}) + if err != nil { + t.Fatal(err) + } + if err := bad.TestConnection(context.Background()); err == nil { + t.Fatal("wrong password must fail the connection test") + } +} + +func TestWekanFetchUpdated(t *testing.T) { + srv := fakeWekan(t, nil) + conn := newWekan(t, srv) + + // since the epoch: clara gets card-a (assignee) and card-b (member + // fallback); card-c is someone else's, card-d is archived + tickets, err := conn.FetchUpdated(context.Background(), "clara", time.Unix(0, 0)) + if err != nil { + t.Fatal(err) + } + if len(tickets) != 2 { + t.Fatalf("tickets = %d, want 2 (%+v)", len(tickets), tickets) + } + byKey := map[string]Ticket{} + for _, tk := range tickets { + byKey[tk.Key] = tk + } + a, ok := byKey["card-a"] + if !ok { + t.Fatal("card-a missing") + } + if a.Title != "Fix the login flow" || a.Type != "task" { + t.Fatalf("card-a mapped wrong: %+v", a) + } + if a.URL != srv.URL+"/b/board1/sprint-board/card-a" { + t.Fatalf("card url: %s", a.URL) + } + if a.Raw["list"] != "Todo" || a.Raw["board"] != "Sprint Board" { + t.Fatalf("raw payload: %+v", a.Raw) + } + + // recent since-watermark filters out the old card-b + tickets, err = conn.FetchUpdated(context.Background(), "clara", time.Now().Add(-time.Hour)) + if err != nil { + t.Fatal(err) + } + if len(tickets) != 1 || tickets[0].Key != "card-a" { + t.Fatalf("since filter: %+v", tickets) + } + + // identity casing is forgiving + if _, err := conn.FetchUpdated(context.Background(), "CLARA", time.Unix(0, 0)); err != nil { + t.Fatalf("case-insensitive identity: %v", err) + } + + // unknown identity errors clearly + if _, err := conn.FetchUpdated(context.Background(), "nobody", time.Unix(0, 0)); err == nil { + t.Fatal("unknown wekan user must error") + } +} + +func TestWekanListAssignedKeysAndTokenReuse(t *testing.T) { + var logins atomic.Int64 + srv := fakeWekan(t, &logins) + conn := newWekan(t, srv) + + keys, err := conn.ListAssignedKeys(context.Background(), "clara") + if err != nil { + t.Fatal(err) + } + if len(keys) != 2 { + t.Fatalf("keys = %v, want card-a + card-b", keys) + } + // several operations reuse one login token instead of re-authenticating + if _, err := conn.FetchUpdated(context.Background(), "clara", time.Unix(0, 0)); err != nil { + t.Fatal(err) + } + if logins.Load() != 1 { + t.Fatalf("logins = %d, want 1 (token reuse)", logins.Load()) + } +} diff --git a/web/static/js/admin.js b/web/static/js/admin.js index 37e839e..a5ecbc4 100644 --- a/web/static/js/admin.js +++ b/web/static/js/admin.js @@ -25,6 +25,8 @@ function credsFromForm(type) { return { organization: val('c-ado-org'), project: val('c-ado-project'), pat: val('c-ado-pat') }; case 'youtrack': return { permanentToken: val('c-yt-token') }; + case 'wekan': + return { username: val('c-wekan-user'), password: val('c-wekan-pass') }; default: return {}; } diff --git a/web/templates/admin.html b/web/templates/admin.html index 355f660..bf7384d 100644 --- a/web/templates/admin.html +++ b/web/templates/admin.html @@ -31,6 +31,7 @@ + @@ -58,6 +59,12 @@
+