@maudecode/kanvil 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/server/dist/{auth-Bm6cIseE.mjs → auth-D43XyHdK.mjs} +266 -68
- package/apps/server/dist/client.mjs +2 -2
- package/apps/server/dist/{config-CrJuk5mY.mjs → config-CQVdVhXv.mjs} +1 -1
- package/apps/server/dist/index.mjs +167 -45
- package/apps/server/dist/migrate.mjs +1 -1
- package/apps/server/dist/recover.mjs +3 -3
- package/apps/server/dist/{src-D80LNZKP.mjs → src-D70GmZ5R.mjs} +170 -28
- package/apps/server/drizzle/0039_intake_credential.sql +2 -0
- package/apps/server/drizzle/0040_upload_urls.sql +20 -0
- package/apps/server/drizzle/0041_project_repositories.sql +2 -0
- package/apps/server/drizzle/meta/0039_snapshot.json +7020 -0
- package/apps/server/drizzle/meta/0040_snapshot.json +7170 -0
- package/apps/server/drizzle/meta/0041_snapshot.json +7183 -0
- package/apps/server/drizzle/meta/_journal.json +21 -0
- package/apps/web/dist/assets/{CompositeItem-Tueq2VKR.js → CompositeItem-DLTRsada.js} +1 -1
- package/apps/web/dist/assets/{Trans-BgU0eWAX.js → Trans-DqX-qhvQ.js} +1 -1
- package/apps/web/dist/assets/admin-settings-BQzdZII5.js +1 -0
- package/apps/web/dist/assets/{archive-restore-CSpoH6mS.js → archive-restore-BO1qng5Y.js} +1 -1
- package/apps/web/dist/assets/az-AZ-SXqDBPA2.js +1 -0
- package/apps/web/dist/assets/{az-DUcDY8Bp.js → az-CEW7-a7J.js} +1 -1
- package/apps/web/dist/assets/configuration-D0zvwrXt.js +1 -0
- package/apps/web/dist/assets/{corner-left-up-C3OGlXIB.js → corner-left-up-Be7Z9Uhf.js} +1 -1
- package/apps/web/dist/assets/{de-89C1V8Ut.js → de-B-oGVMYL.js} +1 -1
- package/apps/web/dist/assets/de-DE-CB7aQ6b-.js +1 -0
- package/apps/web/dist/assets/default-CCI1vZxa.js +56 -0
- package/apps/web/dist/assets/delivery-settings-BrwSrFIN.js +1 -0
- package/apps/web/dist/assets/{el-D6CICkhi.js → el-Cp69kezM.js} +1 -1
- package/apps/web/dist/assets/el-GR-D1ntYXIt.js +1 -0
- package/apps/web/dist/assets/{ellipsis-lJJU8zB7.js → ellipsis-B0AE8ax3.js} +1 -1
- package/apps/web/dist/assets/{es-B20Izib2.js → es-CwOMVSTx.js} +1 -1
- package/apps/web/dist/assets/es-ES-CllsaxcB.js +1 -0
- package/apps/web/dist/assets/{forms--87R7VQW.js → forms-CkVpTx29.js} +1 -1
- package/apps/web/dist/assets/{fr-DVlGnGsu.js → fr-Cy4co9ic.js} +1 -1
- package/apps/web/dist/assets/fr-FR-DxTw6LgI.js +1 -0
- package/apps/web/dist/assets/{hi-D1F5Pi_I.js → hi-DbO9TKwg.js} +1 -1
- package/apps/web/dist/assets/hi-IN-CaQga-0l.js +1 -0
- package/apps/web/dist/assets/{highlight-ygDJPvRy.js → highlight-zMg1BK-r.js} +1 -1
- package/apps/web/dist/assets/{id-LESXKW7G.js → id-B6NKsmCm.js} +1 -1
- package/apps/web/dist/assets/id-ID-DBfwAAqU.js +1 -0
- package/apps/web/dist/assets/index-CZuFd4LM.js +17 -0
- package/apps/web/dist/assets/index-DPT5LAs1.css +2 -0
- package/apps/web/dist/assets/invitations-Dp40BfHj.js +1 -0
- package/apps/web/dist/assets/it-IT-BckuH9Nl.js +1 -0
- package/apps/web/dist/assets/{it-CgRGAs6u.js → it-ilGYCq2T.js} +1 -1
- package/apps/web/dist/assets/item-DQR89bKm.js +1 -0
- package/apps/web/dist/assets/{ja-B7arhzgX.js → ja-D-hJRTgP.js} +1 -1
- package/apps/web/dist/assets/ja-JP-BAGgJKZ4.js +1 -0
- package/apps/web/dist/assets/ko-KR-srIEsTP5.js +1 -0
- package/apps/web/dist/assets/{ko-Ro_UonyA.js → ko-ajkkoJ_d.js} +1 -1
- package/apps/web/dist/assets/label-settings-ChnKkA97.js +1 -0
- package/apps/web/dist/assets/{markdown-blocks-toSPlHSc.js → markdown-blocks-CpzrwyQG.js} +1 -1
- package/apps/web/dist/assets/member-directory-XK0ZEGUq.js +1 -0
- package/apps/web/dist/assets/{mk-DR3kmrIp.js → mk-C_sMyEHX.js} +1 -1
- package/apps/web/dist/assets/mk-MK-BTa1NMAF.js +1 -0
- package/apps/web/dist/assets/{nl-D3uRJJLT.js → nl-D9-P4UPR.js} +1 -1
- package/apps/web/dist/assets/nl-NL-CQduhDqs.js +1 -0
- package/apps/web/dist/assets/{pl-CtzDOLdV.js → pl-CiQM7fSB.js} +1 -1
- package/apps/web/dist/assets/pl-PL-BdZMf7Ni.js +1 -0
- package/apps/web/dist/assets/project-lifecycle-VYecJuiP.js +1 -0
- package/apps/web/dist/assets/project-settings-Bs6YEpo9.js +1 -0
- package/apps/web/dist/assets/{pt-BR-FarXHHuf.js → pt-BR-CP7dhuqF.js} +1 -1
- package/apps/web/dist/assets/pt-BR-DZEXJNHg.js +1 -0
- package/apps/web/dist/assets/public-project-DUfWDao9.js +1 -0
- package/apps/web/dist/assets/{rotate-ccw-Byx7O3RH.js → rotate-ccw-Cxh9ec2s.js} +1 -1
- package/apps/web/dist/assets/{ru-DHMEVxvB.js → ru-DxJ6vF4E.js} +1 -1
- package/apps/web/dist/assets/ru-RU-CZKEe7P8.js +1 -0
- package/apps/web/dist/assets/search-DfbsqO7M.js +85 -0
- package/apps/web/dist/assets/{send-DlGV2XQS.js → send-DrJl0BKm.js} +1 -1
- package/apps/web/dist/assets/settings-BN8uIiBG.js +1 -0
- package/apps/web/dist/assets/{sliders-horizontal-CEHGGft_.js → sliders-horizontal-DM47MLe7.js} +1 -1
- package/apps/web/dist/assets/switch-CJTVGtE9.js +1 -0
- package/apps/web/dist/assets/{table-2AEfwytk.js → table-BLLjFPWD.js} +1 -1
- package/apps/web/dist/assets/ticket-detail-BTy_3JeU.js +2 -0
- package/apps/web/dist/assets/ticket-editor-Bb_lyxnJ.js +2 -0
- package/apps/web/dist/assets/ticket-list-Botp5qf5.js +6 -0
- package/apps/web/dist/assets/ticket-list-controls-Dmkno4pm.js +1 -0
- package/apps/web/dist/assets/{tr-CjdSDu0z.js → tr-9IkdRLIk.js} +1 -1
- package/apps/web/dist/assets/tr-TR-Y8wBLO8I.js +1 -0
- package/apps/web/dist/assets/{uk-C8od6u_H.js → uk-Btampsdw.js} +1 -1
- package/apps/web/dist/assets/uk-UA-CVpasGUS.js +1 -0
- package/apps/web/dist/assets/{upload-BeTAGU_W.js → upload-HARy4VC_.js} +1 -1
- package/apps/web/dist/assets/{useQueries-BtRgTciJ.js → useQueries-CTeEF--U.js} +1 -1
- package/apps/web/dist/assets/{vi-BwdBtgfS.js → vi-BVZzOn_n.js} +1 -1
- package/apps/web/dist/assets/vi-VN-BMy-iJD-.js +1 -0
- package/apps/web/dist/assets/visual-editor-DKBPjqV-.js +208 -0
- package/apps/web/dist/assets/workspace-settings-DqzBNw60.js +1 -0
- package/apps/web/dist/assets/{zh-CN-DM-UoFmL.js → zh-CN-BoUvF2do.js} +1 -1
- package/apps/web/dist/assets/zh-CN-Cds_v0R8.js +1 -0
- package/apps/web/dist/assets/{zh-TW-q2nrwFjt.js → zh-TW-CEIDGTWe.js} +1 -1
- package/apps/web/dist/assets/zh-TW-DCCYakXz.js +1 -0
- package/apps/web/dist/index.html +9 -9
- package/docs/api.md +35 -5
- package/docs/cli.md +1 -1
- package/docs/mobile.md +67 -4
- package/docs/releasing.md +6 -4
- package/package.json +1 -1
- package/apps/web/dist/assets/admin-settings-Ckis3XJG.js +0 -1
- package/apps/web/dist/assets/az-AZ-bFxpMMZN.js +0 -1
- package/apps/web/dist/assets/de-DE-BeXI30Ll.js +0 -1
- package/apps/web/dist/assets/default-Dnep8Yat.js +0 -56
- package/apps/web/dist/assets/delivery-settings-cMldZ9K7.js +0 -1
- package/apps/web/dist/assets/el-GR-SgRHlWhY.js +0 -1
- package/apps/web/dist/assets/es-ES-BF1J5vap.js +0 -1
- package/apps/web/dist/assets/fr-FR-D_sldwf6.js +0 -1
- package/apps/web/dist/assets/hi-IN-CfOw4TWf.js +0 -1
- package/apps/web/dist/assets/id-ID-CLKtHO7Q.js +0 -1
- package/apps/web/dist/assets/index-Bn-MADyj.css +0 -2
- package/apps/web/dist/assets/index-Ds3IHeIi.js +0 -17
- package/apps/web/dist/assets/invitations-C5WbdDUQ.js +0 -1
- package/apps/web/dist/assets/it-IT-eVZNW5qX.js +0 -1
- package/apps/web/dist/assets/item-BD1Poxfs.js +0 -1
- package/apps/web/dist/assets/ja-JP-D96njn-P.js +0 -1
- package/apps/web/dist/assets/ko-KR-Bash_ieA.js +0 -1
- package/apps/web/dist/assets/label-settings-BaikCfal.js +0 -1
- package/apps/web/dist/assets/member-directory-BjUy1KuK.js +0 -1
- package/apps/web/dist/assets/mk-MK-BWQWGIRx.js +0 -1
- package/apps/web/dist/assets/nl-NL-D1tvo0Gd.js +0 -1
- package/apps/web/dist/assets/pl-PL-DNgGUEcQ.js +0 -1
- package/apps/web/dist/assets/project-lifecycle-BBHcRi6p.js +0 -1
- package/apps/web/dist/assets/project-settings-BaYuxYjg.js +0 -1
- package/apps/web/dist/assets/pt-BR-CZsUZQ6P.js +0 -1
- package/apps/web/dist/assets/public-project-DGf3nkg2.js +0 -1
- package/apps/web/dist/assets/ru-RU-4ew92YE_.js +0 -1
- package/apps/web/dist/assets/search-DwsPEC4E.js +0 -85
- package/apps/web/dist/assets/settings-C_sN8Y1h.js +0 -1
- package/apps/web/dist/assets/switch-B_rsZYBf.js +0 -1
- package/apps/web/dist/assets/ticket-detail-C2l8nwF-.js +0 -2
- package/apps/web/dist/assets/ticket-editor-B0UB98HS.js +0 -2
- package/apps/web/dist/assets/ticket-list-D_MHudj1.js +0 -6
- package/apps/web/dist/assets/ticket-list-controls-jKg4i-NA.js +0 -1
- package/apps/web/dist/assets/tr-TR-CM381XCw.js +0 -1
- package/apps/web/dist/assets/uk-UA-AeOiWCVo.js +0 -1
- package/apps/web/dist/assets/vi-VN-td08xXv9.js +0 -1
- package/apps/web/dist/assets/visual-editor-CzB_qwvB.js +0 -208
- package/apps/web/dist/assets/workspace-settings-DOr9j8X1.js +0 -1
- package/apps/web/dist/assets/zh-CN-D3dhEYLS.js +0 -1
- package/apps/web/dist/assets/zh-TW-CfhOEFo8.js +0 -1
package/docs/api.md
CHANGED
|
@@ -12,9 +12,9 @@ Browser and native clients use Better Auth sessions with local login or configur
|
|
|
12
12
|
|
|
13
13
|
Unattended consumers use revocable API credentials in `Authorization: Bearer <key>`. Create credentials through an authenticated account at `POST /api/v1/credentials`, with a name, `projectIds`, `read`, `write`, `claim`, `admin`, or `intake` scopes, and an optional `expiresIn` in seconds (one hour to one year). An omitted `expiresIn` expires the key after 90 days; `null` creates a key that never expires. Pass a nonempty `projectIds` array for a project-scoped key, or explicit `projectIds: null` for a global key. Omitted or empty project lists are invalid. Global keys follow the owning account's current project and workspace access, including future grants; existing project-scoped keys retain their allowlists. Both key types obey operation scopes and current account roles, and neither can perform actions that require a signed-in session, including credential management and instance administration. A scope never grants more access than the owning account has. The secret is returned only on creation. Delete a credential at `/api/v1/credentials/{id}` to revoke it.
|
|
14
14
|
|
|
15
|
-
The `intake` scope is create-only and cannot be combined with another scope (`400 validation_error`). It allows
|
|
15
|
+
The `intake` scope is create-only and cannot be combined with another scope (`400 validation_error`). It allows `create_ticket` over HTTP or MCP in projects the credential and its owning account can write, and `upload_attachment` and `list_attachments` (`POST` and `GET /api/v1/projects/{projectId}/tickets/{ticketId}/attachments`) on a ticket that the same credential created and that still carries the intake hold. Any other ticket, including one a trusted writer has triaged, answers `404 not_found`. Through the intake credential a ticket holds at most 10 attachments, and the credential's tickets that still carry the hold hold at most 1,000,000,000 attachment bytes together; an upload past either limit answers `409 attachment_limit`, while trusted writers can still attach more. Attachments older than 30 days on intake-created tickets that still carry the intake hold expire during storage cleanup, with an `attachment.deleted` event per file. Cleanup removes only attachments, never the tickets. Clearing the hold stops expiry and releases the ticket's share of the byte quota; expiry also releases the deleted files' share. The 10 MB per-file limit is unchanged. It grants no other read access, including ticket lookup, search, snapshots, events, or attachment downloads, and cannot update, delete, move, or comment on tickets or stage uploads. Other authenticated operations return `403 insufficient_scope`.
|
|
16
16
|
|
|
17
|
-
Intake creation returns only the new ticket's normal payload. The server forces `automationReady: false`, `holdReason: "Untrusted intake: human triage required"`, the first To do state, no assignee or parent, `archived: false`, no dates, empty custom fields (including defaults and required fields), and no uploads
|
|
17
|
+
Intake creation returns only the new ticket's normal payload. The server forces `automationReady: false`, `holdReason: "Untrusted intake: human triage required"`, the first To do state, no assignee or parent, `archived: false`, no dates, empty custom fields (including defaults and required fields), and no uploads; attach files afterwards through the returned `id`. It preserves the supplied title, description, requirements, kind, difficulty, priority, labels, and scopes, and adds the `intake` label. The 30-label limit includes that label. Reusing the same `requestId` returns the original ticket without creating another. A human session or a credential with ordinary write access can triage it later and enable automation. Eligibility inspection includes its explicit not-ready reasons until triage clears the hold and enables automation.
|
|
18
18
|
|
|
19
19
|
Repository issue sync uses the server-forced intake hold as a quarantine marker: neither export planning nor event-driven export includes the ticket until a trusted writer clears that hold. Already queued issue-sync deliveries recheck the hold before contacting the repository. Ordinary tickets without automation readiness still follow their configured sync rules.
|
|
20
20
|
|
|
@@ -26,7 +26,9 @@ Reuse the same UUID `requestId` when retrying a write. Reusing it with a differe
|
|
|
26
26
|
|
|
27
27
|
Signed-in sessions may make `KANVIL_SESSION_WRITE_LIMIT` write requests per minute (120 by default) to `/api/v1`, and MCP OAuth clients get the same budget for write tools; exceeding it returns `429 rate_limited` with a `Retry-After` header. This counter is per application process. API keys have a separate limit of 600 requests per minute per key across HTTP and MCP. An instance operator can set `KANVIL_API_KEY_RATE_LIMIT_ENABLED=false` and restart the server to disable that limit for existing and new keys. The default is `true`; restoring it enforces the limit again. Session, MCP OAuth, and sign-in limits are unchanged, as are key expiration, revocation, scopes, and account permissions. Authentication rereads each key and skips timestamp-only bookkeeping writes. When a key is unlimited, authentication leaves its stored `last_request` and `updated_at` unchanged; enabled rate limits and usage quotas still consume their allowance synchronously and atomically. A request that consumed a key's last use is admitted even when a concurrent request finds the key exhausted and deletes it first.
|
|
28
28
|
|
|
29
|
-
Claims require an eligible ticket
|
|
29
|
+
Claims require an eligible ticket. `expectedVersion` is optional on `claim_ticket`: when sent, a stale version returns `409 version_conflict`; when omitted, readiness alone guards the claim, so a ticket that is held, blocked, already claimed, or outside To do still returns `409 not_ready` with its readiness reasons. Successful claiming moves it to In progress and returns the claim's fields at the top level plus `ticket`, the claimed ticket as `get_ticket` reads it next, including its body and new `version`. Renew an unexpired claim using its ID and the same credential. Once a ticket has been claimed, machine ticket updates and evidence submissions require a current matching claim ID, including after release. Recovery without `reschedule` lifts that requirement and increments the ticket version, publishing `ticket.updated` with `changedFields: ["claimRequired"]`, so a ticket left outside To do (for example, In review after its work merged) accepts ordinary worker writes again; a write that still names the recovered claim ID returns `409 claim_conflict`. Submit completion and evidence before releasing ownership. Human sessions can edit and reschedule work without a machine claim. Release unexpired ownership after the worker stops or completes. The ticket response exposes `canReleaseClaim` for the exact credential or client identity; sharing an account does not confer ownership. An expired claim remains blocking until a caller with `claim` scope and `ticket.write` permission confirms the old worker stopped and explicitly calls `recover_claim` with its ID, a reason, and `confirmedStopped: true`. Administrator access is not required for expired claims. If a worker has stopped while its claim is still live, for example after revoking its credential, a caller with `project.manage` and `admin` scope can explicitly pass `cancelActive: true` to cancel it before expiry. This still requires `confirmedStopped: true` and a reason, and the recovery event records the cancellation. Without that flag, live claims remain protected. Recovery does not automatically reschedule work or grant a replacement claim. To resume without a human session, explicitly pass `reschedule: true` and the ticket's current `expectedVersion` to `recover_claim`; this returns it to To do and increments its version atomically with recovery. Then call `get_ticket` and `claim_ticket` with the new version for a fresh claim ID. A missing version returns `400 validation_error`, and a stale one returns `409 version_conflict`, leaving the claim and status unchanged. A ticket that still requires ownership but has no claim left, for example one released outside To do or recovered by an older server that kept the requirement, is recovered the same way without a `claimId`: pass a reason, `confirmedStopped: true`, and the ticket's current `expectedVersion`, which this form requires. Without `reschedule` it lifts the requirement so ordinary worker writes pass again; with `reschedule: true` it returns the ticket to To do for a fresh claim. Both increment the version, and the `claim.recovered` event records `claimId: null`. If the ticket has any claim, live or expired, this form returns `409 claim_conflict` with the claim in `details.claimId`; if it does not require ownership, it returns `409 claim_not_required`. Neither changes the ticket. Initial claims and renewals accept `leaseSeconds` of at least 30, bounded by `KANVIL_CLAIM_MAX_LEASE_SECONDS`, which defaults to 86400 (24 hours). The default lease remains 300 seconds, or the configured maximum if smaller. A request above the configured maximum returns `400 validation_error`; renew before expiry to keep ownership.
|
|
30
|
+
|
|
31
|
+
A worker handed a ticket key claims it in one call, with no read first: `claim_ticket({ projectId: "<key prefix>", ticketId: "<key>", workerId, leaseSeconds })`. Call `resolve_ticket` only after a `404 not_found`, which means the ticket moved since the key was issued.
|
|
30
32
|
|
|
31
33
|
Evidence records identify the submitting account and supplied commit. A reported passing outcome is an assertion by that identity, not independent verification by Kanvil.
|
|
32
34
|
|
|
@@ -36,17 +38,21 @@ Attachments use `upload_attachment` or the corresponding HTTP POST with `name`,
|
|
|
36
38
|
|
|
37
39
|
Files embedded in a ticket that does not exist yet are staged first. `stage_upload` (`POST /api/v1/projects/{projectId}/uploads`) takes the same `name`, `contentType`, and base64 `data` and returns `{id, projectId, actorId, name, contentType, size, createdAt, expiresAt}` without publishing an event. Until it is claimed, only its uploader can read it, at the same `/attachments/{id}/content` address it keeps afterwards, so Markdown that embeds it never needs rewriting. `create_ticket` accepts `uploads`, a list of up to 50 staged IDs; in the same transaction they become the ticket's attachments with unchanged IDs and one `attachment.created` event each after `ticket.created`. If any ID is missing, expired, staged by someone else, or staged in another project, nothing is created and the request fails with `not_found`, whose `details.uploads` lists the unusable IDs. Retrying with the same `requestId` returns the created ticket without claiming twice. `discard_upload` (`DELETE /api/v1/projects/{projectId}/uploads/{uploadId}`) deletes one of your unclaimed uploads with its bytes. Unclaimed uploads expire after 24 hours, and the storage cleanup job removes them and their bytes. Both operations require `write`.
|
|
38
40
|
|
|
41
|
+
A file on disk does not need to pass through a tool call as base64. `create_upload_url` (`POST /api/v1/projects/{projectId}/upload-urls`, `write`) takes `name`, `contentType`, and the file's exact `size` in bytes (at most 10 MB, with the same name and content-type rules), plus an optional `ticketId`, and returns `{uploadId, url, expiresAt}`. Send the bytes with one `PUT` to `url` within 15 minutes, for example `curl --upload-file recording.mp4 "$url"`; the request needs no other credential, must carry a `Content-Length` equal to `size` (curl sends it), and its `Content-Type` is ignored in favor of the declared one. Without `ticketId` the `PUT` stages the file as `stage_upload` would, and `create_ticket` claims it by listing `uploadId` in `uploads`; with `ticketId` it becomes that ticket's attachment with ID `uploadId` and an `attachment.created` event. Either answers `200` with the staged upload or the attachment. The write runs as the account and credential that requested the URL and needs their write access at the time of the `PUT`; once that API credential is revoked or expires, the URL answers `401 unauthorized`. A missing or different `Content-Length`, or a body of another length, fails with `400 invalid_attachment` and leaves the URL usable. While one `PUT` is sending its body, once a `PUT` stores the file, or after 15 minutes, the URL answers `410 upload_url_unavailable`; a `PUT` that fails releases it. An unknown URL answers `404 not_found`. The URL accepts only that one upload: it serves no reads and reaches no other file. Creating one publishes no event and takes no `requestId`, so no receipt keeps the URL; request a new one instead of retrying. Intake credentials cannot create upload URLs.
|
|
42
|
+
|
|
39
43
|
Tickets carry optional `startAt` and `dueAt` ISO timestamps. Both name whole days: clients store a start as the first instant of the chosen day and a due date as its last instant, each in the author's time zone, so a ticket that starts and is due on the same day is valid. A start after the due date fails with `400 validation_error` (path `startAt`) on create, update, bulk update, and duplication, checked against the dates the write leaves on the ticket. Clients reading a server that predates start dates get `startAt: null`. `list_tickets` takes `scheduledFrom` and `scheduledTo` (ISO timestamps) to return tickets whose span, from the start (or due) date to the due (or start) date, overlaps `[scheduledFrom, scheduledTo)`; either bound alone also leaves out undated tickets. `sort: "start"` orders by that span's first day. `list_tickets` accepts `due` as `overdue` (past due), `today` (due within the next 24 hours), `week` (due within the next 7 days), `scheduled`, or `none`; the first three apply to open tickets only and use the database clock and `sort: "due"` for soonest first with undated tickets last. Other sorts: `priority` (default), `updated`, `created`, `title`, `labels`, `number`, `status`, `difficulty`, `kind`, and `assignee`. Each has a natural direction (newest first for `updated` and `created`, ascending otherwise); pass `order: "asc"` or `"desc"` to override it. Ties break by ticket number, and null values sort last in both directions. `manual` follows the project's shared manual order (see below). The `assigneeId` filter takes a user id or `none` for unassigned tickets.
|
|
40
44
|
|
|
41
45
|
The manual order is one server-kept order per project, shared by every list, board, and planning view; a status section shows it filtered. `move_ticket` (`POST /api/v1/projects/{projectId}/tickets/{ticketId}/move`) takes the ticket's `expectedVersion` and places it directly `before` or `after` one other ticket of the project (an ID or key; name one, not both), optionally setting `status`, `stateId`, or both in the same write, resolved like a ticket update's (see Workflow states); a status or state alone changes it and keeps the place. Clients never send positions: only the moved ticket changes place, so a move made in a filtered view leaves hidden tickets in their relative order, and an anchor that has moved since the caller read it still places the ticket beside it. A deleted or foreign anchor is a `404 not_found` and nothing moves. The write needs `write`, follows the machine claim rules of `update_ticket` (pass `claimId`), accepts `requestId`, bumps the ticket's version, and publishes `ticket.updated` with `changedFields` naming `position` and, when the state changed, `status` and `stateId`. A reorder alone notifies no follower. New tickets join the end of the order; existing tickets started in creation order. `move_project` (`POST /api/v1/projects/{projectId}/move`) places a project `before` or `after` another project of the same workspace that the caller can read, with the project's `expectedVersion`; it needs project administration and publishes `project.updated`. `list_projects` returns projects in that order, which started alphabetical.
|
|
42
46
|
|
|
43
|
-
The property filters `status`, `priority`, `kind`, `difficulty`, `label`, `assigneeId`, and `customField` (see [Custom fields](#custom-fields)) each take one value or a list and match tickets with any of them. Each has a `Not` twin (`statusNot`, `labelNot`, `assigneeIdNot`, and so on) that excludes the listed values; excluding a value keeps tickets where the field is unset. Over HTTP a list is a repeated query parameter, such as `?status=todo&status=in_progress&labelNot=blocked`; JSON and MCP callers pass an array. Filters combine with AND. A list holds at most 25 values, and a value in both a filter and its `Not` twin is a `400 validation_error`. Saved views use the same schema, so views saved with single values keep their meaning. New filterable fields join this catalog in the contracts package and get both operators. `list_projects` returns `openTickets` and `overdueTickets` per project (unarchived tickets not done or cancelled; overdue means a due date in the past), `closedTickets` (unarchived tickets done or cancelled), `nextDueAt` (the soonest upcoming due date of an open ticket, or `null`), and the caller's effective `role` there (`reader`, `writer`, or `admin`; always `admin` for instance administrators), with `roleId` and `capabilities` as described in [Roles and permissions](#roles-and-permissions).
|
|
47
|
+
The property filters `status`, `priority`, `kind`, `difficulty`, `automation`, `label`, `assigneeId`, and `customField` (see [Custom fields](#custom-fields)) each take one value or a list and match tickets with any of them. `automation` is `ready` (`automationReady` is true and `holdReason` is null) or `manual` (everything else, the tickets agents won't take). Each has a `Not` twin (`statusNot`, `labelNot`, `assigneeIdNot`, and so on) that excludes the listed values; excluding a value keeps tickets where the field is unset. Over HTTP a list is a repeated query parameter, such as `?status=todo&status=in_progress&labelNot=blocked`; JSON and MCP callers pass an array. Filters combine with AND. A list holds at most 25 values, and a value in both a filter and its `Not` twin is a `400 validation_error`. Saved views use the same schema, so views saved with single values keep their meaning. New filterable fields join this catalog in the contracts package and get both operators. `list_projects` returns `openTickets` and `overdueTickets` per project (unarchived tickets not done or cancelled; overdue means a due date in the past), `closedTickets` (unarchived tickets done or cancelled), `nextDueAt` (the soonest upcoming due date of an open ticket, or `null`), and the caller's effective `role` there (`reader`, `writer`, or `admin`; always `admin` for instance administrators), with `roleId` and `capabilities` as described in [Roles and permissions](#roles-and-permissions).
|
|
44
48
|
|
|
45
49
|
`list_my_work` (`GET /api/v1/my-work`) pages through the open, unarchived tickets assigned to the caller across every project they can read, optionally narrowed to one `workspaceId`. It accepts `q`, `sort`, and `order` like `list_tickets`, plus `limit` and `offset`; `total` counts every match so clients can fetch the remaining pages. Set `activity` (up to 50) to also receive that many of the most recent events from the same accessible projects, newest first. Revoked memberships and project-bound credentials narrow the tickets, totals, and activity alike.
|
|
46
50
|
|
|
47
51
|
`search_tickets` (`GET /api/v1/tickets/search?q=...`) finds unarchived tickets by exact key or text across every project the caller can read, optionally limited to one `workspaceId`, returning each ticket with its project `id`, `key`, and `name`. Exact key matches sort first, then the most relevant matches (the external index's score, or on PostgreSQL title matches before matches only in other text), then the most recently updated.
|
|
48
52
|
|
|
49
|
-
Ticket text queries in `list_tickets`, `list_my_work`, `search_tickets`, and `search_workspace` treat each double-quoted segment of `q` as an exact phrase: `q="live activity" acknowledge` needs the phrase `live activity` and the term `acknowledge`.
|
|
53
|
+
Ticket text queries in `list_tickets`, `list_my_work`, `search_tickets`, and `search_workspace` treat each double-quoted segment of `q` as an exact phrase: `q="live activity" acknowledge` needs the phrase `live activity` and the term `acknowledge`. A phrase matches case-insensitively with its punctuation as written, its words separated only by whitespace, and no letter, digit, or underscore against either outer edge, so `q="IME"` does not match `time` and `q="exp-v0.52.6"` does not match `exp-v0.52.61`. Outside quotes, `search_tickets` and `search_workspace` match terms fuzzily on the external index, and on PostgreSQL the terms must appear together as written. An unpaired quote is an ordinary character. Repeated phrases count once, and only the first 8 distinct phrases count.
|
|
54
|
+
|
|
55
|
+
`list_tickets` and `list_my_work` filter rather than search, so their `q` keeps only tickets that contain its text on every engine: each phrase on its word boundaries and the free text as written in the key, title, description, acceptance criteria, labels, or scopes, as PostgreSQL matches for `search_tickets`, or the exact key or ID. `q="oxlint"` never returns a ticket that only says `lint`, and `total` counts only those tickets. With `q` and no `sort`, they order like `search_tickets`: exact key match, then relevance (the external index ranks when it is current), then most recently updated. An explicit `sort` applies as usual; without `q`, the default `sort` is `priority`.
|
|
50
56
|
|
|
51
57
|
`list_tickets`, `search_tickets`, and `search_workspace` take `view`: `full` (the default) or `summary`. A summary row has only `id`, `key`, `number`, `projectId`, `title`, `status`, `stateId`, `kind`, `difficulty`, `priority`, `labels`, `scopes`, `archived`, `automationReady`, `updatedAt`, and `version`, so agents can scan many results without every description and acceptance criteria. In summary view each `search_tickets` result and `search_workspace` result also carries `snippet`: up to 240 characters of its body text (a ticket's description and acceptance criteria) around the first literal match of a phrase or term, or empty when only the title, key, or a fuzzy term matched; a `search_workspace` result's `ticket` is its summary row.
|
|
52
58
|
|
|
@@ -78,6 +84,10 @@ A key such as `ABC-12` is the project prefix and the ticket's number in that pro
|
|
|
78
84
|
|
|
79
85
|
Prefixes were permanent before this release; they are now editable and permanently reserved instead. A project administrator changes one with `update_project` and a new `key` (same format as at creation) at the project's `expectedVersion`. Every ticket of the project takes the new prefix with its number unchanged, new tickets keep counting, and the old prefix and every key issued under it keep resolving, so existing links and stored keys keep working. Ticket versions do not change. A prefix that any other project has or had fails with `409 duplicate_prefix`; a project can return to one of its own. The write publishes `project.updated` with `key` and `previousKey`, on which a configured external search index reindexes every ticket of the project. Clients that cache keys should refresh project tickets on that event.
|
|
80
86
|
|
|
87
|
+
A project can link the git remotes of its repositories, so an agent can name the project with the output of `git remote get-url origin` instead of looking it up. `update_project` takes optional `repositories`, at most 20 remotes, and replaces the stored list; omitting it leaves the list unchanged. Each entry may be `https://[user[:password]@]host[:port]/path[.git]`, `ssh://[user@]host[:port]/path[.git]`, scp-style `user@host:path[.git]`, or the stored form, and is stored as `host[:port]/path`: lowercased, with credentials, the scheme's default port, a trailing `.git`, and a trailing slash removed, and repeats dropped. Anything else fails with `400 validation_error`. `repositories` on a project returns the stored form. The write needs project administration and publishes `project.updated`. Within a workspace a remote belongs to at most one unarchived project: linking one another unarchived project holds, restoring an archived project, or moving a project into a workspace where one of its remotes is taken fails with `409 duplicate_repository`, and `get_project_impact` for that move fails the same way. An archived project keeps its remotes, and another project can link them meanwhile.
|
|
88
|
+
|
|
89
|
+
Any `projectId` of an authenticated request, including the snapshot's and the event feed's, also accepts a linked remote in any of those spellings, up to 300 characters; percent-encode it in a URL path or query. Access, project-bound credentials, and permissions are checked as for the ID. A remote that links nothing the caller can reach reads as `404 not_found`, like an unknown one. When it links several projects the caller can reach, unarchived ones win over archived ones, and if more than one remains, as when two workspaces each link it, the request fails with `409 ambiguous_project`, whose `details.projects` lists their keys. Use the key or ID in that case. A write retried with the same `requestId` replays against the project it wrote to, even if the remote no longer names that project.
|
|
90
|
+
|
|
81
91
|
`check_ticket_relocation` (`GET /api/v1/projects/{projectId}/tickets/{ticketId}/relocation?destinationProjectId=...`) previews a move to another project of the same workspace. It returns the `destination` (`id`, `key`, `name`), its workflow `states`, the default `stateId` (the destination's first state of the ticket's current status), and `blockers`. Each blocker has a `code` (`claimed`, `claim_conflict`, `parent`, `children`, `relations`, `assignee`, `custom_fields`, or `required_fields`), a `message`, and for hierarchy and relationship blockers the related `ticketId`. `claim_conflict` reaches only machine callers: a ticket that has been claimed keeps requiring ownership for worker writes after its claim is released or recovered with `reschedule`, so a worker cannot move it without a claim. A person can still move it, or return it to To do so a worker can claim it again. Kanvil never severs a claim, a parent link, children, or relationships to make a move succeed, never drops an assignee who cannot access the destination, and never discards custom field values, which belong to the source project's fields; resolve them first. A destination field that is required and has no default also blocks the move, since the moved ticket takes the destination's custom field defaults.
|
|
82
92
|
|
|
83
93
|
`relocate_ticket` (`POST /api/v1/projects/{projectId}/tickets/{ticketId}/relocate`) moves the ticket with its `expectedVersion`, `destinationProjectId` (ID or key), and optionally `status`, `stateId`, or both, resolved in the destination like a ticket update's. Both projects require `write`, so a project-bound credential can move tickets only between projects it is bound to; a destination the caller cannot reach reads as `404 not_found`, an archived destination is a `409 project_archived`, and a different workspace or the same project is a `400 validation_error`. An archived source refuses the move like any other write. Any blocker fails the move with `409 relocation_blocked` and `details.blockers`. Machine callers follow the claim rules of `update_ticket`, reported as the `claimed` or `claim_conflict` blocker so the preview predicts the result. The ticket keeps its ID, content, comments, attachments, evidence, links, and followers, takes the destination's next number as its new key, bumps its version, and its labels register in the destination. Every key it had in the source, under each prefix the source has used, keeps resolving to it in the destination and through `resolve_ticket`, never in the source. Retrying with the same `requestId` returns the moved ticket only while the caller can still write to both projects. Files embedded before the move keep loading from their original `/attachments/{id}/content` address, authorized in the ticket's current project.
|
|
@@ -287,6 +297,26 @@ While the connection is paused nothing syncs; issue changes reported meanwhile a
|
|
|
287
297
|
|
|
288
298
|
The authenticated Streamable HTTP endpoint is `/mcp`. Tool inputs use the shared HTTP contracts, including expected versions, request IDs, and claim IDs. Successful structured results have a `result` property. A rejected call returns an error result (`isError: true`) whose text content is the same JSON envelope an HTTP response carries, `{"error": {"status", "code", "message", "details"}}`, so claim readiness reasons, lease maximums, version conflicts, and validation paths read the same on both transports. Unexpected failures return only `internal_error`.
|
|
289
299
|
|
|
300
|
+
`tools/list` returns one list for every client. The agent core marked `mcp: "core"` in the operation registry serves as its own tools: `list_my_work`, `list_eligible_tickets`, `search_tickets`, `get_ticket`, `create_ticket`, `update_ticket`, `claim_ticket`, `renew_claim`, `release_claim`, `add_comment`, `add_evidence`, and `resolve_ticket`. Tools advertise inputs only, with no `outputSchema`; results keep the `result` property. Three discovery tools reach every operation in the registry:
|
|
301
|
+
|
|
302
|
+
| Tool | Input | Behavior |
|
|
303
|
+
| ---------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
304
|
+
| `find_operation` | `query` or `name`, both optional | `query` returns `{operations: [{name, description, access}]}`, with `access` either `read` or `write`, for operations whose name and description contain every word, ignoring case. No `query` returns all. `name` returns that one operation with `method`, `scope`, and `inputSchema` (JSON Schema). |
|
|
305
|
+
| `read_operation` | `name`, `input` (defaults to `{}`) | Runs a `GET` operation. Hinted `readOnlyHint` and `idempotentHint`, so a client can approve reads automatically. A write operation returns `400 invalid_request` without running. |
|
|
306
|
+
| `run_operation` | `name`, `input` (defaults to `{}`) | Runs any other operation and counts against the OAuth write budget. Hinted `destructiveHint`. A read operation returns `400 invalid_request`. |
|
|
307
|
+
|
|
308
|
+
The runners call the same implementation as a direct tool, so results, error envelopes, scopes, and permissions match. An unknown name returns `404 not_found` from all three tools.
|
|
309
|
+
|
|
310
|
+
Results from operations, through their own tools and the runners, are smaller than the same HTTP responses:
|
|
311
|
+
|
|
312
|
+
- **Summary rows by default.** An operation with a `view` input (`list_tickets`, `search_tickets`, `search_workspace`) defaults to `view: "summary"`. Pass `view: "full"` for whole tickets. HTTP keeps its default of `full`.
|
|
313
|
+
- **No echoed bodies.** When an operation takes `description` or `acceptanceCriteria` as input, at the top level or inside `changes` (`create_ticket`, `update_ticket`, `bulk_update_tickets`), tickets in its result leave that field out, even when this call did not change it. Read it with `get_ticket`. Other operations return tickets with their bodies, and a project keeps its `description`.
|
|
314
|
+
- **No empty keys.** Object keys whose value is `null`, `[]`, or `{}` are removed at any depth, so a missing key means null or empty. `false`, `0`, and `""` stay, and the top-level `result` property is always present. Results therefore do not validate against the HTTP output schemas. An `export_project` bundle read this way does not import; export over HTTP to get a file for `preview_import`.
|
|
315
|
+
|
|
316
|
+
`find_operation` is not an operation result: its input schemas keep their empty values, such as `default: null`.
|
|
317
|
+
|
|
318
|
+
The `initialize` response carries `instructions`, at most 600 characters of English for agents: what ticket keys look like, how to work a ticket through claim, status, comment, evidence, `done`, and release, that tickets with `automationReady: false` stay with people, how to use the discovery tools, and how replies are shortened.
|
|
319
|
+
|
|
290
320
|
Clients supporting custom headers can connect using a scoped API credential. Interactive clients can discover OAuth authorization through the endpoint's protected-resource metadata, then sign in and consent through Kanvil. Resource scopes are `kanvil:read`, `kanvil:write`, `kanvil:claim`, and `kanvil:admin`. The web app supplies the login and consent pages. Configure OIDC callback URLs as `/api/auth/callback/oidc` and explicitly link an existing local account from Settings before using OIDC sign-in. Protocol compatibility is tested using the official SDK, including discovery, tool listing, and writes.
|
|
291
321
|
|
|
292
322
|
## Instance administration and profile photos
|
package/docs/cli.md
CHANGED
|
@@ -63,7 +63,7 @@ JSON
|
|
|
63
63
|
kanvil task delete APP-12 --profile work --expected-version 7 --yes
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
-
Use the version returned by a read or write. The CLI never fetches a newer version to bypass a conflict. Claims retain the server's readiness, ownership and lease rules; include `claimId` in an update when the operation requires it. Once a lease expires, renew and release refuse it; after confirming the old worker stopped, any writer with `claim` scope can run `claim recover` with the claim ID, a reason and `confirmedStopped: true`. Recovery never reschedules or reclaims on its own: pass `reschedule: true` with the ticket's current version to return it to To do, then read the ticket and `claim acquire` at the new version. When a ticket still requires ownership but no claim is left, for example after a release outside To do, omit the claim ID and pass `--expected-version` with the ticket's current version to lift the requirement, or to reschedule it with `reschedule: true`. `cancelActive: true` cancels a live claim only for project managers whose credential has `admin` scope. See [writes and ownership](api.md#writes-and-ownership) for the full policy. Deletion requires an explicit version and `--yes`; noninteractive invocations receive no confirmation prompt.
|
|
66
|
+
Use the version returned by a read or write. `claim acquire` may omit the version, since readiness still guards the claim, and prints the claim with the claimed `ticket`, whose `version` the next write needs. The CLI never fetches a newer version to bypass a conflict. Claims retain the server's readiness, ownership and lease rules; include `claimId` in an update when the operation requires it. Once a lease expires, renew and release refuse it; after confirming the old worker stopped, any writer with `claim` scope can run `claim recover` with the claim ID, a reason and `confirmedStopped: true`. Recovery never reschedules or reclaims on its own: pass `reschedule: true` with the ticket's current version to return it to To do, then read the ticket and `claim acquire` at the new version. When a ticket still requires ownership but no claim is left, for example after a release outside To do, omit the claim ID and pass `--expected-version` with the ticket's current version to lift the requirement, or to reschedule it with `reschedule: true`. `cancelActive: true` cancels a live claim only for project managers whose credential has `admin` scope. See [writes and ownership](api.md#writes-and-ownership) for the full policy. Deletion requires an explicit version and `--yes`; noninteractive invocations receive no confirmation prompt.
|
|
67
67
|
|
|
68
68
|
The typed client retries a transport failure once for reads and receipted writes, using the identical request body, request ID and expected version. HTTP errors, including conflicts, are never retried. For a retry across CLI invocations, supply the original `--request-id UUID` (or `requestId` in JSON) and unchanged input. If both transport attempts fail, the outcome may be unknown: reconcile by reading or replaying that same request ID; do not submit a new write identity blindly. Requests have a finite transport timeout.
|
|
69
69
|
|
package/docs/mobile.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Native app
|
|
2
2
|
|
|
3
|
-
Kanvil uses Expo SDK
|
|
3
|
+
Kanvil uses Expo SDK 58 with its compatible React Native and React versions. Web and native use the same contracts, ticket defaults, draft format, API client, settings catalogs, themes, Markdown editing commands, schedule calculations, and realtime recovery. Controls and navigation use their platform's renderer; the web DOM controls cannot provide native inputs, navigation, or document pickers.
|
|
4
4
|
|
|
5
5
|
## Development
|
|
6
6
|
|
|
@@ -15,11 +15,25 @@ pnpm --filter @kanvil/mobile android
|
|
|
15
15
|
|
|
16
16
|
Enter your server's origin in the app. An iOS simulator can reach the host through `http://localhost:5180`; a phone needs your reachable HTTPS address. Release builds require HTTPS. Local development allows HTTP. The server must expose its auth and API endpoints at the origin's root.
|
|
17
17
|
|
|
18
|
-
Native projects are generated from `app.config.ts` and its Expo plugins. Keep `ios/` and `android/` untracked. Expo's
|
|
18
|
+
Native projects are generated from `app.config.ts` and its Expo plugins. Keep `ios/` and `android/` untracked. SDK 58 enables Expo's scene lifecycle by default. Use Xcode 27.1 or newer for iPhone Duo support; see [Expo's Duo guidance](https://expo.dev/blog/supporting-iphone-duo).
|
|
19
|
+
|
|
20
|
+
## Navigation and capabilities
|
|
21
|
+
|
|
22
|
+
Projects, My work, Inbox, Search, and Settings use the native tab controller, with a browsing stack under each tab so navigation remains reachable inside projects and tickets. A searchable workspace/project picker is available in every browsing header. Project switching replaces the current project destination and restores its filters, search, layout, calendar anchor, selected ticket, and list position for the current account. Workspace switching leaves old project routes. Workspace selection and page actions use native navigation-bar items and menus on iOS, allowing UIKit to place them in the Duo rail. Android renders the same action descriptions through shared header controls and sheets. Pickers and form sheets use a full-screen native stack presentation, with Cancel/Close and the primary action in native bars. The presenting screen completes its return transition before the temporary navigator is removed, preserving touches through repeated and nested sheets. Sheet actions that open a ticket editor wait for that native dismissal before presenting its modal. The signed-out surface paints the entire window, including the rail's safe area.
|
|
23
|
+
|
|
24
|
+
Ticket lists provide workflow sections, filters, saved views, planning, bulk edits, and List/Board/Calendar/Timeline layouts. List and Board previews and calendar details use one adaptive pane layout. UIKit's active reserved-region measurements place panes on either side of the fold on iOS 27.1+; Android fold geometry comes from the same provider. Other devices split when both minimum pane sizes fit. Both React Native panes stay mounted, preserving scroll positions and form state when folding. Compact layouts show the active pane. Forms, pickers, and ordinary lists stay on one usable facet while the fold is active. Safe areas are measured inside the native screen and consumed once at the shared content boundary; modal windows start fresh inset ownership. The alpha Screens split controller is unsuitable here because it cannot be nested in another navigator and cannot customize its column headers; see [Expo's limitations](https://docs.expo.dev/versions/latest/sdk/router/split-view/#known-limitations).
|
|
25
|
+
|
|
26
|
+
Secondary scope and active-query summaries belong to scrolling content. Views, filters and creation use native bar items. Single-scroller iOS screens and wide ticket lists extend under translucent system bars with native soft scroll edges; Board, Calendar, and image backgrounds retain explicit safe inset handling where native edge attachment is ambiguous. Compact native titles use semantic foreground colour and system typography. Settings and forms use inset grouped sections; document and collection screens stay plain. Horizontal safe areas and fold exclusion remain owned by the shared content boundary.
|
|
27
|
+
|
|
28
|
+
Project directories show identity and counts; project information remains available through the project settings action or a long press. Ticket rows wrap their full titles and use a measured line of metadata, label dots and scope text, with filled tags and every property available in detail. Search comments have a short excerpt and a readable preview; role permissions expand in place.
|
|
29
|
+
|
|
30
|
+
Native settings expose account/profile security, appearance and custom themes, API credentials, notification rules and delivery channels, workspace membership/roles/labels, project workflow/custom fields/access/lifecycle, tracker imports, repositories, and calendar feeds. Instance administrators also have user, workspace, storage, search, event-broker, and authentication settings. The server remains responsible for permissions, validation, version checks, and destructive-operation blockers.
|
|
31
|
+
|
|
32
|
+
The pinned Screens patch sizes the tabs controller to its measured host before window attachment, so UIKit constructs its bars for the active display on an unfolded cold launch. It preserves equal native bar-item configurations and commits changed arrays through UIKit's animated setters. Item identifiers and grouping stay stable while searching; the active primary action has higher visibility than menus and the project picker. UIKit owns push/pop motion; native tabs use a short opacity transition through the public tab-controller animation delegate. Both tab and in-page changes honor Reduce Motion. The classic screen explicitly associates its measured content scroll view using `setContentScrollView:forEdge:` and refreshes edge settings after layout, including split-pane changes. Shared List/Scroll layout callbacks request soft edges after their actual scroll view exists. Screen preserves its native stacking context so Fabric cannot hoist a background leaf ahead of the scroll-content chain.
|
|
19
33
|
|
|
20
34
|
## Sessions and cached data
|
|
21
35
|
|
|
22
|
-
Better Auth's Expo plugin stores session cookies and its session cache in SecureStore, with a separate namespace for each normalized server origin. Ticket caches and drafts use AsyncStorage, scoped to both that origin and account. Mutation state is never persisted for later automatic submission. Failed offline writes remain drafts and must be explicitly saved again.
|
|
36
|
+
Better Auth's Expo plugin stores session cookies and its session cache in SecureStore, with a separate namespace for each normalized server origin. Ticket caches and drafts use AsyncStorage, scoped to both that origin and account. Successful reads remain cached after network or server failures; explicit client-side API rejections such as revoked access are excluded from persistence. Mutation state is never persisted for later automatic submission. Failed offline writes remain drafts and must be explicitly saved again.
|
|
23
37
|
|
|
24
38
|
Local login and explicitly linked OIDC accounts use the same project permissions. PocketID redirects to the server's `/api/auth/callback/oidc`; the Expo integration completes the return to `kanvil://auth/callback`. Ticket links use `kanvil://projects/<projectId>/tickets/<ticketId>`.
|
|
25
39
|
|
|
@@ -33,8 +47,32 @@ Settings has a global push preference and an action to enable or disable this de
|
|
|
33
47
|
|
|
34
48
|
Outbox commits, receipt processing, retry/restart behavior, membership revocation during delivery, account changes on a shared device, and session revocation are tested with an isolated transport. Native Inbox/push preference changes were checked against a second HTTP client on the iOS simulator. Real APNs/FCM delivery and notification-tap routing have not been validated; they need signing/push configuration and an authorized notification source. The unconfigured simulator rejected an injected notification with `UNErrorDomain 2003: Source is not authorized`.
|
|
35
49
|
|
|
50
|
+
Local search in Projects, My work, and project tickets opens on demand from their native options/Views menu, using a stacked search field that collapses with scrolling on iOS. Closing the native search field or dismissing its keyboard retains the query; the removable search row clears it. Platforms that expose a search Cancel callback also clear the query on Cancel. Compact ticket detail hides list search and restores it on Back. The dedicated Search tab keeps its native stacked input available; result-type filters use a native menu. Workspace roots show the workspace name as a picker action; Search keeps its result-type choice on that same strip. The picker changes workspace in place; selecting the current project dismisses it without resetting the active screen.
|
|
51
|
+
|
|
52
|
+
Ticket-list status headers expand and collapse their rows, pin below the native bar while scrolling, and are pushed away by the next section. Counts include every matching ticket. Normal disclosure choices are retained by project and collection; search starts with matching sections expanded and keeps temporary choices until the query changes. Clearing search restores normal disclosure choices. Views uses one native grouped menu for layouts, All tickets/Planning, saved views, section controls, and explicit selection. The native Views action contains the same layout palette, symbols and filter controls. Active queries and filter counts also appear on its badge. Saved views have a searchable management sheet. Active filter clauses combine property, operator, and values; Add filter reveals further properties. Planning always uses a Planned/Archived list. Ticket rows use the same canonical status, priority, kind, and difficulty symbols as web, alongside assignment, dates, colored labels, and scopes. My work groups by due window or project; Calendar shows work density and a day agenda, and Timeline keeps ticket identity beside the chart. Rows wrap full titles, flow labels and scopes together in measured previews, and distinguish the open split-view ticket from bulk selection. Native Markdown inputs put formatting above the keyboard on iOS; Android retains its inline toolbar. Profile-name saves use the native header while other account operations retain their independent transaction boundaries. Cached revalidation leaves the pull-to-refresh indicator idle; a user pull owns its visible lifecycle. The native tab controller disables automatic minimization, and connection status stays inside screen content rather than resizing the navigator. Empty results, loading additional batches, and failed reads have separate states.
|
|
53
|
+
|
|
54
|
+
The composer puts the title, compact property choices, and description first. Finite property choices use anchored native menus; searchable and multiple choices use sheets. Markdown uses a native Write/Preview segment and a continuous formatting strip; acceptance criteria and advanced properties expand in place. Closing retains the visible local draft; Discard explicitly removes it. Detail reuses those property values for quick edits, and comments compose in a sheet with native submission. Settings titles identify the current section, with color previews and compact reorder actions. Account, workspace, project and administrator sections are direct rows on the main Settings page in compact layouts; the rail does not hide them in a section menu. Choosing a project there reveals its section rows in place.
|
|
55
|
+
|
|
56
|
+
Native rail actions have semantic symbols and stable identifiers. Create is high priority in an unselected project list, Edit in visible detail, Done and Fields during selection, and Save in editors. Views has default priority, secondary ticket/settings menus low priority; list controls disappear in compact detail and selection. Search lowers secondary browsing priorities while typing. UIKit owns the actual overflow decision; priority is a preference, not a fixed rail width or guaranteed pin. Application menus avoid the system overflow ellipsis.
|
|
57
|
+
|
|
58
|
+
The native-stack type patch permits explicitly clearing `headerSearchBarOptions`, matching its existing runtime nullish check. This removes list search from compact detail and selection mode without replacing the system bar.
|
|
59
|
+
|
|
60
|
+
Settings navigation uses a flat sidebar with rounded, tinted selections and semantic foreground text. Each section has its own native route identity, scoped to its workspace or project where applicable, so changing sections cannot transfer scroll momentum or form state. Returning to a section restores its own mounted page. Shared rows use inset rounded feedback for browsing, choice sheets, and project pickers; selected rows keep their accessible state and checkmarks. Grouped forms remain separate from navigation, and row text continues to wrap.
|
|
61
|
+
|
|
62
|
+
Wide split layouts keep their translucent native bar at a stable height while either pane scrolls. The Screens/native-stack patch exposes UIKit's iOS 27 minimization policy separately from translucency; compact routes and standalone detail pages retain automatic minimization. The signed-binary gate verifies this native override alongside the tab sizing fix. All flat split layouts share an adjustable divider and saved sidebar width, constrained to keep both panes usable; a measured physical fold fixes the divider instead. Ticket and Settings sidebars share the semantic sidebar background and rounded, tinted active rows. A collapsed or sole collection uses the normal screen surface, matching the exposed canvas beneath the native rail; the sidebar palette applies only beside a visible secondary pane. Both expose the same native sidebar toggle. Board uses the available width before a ticket is selected and keeps active query/filter and read-error summaries visible after loading.
|
|
63
|
+
|
|
64
|
+
Wide search opens inside the native bar without hiding it or stacking an extra row over both panes. Split-browsing scrollers keep the same inset owner across folding and presentations. They use measured bar clearance instead of inheriting automatic inset changes from another scroller or a search/sheet presentation. Non-minimizing native screens report the current public bar frame, so rotation updates the measurement without frozen or device-specific padding. In-place split browsers reserve horizontal gestures for their content and divider; native Back gestures remain available on standalone ticket routes and compact Settings pages. Compact split detail returns through its native list Back action.
|
|
65
|
+
|
|
66
|
+
Native bars stay transparent with their soft scroll edges. A project's name is a labeled system bar button that opens the existing project picker, replacing the duplicate fixed project title. Other translucent native titles use the existing titleView slot with a content-sized regular UIGlassEffect, semantic text attributes and Dynamic Type. Its label is an accessibility heading; its intrinsic size follows the title. No scrolling pixels, viewport layers or full-width header backgrounds are masked. Sticky headings retain their owning pane surface and next-section push-off. The resize target remains the 44-point grip, with the rest of the divider passing touches through.
|
|
67
|
+
|
|
68
|
+
On iOS 27.1+, native vertical-bar compression prefers action items, compressing integrated tabs before actions when space is constrained. UIKit still owns tab expansion; this is not a permanently minimized tab policy. Split detail keeps its ticket actions to sidebar, Edit and List options, alongside the project picker. Expanded native tabs otherwise overlap lower controls despite visibility priorities. List options places checked ordering criteria and direction first, with Views and secondary creation/ticket actions in the same native menu. Browsing without detail retains direct New ticket. Common and currently selected criteria appear first; the remaining contract-defined criteria stay under More. Ordering uses the same filter callback and saved-view reset as the Filters sheet. The project button has a disclosure chevron; directory and Search rows navigate without selection checks, which belong only to the picker.
|
|
69
|
+
|
|
70
|
+
The root-scroll collector stops at nested scroll views and child screens, keeping system edge configuration scoped to the owning screen. No per-scroll-view opacity mask or bounds observer remains to carry into a recycled Timeline chart or code pane.
|
|
71
|
+
|
|
36
72
|
## Checks
|
|
37
73
|
|
|
74
|
+
The native sidebar button hides or restores the ticket list while keeping its scroll position and the open ticket's drafts mounted. The choice is retained per project during the account session. The native workspace/project picker replaces the project-level Back button; Back from compact ticket detail still returns to its list. Settings keeps one persistent category list outside its native detail stack on wide and book layouts. Each column scrolls independently; category selection replaces the visible detail without a slide, including the first selection, and retains the list position. Compact displays use the same detail stack at full width. Standalone reading and editing screens prefer the right-hand facet while a fold is active, falling back only when it is too constrained.
|
|
75
|
+
|
|
38
76
|
```sh
|
|
39
77
|
pnpm --filter @kanvil/mobile typecheck
|
|
40
78
|
pnpm --filter @kanvil/mobile exec expo install --check
|
|
@@ -45,6 +83,8 @@ Export checks both iOS and Android JavaScript bundles. It does not replace nativ
|
|
|
45
83
|
|
|
46
84
|
Initial simulator validation includes native owner/project/ticket creation, Markdown rendering, authenticated realtime, deep links, session restoration, persisted drafts across termination, and a concurrent server update preventing an overwrite. Switching between cached ticket deep links kept each comment draft with its original ticket.
|
|
47
85
|
|
|
86
|
+
The navigation overhaul was compiled with Xcode 27.1 and exercised against the disposable HTTPS fixture on iPhone Duo (iOS 27.1) and a standard iPhone (iOS 27.0). Checks included native tabs and bar menus, workspace switching, project creation, list/detail collapse, Calendar and Timeline with scheduled data, an unsaved edit retained across folding and then saved into both panes, sign-out/in, and consecutive project-settings saves verified against the server's version. Login background samples match the rail in both appearances on the open and closed Duo. Web regression tests and desktop/390px visual checks cover the extracted shared code. These simulator checks do not establish physical-Duo behavior or real push delivery.
|
|
87
|
+
|
|
48
88
|
An ad-hoc-signed iOS Release app was also compiled with Xcode 27.1 and run on iOS 27 with Metro stopped. It rejected HTTP, signed in through a verified HTTPS fixture, showed complete parent/child/relationship titles, explicitly linked PocketID, and signed out/in through the system browser to the same workspace. The provider used its login-code flow after a virtual passkey enrollment. Local password login remained available. Cached reading and cold startup after the backend stopped were checked separately.
|
|
49
89
|
|
|
50
90
|
The Android arm64 Release APK compiled in GitHub Actions and passed signature and embedded-JavaScript verification. Android JavaScript exports also pass. The development host has no Android SDK, and Android device execution has not been performed. Physical-device passkeys, distribution signing, and store publication remain separate validation. See the [verification record](verification.md).
|
|
@@ -62,7 +102,28 @@ pnpm --filter @kanvil/mobile exec expo run:ios --configuration Release
|
|
|
62
102
|
|
|
63
103
|
A Release app requires HTTPS and includes its JavaScript bundle, so Metro is unnecessary. Keep simulator ad-hoc signing enabled; removing signing also removes the entitlements SecureStore needs. For an isolated simulator backend using HTTPS, run `pnpm native:auth-fixture --https --simulator <udid>` and connect to `https://localhost:5188`. Only this test process and selected simulator trust the temporary certificate.
|
|
64
104
|
|
|
65
|
-
|
|
105
|
+
Tagged releases publish iOS to TestFlight after CI passes, using the tag's version and a build number above every uploaded build for that version. See [releases](releasing.md). Local and EAS builds retain the versions in `app.config.ts`; increment `ios.buildNumber` and `android.versionCode` for subsequent builds distributed through those paths. Keep signing material outside the repository. Building a simulator app or preview APK does not publish to an app store or install on a physical device.
|
|
106
|
+
|
|
107
|
+
After changing a native dependency patch, regenerate the native project and reinstall Pods. CocoaPods can retain a path to an older pnpm patch directory even when the dependency version is unchanged. A JavaScript export or incremental archive does not establish which native implementation was linked. Verify the archived executable before exporting, and repeat against the executable extracted from the final IPA:
|
|
108
|
+
|
|
109
|
+
```sh
|
|
110
|
+
node scripts/verify-ios-tabs.ts /path/to/Kanvil.xcarchive/Products/Applications/Kanvil.app/Kanvil
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
This macOS build check uses Xcode's disassembler to require the tab host's compiled sizing call before controller attachment. It rejects a stale binary without that fix. It does not establish physical-device startup behavior.
|
|
114
|
+
|
|
115
|
+
## Local startup diagnostics
|
|
116
|
+
|
|
117
|
+
For a native startup failure that cannot be reproduced on the available simulator, make an explicit diagnostic candidate with `EXPO_PUBLIC_STARTUP_DIAGNOSTICS=1` set during prebuild, JavaScript export, and native compilation:
|
|
118
|
+
|
|
119
|
+
```sh
|
|
120
|
+
EXPO_PUBLIC_STARTUP_DIAGNOSTICS=1 pnpm --filter @kanvil/mobile exec expo prebuild --platform ios
|
|
121
|
+
EXPO_PUBLIC_STARTUP_DIAGNOSTICS=1 pnpm --filter @kanvil/mobile exec expo run:ios --configuration Release
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The config plugin starts the recorder before JavaScript. It records app-owned window/scene geometry, loaded controller and native-bar geometry, item counts, OS/build versions, and lifecycle events. It avoids loading controller views and never reads labels, titles, ticket text, URLs, credentials, or account identifiers. Sampling is bounded after startup/activation, and unchanged samples are omitted. The atomically replaced `kanvil-native-startup.json` file in the app cache is capped at 1 MiB and retained across process launches so cold and foreground-return states can be compared.
|
|
125
|
+
|
|
126
|
+
Only diagnostic candidates expose Settings → Share in the server section. After reproducing the failure, background and return if needed to restore navigation, then share that JSON file. Sharing is manual; the recorder has no network transport. Rebuild without the variable to disable recording and remove the export action. Simulator results remain separate from physical-device confirmation, and the simulator OS must match the affected device before claiming a reproduction.
|
|
66
127
|
|
|
67
128
|
## Native OIDC fixture
|
|
68
129
|
|
|
@@ -75,3 +136,5 @@ pnpm native:auth-fixture --simulator <booted-simulator-udid>
|
|
|
75
136
|
This generates a private temporary certificate, trusts its public certificate in the selected simulator, starts PocketID at `https://localhost:51411`, and runs an isolated Kanvil backend on port 5188. The backend trusts the certificate through its own `NODE_EXTRA_CA_CERTS` setting. Certificate trust is scoped to that simulator and test process. Stop the fixture with Ctrl-C to remove its container, database, and temporary private key. The simulator's temporary trust entry expires with the test certificate.
|
|
76
137
|
|
|
77
138
|
The fixture uses its own fixed test-only API key and accepts no real PocketID URL or credentials. It is exclusive with `pnpm test:oidc`, which uses the same loopback provider port. Native builds and Metro are started separately using the development commands above.
|
|
139
|
+
|
|
140
|
+
Workspace access is explicit in browsing headers, including ticket and Settings panes. The shared scope button names the active workspace and project and opens the combined picker with workspace choices already visible. Workspace scope rows open workspace choices directly. Selecting the current workspace does not reset the current project or detail; selecting another workspace leaves old scoped routes before selecting the new scope.
|
package/docs/releasing.md
CHANGED
|
@@ -2,20 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
PRs and `main` pushes run `.github/workflows/ci.yml`. `main` pushes also publish an [experimental build](#experimental-builds). Independent suites and browser/native/Docker matrices run in parallel; only jobs consuming build artifacts wait for the build. The always-running `Checks` status requires every suite to succeed. Releases start on a `vX.Y.Z` tag and reuse that workflow against the tagged commit. Publishing jobs have registry write permissions; PR checks have read-only repository access.
|
|
4
4
|
|
|
5
|
-
The public npm application package is `@maudecode/kanvil`; the workspace root and internal packages remain private. The release tag supplies the version for
|
|
5
|
+
The public npm application package is `@maudecode/kanvil`; the workspace root and internal packages remain private. The release tag supplies the version for npm, Docker, and iOS: `v1.2.3` publishes `@maudecode/kanvil@1.2.3`, `ghcr.io/maudecode/kanvil:1.2.3`, and Kanvil 1.2.3 on TestFlight. The server build embeds that same version for Administration, public instance info, OpenAPI, and MCP. Installed artifacts need no Git checkout or release environment variables. No `package.json` version bump is needed. PR and local builds use the development version in `package.json`; custom Docker builds pass `--build-arg KANVIL_VERSION=X.Y.Z` to stamp the server and image label together.
|
|
6
6
|
|
|
7
7
|
## Publish a version
|
|
8
8
|
|
|
9
9
|
1. Merge the intended changes to `main` through a reviewed PR with green `Checks`.
|
|
10
10
|
2. Fetch `main`, inspect the intended commit, then create and push an annotated tag: `git tag -a vX.Y.Z COMMIT -m "Kanvil X.Y.Z"` and `git push origin vX.Y.Z`. Repository release-tag rules apply. Creating the tag authorizes public publication.
|
|
11
11
|
3. Check the `Release` run. It rejects malformed or prerelease tags, `v0.0.0`, and commits outside `main` history. The full CI workflow must pass on that commit.
|
|
12
|
-
4. Verify `npm view @maudecode/kanvil@X.Y.Z dist.integrity` and `docker buildx imagetools inspect ghcr.io/maudecode/kanvil:X.Y.Z`. The image includes Linux amd64 and arm64. Confirm anonymous Docker pulls work, and check the GitHub Release for the tag and its automatically generated notes.
|
|
12
|
+
4. Verify `npm view @maudecode/kanvil@X.Y.Z dist.integrity` and `docker buildx imagetools inspect ghcr.io/maudecode/kanvil:X.Y.Z`. The image includes Linux amd64 and arm64. Confirm anonymous Docker pulls work, check the TestFlight job's summary for the exact iOS version/build in Apple's `VALID` state, and check the GitHub Release for the tag and its automatically generated notes.
|
|
13
13
|
|
|
14
14
|
The workflow publishes the exact npm tarball installed by CI to the `latest` dist-tag, while separate amd64/arm64 publishers push the exact image archives tested by CI. Docker builds and restoration tests run on matching native runners; release publishing does not rebuild or emulate either image. A final Docker manifest job joins those immutable digests under `ghcr.io/maudecode/kanvil:X.Y.Z` and `:latest`. Stable and experimental builds share this publisher, `.github/workflows/publish-images.yml`. Stable releases only; prerelease versions are rejected. Do not tag an older version to move `latest` backward.
|
|
15
15
|
|
|
16
|
-
If
|
|
16
|
+
If a publisher fails, rerun the failed release jobs. npm, Docker, and iOS publication are independent after validation. The npm step accepts an existing version only when its SHA-512 integrity matches the tested tarball; different bytes fail and require a new version. Publication is not atomic across npm, GHCR, and Apple. An iOS rerun selects a fresh build number for the same version. Never move a published tag or reuse a version for changed bytes. Avoid rerunning an old release after a newer release, since Docker's `latest` tag would move backward.
|
|
17
17
|
|
|
18
|
-
After npm publication
|
|
18
|
+
After npm publication, the Docker manifest job, and Apple processing all succeed, the workflow creates a GitHub Release for the existing tag with an automatically generated title and release notes. Only this final job has repository contents write permission. Reruns preserve an existing release and its notes.
|
|
19
|
+
|
|
20
|
+
The iOS publisher uses `.github/workflows/testflight.yml`, an ephemeral hosted Xcode 27 runner, and the `testflight` environment restricted to `v*` tags. That environment needs `APPLE_DEVELOPMENT_TEAM_ID`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_ISSUER_ID`, `APP_STORE_CONNECT_PRIVATE_KEY`, `IOS_DISTRIBUTION_CERTIFICATE_P12_BASE64`, and `IOS_DISTRIBUTION_CERTIFICATE_PASSWORD`. The Apple account must contain the `dev.kil.kanvil` app record and an active App Store profile matching the certificate. Configure its TestFlight information and an internal testing group with automatic build distribution in App Store Connect. The publisher generates Expo's native project, archives and verifies its embedded bundle and native fixes, exports a signed IPA, uploads it, and verifies the exact unexpired iOS build after processing. It retains no public IPA artifact. Android store publishing remains separate.
|
|
19
21
|
|
|
20
22
|
## Experimental builds
|
|
21
23
|
|
package/package.json
CHANGED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{i as e}from"./rolldown-runtime-COiEfhsA.js";import{An as t,Cn as n,Dn as r,G as i,Mn as a,Nn as o,On as s,Rt as c,Sn as l,W as u,_n as d,an as f,bn as p,bt as m,gn as h,it as g,mn as _,mt as v,r as y,wn as b,xn as x,yn as ee,zt as S}from"./default-Dnep8Yat.js";import{B as te,Co as C,G as w,Ga as T,Hi as E,J as D,Ji as O,K as k,Oa as A,Pt as j,Q as M,U as N,Ui as P,V as F,X as I,Y as L,Yi as ne,Zi as re,aa as ie,at as R,cr as z,di as B,ia as ae,it as V,nt as oe,ot as se,po as ce,q as H,qa as U,ra as le,rt as ue,tt as de,vt as W}from"./search-DwsPEC4E.js";import{g as fe}from"./item-BD1Poxfs.js";import{l as pe,r as me}from"./project-lifecycle-BBHcRi6p.js";import{r as he}from"./CompositeItem-Tueq2VKR.js";import{t as ge}from"./ellipsis-lJJU8zB7.js";import{t as _e}from"./invitations-C5WbdDUQ.js";import{t as G}from"./forms--87R7VQW.js";import{A as ve,D as ye,E as be,Ht as K,M as xe,O as Se,T as Ce,an as we,fn as Te,hn as Ee,i as q,k as De,n as J,r as Oe}from"./index-Ds3IHeIi.js";import{t as ke}from"./switch-B_rsZYBf.js";import{a as Ae,i as Y,n as je,o as X,r as Z,t as Me}from"./table-2AEfwytk.js";import{t as Ne}from"./member-directory-BjUy1KuK.js";var Pe={name:`user-plus`,size:24,node:[[`path`,{d:`M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2`,key:`1yyitq`}],[`circle`,{cx:`9`,cy:`7`,r:`4`,key:`nufk8`}],[`line`,{x1:`19`,x2:`19`,y1:`8`,y2:`14`,key:`1bvyxn`}],[`line`,{x1:`22`,x2:`16`,y1:`11`,y2:`11`,key:`1shjgl`}]]};Pe.node;var Fe=T(Pe),Q=e(o(),1),$=y(),Ie={get local(){return _(`admin.instance.provider.local`)},get s3(){return _(`admin.instance.provider.s3`)},get postgres(){return _(`admin.instance.provider.postgres`)},get elasticsearch(){return _(`admin.instance.provider.elasticsearch`)},rabbitmq:`RabbitMQ`};function Le({data:e,section:t,onConfigure:n}){if(t===`general`)return(0,$.jsxs)(`section`,{children:[(0,$.jsx)(V,{title:_(`admin.instance.heading`),action:(0,$.jsx)(D,{label:_(`admin.instance.edit`),onClick:n,children:(0,$.jsx)(he,{})})}),(0,$.jsx)(J,{title:_(`admin.instance.name`),description:e.name}),(0,$.jsx)(J,{title:_(`admin.instance.description`),description:e.description||_(`admin.instance.noDescription`)}),(0,$.jsx)(J,{title:_(`admin.instance.version`),children:(0,$.jsx)(`code`,{children:e.deployment.version})}),(0,$.jsx)(J,{title:_(`admin.instance.publicUrl`),children:(0,$.jsx)(N,{label:_(`admin.instance.publicUrl`),value:e.deployment.url,inline:!0})})]});let r=t===`storage`?e.storage:t===`search`?e.search:e.broker,i=_(t===`storage`?`admin.instance.section.storage`:t===`search`?`admin.instance.section.search`:`admin.instance.section.events`),a=r.source===`deployment`;return(0,$.jsxs)(`section`,{children:[(0,$.jsx)(V,{title:i,action:(0,$.jsx)(D,{label:_(a?`admin.instance.viewConfiguration`:`admin.instance.configure.${t}`),onClick:n,children:(0,$.jsx)(we,{})})}),(0,$.jsx)(J,{title:_(`admin.instance.providerTitle`),description:Ie[r.provider],children:(0,$.jsx)(W,{variant:`secondary`,children:_(a?`admin.instance.deploymentManaged`:`admin.instance.instanceManaged`)})}),(0,$.jsx)(J,{title:_(t===`storage`?`admin.instance.transfers`:t===`search`?`admin.instance.indexStatus`:`admin.instance.connection`),description:t===`storage`?e.storage.provider===`s3`?_(`admin.instance.transfersS3`):_(`admin.instance.transfersLocal`):t===`search`?r.status===`builtin`?_(`admin.instance.searchBuiltIn`):_(`admin.instance.searchIndexed`):_(`admin.instance.eventsConnection`),children:(0,$.jsx)(W,{variant:r.status===`unavailable`?`destructive`:`outline`,children:r.status===`builtin`?_(`admin.instance.builtIn`):r.status===`ready`?_(t===`events`?`admin.instance.connected`:`admin.instance.upToDate`):_(`admin.instance.status.${r.status}`)})}),t===`storage`&&(0,$.jsxs)($.Fragment,{children:[(0,$.jsx)(J,{title:_(`admin.instance.pendingTransfers`),description:e.storage.pending?_(`admin.instance.pendingRemain`):_(`admin.instance.noPending`),children:e.storage.pending}),e.storage.settings&&(0,$.jsxs)($.Fragment,{children:[(0,$.jsx)(J,{title:_(`admin.instance.bucket`),description:e.storage.settings.bucket}),(0,$.jsx)(N,{label:_(`admin.instance.storageEndpoint`),value:e.storage.settings.endpoint||`Amazon S3`})]}),(0,$.jsx)(J,{title:_(`admin.instance.backup`),description:e.locations.length?_(`admin.instance.backupBuckets`):_(`admin.instance.backupDatabase`)})]}),t===`search`&&(0,$.jsxs)($.Fragment,{children:[(0,$.jsx)(J,{title:_(`admin.instance.fallback`),description:_(`admin.instance.fallbackDescription`)}),e.search.settings&&(0,$.jsxs)($.Fragment,{children:[(0,$.jsx)(N,{label:_(`admin.instance.searchEndpoint`),value:e.search.settings.url}),(0,$.jsx)(J,{title:_(`admin.instance.index`),description:e.search.settings.index})]})]}),t===`events`&&(0,$.jsxs)($.Fragment,{children:[(0,$.jsx)(J,{title:_(`admin.instance.delivery`),description:_(`admin.instance.deliveryDescription`)}),e.broker.settings&&(0,$.jsxs)($.Fragment,{children:[(0,$.jsx)(N,{label:_(`admin.instance.brokerEndpoint`),value:e.broker.settings.endpoint}),(0,$.jsx)(J,{title:_(`admin.instance.exchange`),description:e.broker.settings.exchange})]})]}),(0,$.jsx)(k,{error:r.error?Error(r.error):void 0})]})}var Re=g.pick({name:!0,email:!0,image:!0}).extend({id:t(),role:t().nullable(),banned:b(),createdAt:x()}),ze=s({q:t().trim().max(200).default(``),role:l([`all`,`user`,`admin`]).default(`all`),status:l([`all`,`active`,`disabled`]).default(`all`),limit:p().int().min(1).max(100).default(25),offset:p().int().min(0).default(0)}),Be=s({users:n(Re),total:r().int().nonnegative()}),Ve=Re.extend({workspaces:n(s({id:a(),name:t(),role:l(S)})),projects:n(s({id:a(),name:t(),role:l(S)})),providers:n(t()),sessionCount:r().int().nonnegative()});s({password:t().min(12).max(128)}).strict(),ze.pick({q:!0,limit:!0,offset:!0});var He=s({workspaces:n(s({id:a(),name:t(),slug:t(),ownerId:t().nullable().default(null),createdAt:x(),memberCount:r().int().nonnegative(),projectCount:r().int().nonnegative(),administrators:n(Re.pick({id:!0,name:!0,email:!0,image:!0}))})),total:r().int().nonnegative()});function Ue({children:e,badge:t}){return(0,$.jsxs)(`div`,{className:`flex items-center justify-between gap-4 border-b py-3`,children:[e,(0,$.jsx)(W,{variant:`outline`,children:t})]})}function We({userId:e,onClose:t,onChanged:n}){let{shown:r,settled:i}=Ce(e,e=>e),[a,o]=(0,Q.useState)(!1);return(0,$.jsx)(be,{open:!!e,onOpenChange:e=>{!e&&!a&&t()},onOpenChangeComplete:i,children:(0,$.jsx)(ye,{className:`w-full! sm:max-w-lg!`,children:r&&(0,$.jsx)(Ge,{userId:r,pending:a,setPending:o,onClose:t,onChanged:n},r)})})}function Ge({userId:e,pending:t,setPending:n,onClose:r,onChanged:a}){let o=O(),[s,l]=(0,Q.useState)(null),[d,f]=(0,Q.useState)(),p=le(),m=re(`set_workspace_member`),h=U({queryKey:[`kanvil`,`user`,e],queryFn:()=>E.request(`/api/v1/admin/users/${encodeURIComponent(e)}`,Ve)}),g=h.data;async function y(e,t){n(!0),f(void 0);try{let n=await e();if(n&&typeof n==`object`&&`error`in n&&n.error){let e=n.error;throw Error(typeof e==`object`&&`message`in e?String(e.message):_(`admin.account.updateFailed`))}await a(),l(null),A.success(t)}catch(e){throw f(e),e}finally{n(!1)}}return(0,$.jsxs)($.Fragment,{children:[(0,$.jsxs)(De,{className:`shrink-0 border-b pr-14`,children:[(0,$.jsx)(ve,{className:`mb-0`,children:g?.name??_(`admin.account.details`)}),(0,$.jsx)(Se,{className:`break-all`,children:g?.email})]}),(0,$.jsxs)(`div`,{className:`@container min-h-0 overflow-y-auto px-6 pb-8`,children:[(0,$.jsx)(k,{error:h.error??(s?void 0:d)}),h.isPending?(0,$.jsx)(I,{}):g&&(0,$.jsxs)($.Fragment,{children:[(0,$.jsxs)(`div`,{className:`mb-7 flex flex-wrap items-center gap-4`,children:[(0,$.jsx)(ae,{name:g.name,image:g.image,className:`size-16 text-xl`}),(0,$.jsxs)(`div`,{className:`min-w-40 flex-1`,children:[(0,$.jsx)(W,{variant:g.banned?`destructive`:`secondary`,children:g.banned?_(`admin.users.disabled`):_(`admin.users.active`)}),(0,$.jsx)(`p`,{className:`mt-2 mb-0 text-xs text-muted-foreground`,children:_(`admin.account.joined`,{date:u(g.createdAt)})})]}),(0,$.jsxs)(B,{className:`ml-auto`,onClick:()=>{f(void 0),l(`profile`)},children:[(0,$.jsx)(he,{}),_(`admin.account.edit`)]})]}),(0,$.jsx)(J,{title:_(`admin.account.instanceRole`),description:i(g.role)?_(`admin.account.adminDescription`):_(`admin.account.userDescription`),children:(0,$.jsx)(B,{onClick:()=>{f(void 0),l(`role`)},children:_(`admin.account.changeRole`)})}),(0,$.jsx)(J,{title:_(`admin.account.signInMethods`),description:pe(g.providers),children:(0,$.jsxs)(B,{onClick:()=>{f(void 0),l(`password`)},children:[(0,$.jsx)(Te,{}),_(`admin.account.setPassword`)]})}),(0,$.jsx)(J,{title:_(`admin.account.activeSessions`),description:_(`admin.account.sessions`,{count:g.sessionCount}),children:(0,$.jsx)(F,{label:_(`admin.account.revokeSessions`),description:_(`admin.account.revokeConfirm`,{name:g.name}),onConfirm:()=>y(()=>P.admin.revokeUserSessions({userId:g.id}),_(`admin.account.sessionsRevoked`))})}),g.id!==o.id&&(0,$.jsx)(J,{title:_(`admin.account.access`),description:g.banned?_(`admin.account.accessDisabled`):_(`admin.account.accessPreserved`),children:(0,$.jsx)(F,{label:g.banned?_(`admin.account.enable`):_(`admin.account.disable`),danger:!g.banned,description:g.banned?_(`admin.account.enableConfirm`,{name:g.name}):_(`admin.account.disableConfirm`,{name:g.name}),onConfirm:()=>y(()=>g.banned?P.admin.unbanUser({userId:g.id}):P.admin.banUser({userId:g.id,banReason:`Disabled by an instance administrator`}),g.banned?_(`admin.account.enabled`):_(`admin.account.disabledToast`))})}),g.id!==o.id&&(0,$.jsx)(me,{account:g,onDeleted:async()=>{r(),await a()}}),(0,$.jsxs)(`section`,{className:`pt-7`,children:[(0,$.jsxs)(`div`,{className:`mb-3 flex items-center justify-between gap-3`,children:[(0,$.jsx)(`h3`,{className:`mb-0`,children:_(`admin.account.workspaces`)}),(0,$.jsxs)(B,{size:`sm`,onClick:()=>{f(void 0),l(`workspace`)},children:[(0,$.jsx)(fe,{}),_(`admin.account.addToWorkspace`)]})]}),g.workspaces.length?g.workspaces.map(e=>(0,$.jsx)(Ue,{badge:c[e.role],children:(0,$.jsx)(K,{to:`/workspaces/$workspaceId/settings`,params:{workspaceId:e.id},search:{section:`members`},onClick:r,className:`text-primary`,dir:`auto`,children:e.name})},e.id)):(0,$.jsx)(w,{title:_(`admin.account.noWorkspaces`),className:`py-5`})]}),!!g.projects.length&&(0,$.jsxs)(`section`,{className:`pt-7`,children:[(0,$.jsx)(`h3`,{children:_(`admin.account.projectAccess`)}),g.projects.map(e=>(0,$.jsx)(Ue,{badge:c[e.role],children:(0,$.jsx)(K,{to:`/projects/$projectId/settings`,params:{projectId:e.id},search:{section:`members`},onClick:r,className:`text-primary`,dir:`auto`,children:e.name})},e.id))]})]})]}),g&&(0,$.jsx)(M,{title:_(s===`profile`?`admin.account.edit`:s===`role`?`admin.account.changeRoleTitle`:s===`workspace`?`admin.account.addToWorkspace`:`admin.account.setPassword`),open:!!s,onOpenChange:e=>{e||l(null)},busy:t,children:(0,$.jsxs)(`form`,{onSubmit:e=>{e.preventDefault();let t=new FormData(e.currentTarget),n,r;if(s===`profile`)n=()=>P.admin.updateUser({userId:g.id,data:{name:G(t,`name`),email:G(t,`email`)}}),r=_(`admin.account.updated`);else if(s===`role`)n=()=>P.admin.setRole({userId:g.id,role:t.get(`role`)===`admin`?`admin`:`user`}),r=_(`admin.account.roleUpdated`);else if(s===`workspace`)n=()=>m.mutateAsync({workspaceId:G(t,`workspace`),email:g.email,role:G(t,`role`)===`reader`?`reader`:G(t,`role`)===`admin`?`admin`:`writer`}),r=_(`admin.account.workspaceAdded`);else{let e=G(t,`password`);if(e!==G(t,`confirm`)){f(Error(_(`admin.account.passwordMismatch`)));return}n=()=>E.request(`/api/v1/admin/users/${encodeURIComponent(g.id)}/password`,v,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({password:e})}),r=_(`admin.account.passwordSet`)}y(n,r).catch(()=>{})},children:[s===`profile`?(0,$.jsxs)($.Fragment,{children:[(0,$.jsx)(L,{label:_(`admin.account.name`),name:`name`,defaultValue:g.name,required:!0,maxLength:120,autoFocus:!0}),(0,$.jsx)(L,{label:_(`admin.account.email`),name:`email`,type:`email`,defaultValue:g.email,required:!0})]}):s===`role`?(0,$.jsxs)($.Fragment,{children:[(0,$.jsxs)(R,{label:_(`admin.account.instanceRole`),name:`role`,defaultValue:i(g.role)?`admin`:`user`,children:[(0,$.jsx)(j,{value:`user`,children:_(`admin.account.user`)}),(0,$.jsx)(j,{value:`admin`,children:_(`admin.account.administrator`)})]}),(0,$.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:_(`admin.account.adminsNote`)})]}):s===`workspace`?(0,$.jsxs)($.Fragment,{children:[(0,$.jsx)(R,{label:_(`admin.account.workspace`),name:`workspace`,required:!0,children:p.data?.filter(e=>!g.workspaces.some(t=>t.id===e.id)).map(e=>(0,$.jsx)(j,{value:e.id,children:e.name},e.id))}),(0,$.jsxs)(R,{label:_(`admin.account.workspaceRole`),name:`role`,defaultValue:`writer`,children:[(0,$.jsx)(j,{value:`reader`,children:c.reader}),(0,$.jsx)(j,{value:`writer`,children:c.writer}),(0,$.jsx)(j,{value:`admin`,children:c.admin})]})]}):(0,$.jsxs)($.Fragment,{children:[(0,$.jsx)(L,{label:_(`admin.account.newPassword`),name:`password`,type:`password`,autoComplete:`new-password`,required:!0,minLength:12,maxLength:128}),(0,$.jsx)(L,{label:_(`admin.account.confirmPassword`),name:`confirm`,type:`password`,autoComplete:`new-password`,required:!0,minLength:12,maxLength:128}),(0,$.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:_(`admin.account.passwordNote`)})]}),(0,$.jsx)(H,{error:d,pending:t,submitLabel:_(`admin.account.save`),pendingLabel:_(`common.state.saving`),onCancel:()=>l(null)})]},s)})]})}function Ke({onCreated:e,onCancel:t,pending:n,setPending:r}){let i=C(),[a,o]=(0,Q.useState)();async function s(t){t.preventDefault();let n=t.currentTarget,a=new FormData(n);r(!0),o(void 0);try{let t=await P.admin.createUser({name:G(a,`name`),email:G(a,`email`),password:G(a,`password`),role:a.get(`role`)===`admin`?`admin`:`user`});if(t.error)throw Error(t.error.message);await i.invalidateQueries({queryKey:[`kanvil`,`users`]}),e(t.data.user.id,t.data.user.email)}catch(e){o(e)}finally{r(!1)}}return(0,$.jsx)(`section`,{children:(0,$.jsxs)(`form`,{onSubmit:e=>{s(e)},children:[(0,$.jsx)(L,{label:_(`admin.users.name`),name:`name`,required:!0,maxLength:120,autoFocus:!0}),(0,$.jsx)(L,{label:_(`admin.users.email`),name:`email`,type:`email`,required:!0,autoComplete:`off`}),(0,$.jsx)(L,{label:_(`admin.users.initialPassword`),name:`password`,type:`password`,autoComplete:`new-password`,minLength:12,maxLength:128,required:!0}),(0,$.jsxs)(R,{label:_(`admin.users.instanceRole`),name:`role`,children:[(0,$.jsx)(j,{value:`user`,children:_(`admin.users.user`)}),(0,$.jsx)(j,{value:`admin`,children:_(`admin.users.administrator`)})]}),(0,$.jsx)(H,{error:a,pending:n,submitLabel:_(`admin.users.create`),pendingLabel:_(`admin.users.creating`),onCancel:t})]})})}function qe(){let e=C(),t=P.useSession(),[n,r]=(0,Q.useState)(!1),[a,o]=(0,Q.useState)(!1),[s,c]=(0,Q.useState)(``),[l,u]=(0,Q.useState)(`all`),[d,f]=(0,Q.useState)(`all`),[p,m]=(0,Q.useState)(0),[h,g]=(0,Q.useState)(null),v=U({queryKey:[`kanvil`,`users`,s,l,d,p],queryFn:()=>E.request(`/api/v1/admin/users?${new URLSearchParams({q:s,role:l,status:d,offset:String(p),limit:`25`})}`,Be),placeholderData:ce});async function y(){await Promise.all([e.invalidateQueries({queryKey:[`kanvil`]}),t.refetch()])}return(0,$.jsxs)(`section`,{children:[(0,$.jsx)(V,{title:_(`admin.users.title`),action:(0,$.jsxs)(`div`,{className:`flex min-w-0 flex-1 items-center justify-end gap-2`,children:[(0,$.jsx)(ue,{label:_(`admin.users.search`),placeholder:_(`admin.users.searchPlaceholder`),value:s,onChange:e=>{c(e),m(0)}}),(0,$.jsx)(D,{label:_(`admin.users.new`),variant:`default`,onClick:()=>r(!0),children:(0,$.jsx)(Fe,{})})]})}),(0,$.jsxs)(`div`,{className:`mb-5 flex flex-wrap items-center gap-2`,children:[(0,$.jsxs)(R,{label:_(`admin.users.roleFilter`),labelHidden:!0,className:`mb-0 w-40`,value:l,onValueChange:e=>{e&&u(e),m(0)},children:[(0,$.jsx)(j,{value:`all`,children:_(`admin.users.allRoles`)}),(0,$.jsx)(j,{value:`admin`,children:_(`admin.users.administrators`)}),(0,$.jsx)(j,{value:`user`,children:_(`admin.users.users`)})]}),(0,$.jsxs)(R,{label:_(`admin.users.statusFilter`),labelHidden:!0,className:`mb-0 w-40`,value:d,onValueChange:e=>{e&&f(e),m(0)},children:[(0,$.jsx)(j,{value:`all`,children:_(`admin.users.allAccounts`)}),(0,$.jsx)(j,{value:`active`,children:_(`admin.users.active`)}),(0,$.jsx)(j,{value:`disabled`,children:_(`admin.users.disabled`)})]})]}),(0,$.jsx)(k,{error:v.error}),v.isPending?(0,$.jsx)(I,{}):v.data?.users.length?(0,$.jsx)(`div`,{"aria-busy":v.isFetching,children:(0,$.jsxs)(Me,{mobileLayout:`rows`,"aria-label":_(`admin.users.title`),children:[(0,$.jsx)(Ae,{children:(0,$.jsxs)(X,{children:[(0,$.jsx)(Y,{children:_(`admin.users.user`)}),(0,$.jsx)(Y,{children:_(`admin.users.role`)}),(0,$.jsx)(Y,{children:_(`admin.users.status`)}),(0,$.jsx)(Y,{children:_(`admin.users.joined`)}),(0,$.jsx)(Y,{children:(0,$.jsx)(`span`,{className:`sr-only`,children:_(`admin.users.actions`)})})]})}),(0,$.jsx)(je,{children:v.data.users.map(e=>(0,$.jsx)(xe,{name:e.name,email:e.email,onOpen:()=>g(e.id),children:(0,$.jsxs)(X,{children:[(0,$.jsx)(Z,{className:`whitespace-normal`,children:(0,$.jsx)(B,{variant:`ghost`,className:`h-auto max-w-full justify-start p-0 text-left hover:bg-transparent`,onClick:()=>g(e.id),children:(0,$.jsx)(ie,{name:e.name,email:e.email,image:e.image})})}),(0,$.jsx)(Z,{"data-label":_(`admin.users.role`),children:(0,$.jsx)(W,{variant:`secondary`,children:i(e.role)?_(`admin.users.administrator`):_(`admin.users.user`)})}),(0,$.jsx)(Z,{"data-label":_(`admin.users.status`),children:(0,$.jsx)(W,{variant:e.banned?`destructive`:`outline`,children:e.banned?_(`admin.users.disabled`):_(`admin.users.active`)})}),(0,$.jsx)(Z,{"data-label":_(`admin.users.joined`),className:`text-xs text-muted-foreground`,children:(0,$.jsx)(oe,{value:e.createdAt})}),(0,$.jsx)(Z,{children:(0,$.jsx)(B,{variant:`ghost`,size:`icon`,"aria-label":_(`admin.users.manage`,{name:e.name}),onClick:()=>g(e.id),children:(0,$.jsx)(ge,{})})})]})},e.id))})]})}):(0,$.jsx)(w,{title:_(s||l!==`all`||d!==`all`?`admin.users.noMatches`:`admin.users.empty`),className:`py-12`}),(0,$.jsx)(de,{noun:_(`admin.users.pagerNoun`),offset:p,total:v.data?.total??0,size:25,onChange:m}),(0,$.jsx)(M,{title:_(`admin.users.create`),open:n,onOpenChange:r,busy:a,children:(0,$.jsx)(Ke,{pending:a,setPending:o,onCancel:()=>r(!1),onCreated:(e,t)=>{r(!1),c(``),u(`all`),f(`all`),m(0),g(e),A.success(_(`admin.users.created`,{email:t}))}})}),(0,$.jsx)(We,{userId:h,onClose:()=>g(null),onChanged:y})]})}var Je=25;function Ye(){let[e,t]=(0,Q.useState)(``),[n,r]=(0,Q.useState)(0),[i,a]=(0,Q.useState)(null),o=U({queryKey:[`kanvil`,`admin-workspaces`,e,n],queryFn:()=>E.request(`/api/v1/admin/workspaces?${new URLSearchParams({q:e,offset:String(n),limit:String(Je)})}`,He),placeholderData:ce});return(0,$.jsxs)(`section`,{children:[(0,$.jsx)(V,{title:_(`settings.workspaces.title`),action:(0,$.jsx)(`div`,{className:`flex min-w-0 flex-1 items-center justify-end gap-2`,children:(0,$.jsx)(ue,{label:_(`settings.workspaces.search`),placeholder:_(`settings.workspaces.searchPlaceholder`),value:e,onChange:e=>{t(e),r(0)}})})}),(0,$.jsx)(k,{error:o.error}),o.isPending?(0,$.jsx)(I,{}):o.data?.workspaces.length?(0,$.jsx)(`div`,{"aria-busy":o.isFetching,children:(0,$.jsxs)(Me,{mobileLayout:`rows`,"aria-label":_(`settings.workspaces.title`),children:[(0,$.jsx)(Ae,{children:(0,$.jsxs)(X,{children:[(0,$.jsx)(Y,{children:_(`settings.workspaces.workspace`)}),(0,$.jsx)(Y,{children:_(`settings.workspaces.administrators`)}),(0,$.jsx)(Y,{children:_(`settings.workspaces.members`)}),(0,$.jsx)(Y,{children:_(`settings.workspaces.projects`)}),(0,$.jsx)(Y,{children:_(`settings.workspaces.created`)}),(0,$.jsx)(Y,{children:(0,$.jsx)(`span`,{className:`sr-only`,children:_(`settings.workspaces.actions`)})})]})}),(0,$.jsx)(je,{children:o.data.workspaces.map(e=>(0,$.jsxs)(X,{children:[(0,$.jsx)(Z,{className:`whitespace-normal`,children:(0,$.jsxs)(B,{variant:`ghost`,className:`h-auto max-w-full flex-col items-start gap-0.5 p-0 text-left hover:bg-transparent`,onClick:()=>a(e),children:[(0,$.jsx)(`span`,{dir:`auto`,className:`font-medium wrap-anywhere whitespace-normal`,children:e.name}),(0,$.jsx)(`span`,{className:`font-mono text-xs font-normal text-muted-foreground`,children:e.slug})]})}),(0,$.jsx)(Z,{"data-label":_(`settings.workspaces.administrators`),className:`text-xs wrap-anywhere whitespace-normal`,children:e.administrators.length?f(e.administrators.map(e=>e.name)):(0,$.jsx)(W,{variant:`destructive`,children:_(`settings.workspaces.noAdministrator`)})}),(0,$.jsx)(Z,{"data-label":_(`settings.workspaces.members`),className:`tabular-nums`,children:e.memberCount}),(0,$.jsx)(Z,{"data-label":_(`settings.workspaces.projects`),className:`tabular-nums`,children:e.projectCount}),(0,$.jsx)(Z,{"data-label":_(`settings.workspaces.created`),className:`text-xs text-muted-foreground`,children:(0,$.jsx)(oe,{value:e.createdAt})}),(0,$.jsx)(Z,{children:(0,$.jsx)(B,{variant:`ghost`,size:`icon`,"aria-label":_(`settings.workspaces.manage`,{name:e.name}),onClick:()=>a(e),children:(0,$.jsx)(ge,{})})})]},e.id))})]})}):(0,$.jsx)(w,{title:_(e?`settings.workspaces.noMatches`:`settings.workspaces.empty`),className:`py-12`}),(0,$.jsx)(de,{noun:_(`settings.workspaces.pagerNoun`),offset:n,total:o.data?.total??0,size:Je,onChange:r}),(0,$.jsx)(Xe,{workspace:i,onClose:()=>a(null)})]})}function Xe({workspace:e,onClose:t}){let{shown:n,settled:r}=Ce(e,e=>e.id),i=n?.id??``,a=U({...E.query(`list_workspace_members`,{workspaceId:i}),enabled:!!n}),o=U({...E.query(`list_projects`,{workspaceId:i,archived:`include`}),enabled:!!n}),s=U({...E.query(`list_workspace_roles`,{workspaceId:i}),enabled:!!n}),c=re(`set_workspace_member`);return(0,$.jsx)(be,{open:!!e,onOpenChange:e=>{!e&&!c.isPending&&t()},onOpenChangeComplete:r,children:(0,$.jsxs)(ye,{className:`w-full! sm:max-w-lg!`,children:[(0,$.jsxs)(De,{className:`shrink-0 border-b pr-14`,children:[(0,$.jsx)(ve,{className:`mb-0`,children:n?.name??_(`settings.workspaces.workspace`)}),(0,$.jsx)(Se,{className:`font-mono`,children:n?.slug})]}),(0,$.jsxs)(`div`,{className:`@container min-h-0 overflow-y-auto px-6 pb-8`,children:[(0,$.jsx)(Ne,{people:a.data??[],pending:c.isPending,loading:a.isPending,error:a.error,canAdmin:!0,ownerId:n?.ownerId,scope:`workspace`,roles:s.data,authority:{admin:!0,capabilities:m},onChange:(e,t)=>c.mutateAsync({workspaceId:i,email:e,role:t})}),n&&(0,$.jsx)(_e,{workspaceId:n.id}),(0,$.jsxs)(`section`,{className:`pt-7`,children:[(0,$.jsx)(`h3`,{children:_(`settings.workspaces.projects`)}),(0,$.jsx)(k,{error:o.error}),o.isPending?(0,$.jsx)(I,{}):o.data?.length?o.data.map(e=>(0,$.jsx)(Ue,{badge:e.visibility===`private`?_(`settings.workspaces.private`):_(`settings.members.workspaceAccess`),children:(0,$.jsx)(K,{to:`/projects/$projectId/settings`,params:{projectId:e.id},search:{section:`members`},onClick:t,className:`text-primary`,children:e.name})},e.id)):(0,$.jsx)(w,{title:_(`settings.workspaces.noProjects`),className:`py-5`})]})]},n?.id)]})})}var Ze={queryKey:[`kanvil`,`instance`],queryFn:()=>E.request(`/api/v1/admin/instance`,h)};function Qe({section:e}){let t=U({...Ze,refetchOnMount:`always`,refetchInterval:[`search`,`storage`,`events`].includes(e)?5e3:!1});return t.data&&t.isFetchedAfterMount?(0,$.jsx)(et,{data:t.data,section:e},e):t.isPending||t.isFetching?(0,$.jsx)(I,{}):(0,$.jsx)(k,{error:t.error})}function $e(e){return{expectedVersion:e.version,authentication:e.authentication,name:e.name,description:e.description,storageProvider:e.storage.provider,s3:{endpoint:``,region:`us-east-1`,bucket:``,prefix:`kanvil/`,forcePathStyle:!0,accessKeyId:``,...e.storage.settings,secretAccessKey:``},brokerProvider:e.broker.provider,rabbit:{url:``,exchange:e.broker.settings?.exchange??`kanvil.events`},searchProvider:e.search.provider,elastic:{url:``,index:`kanvil-${e.id}`,username:``,...e.search.settings,auth:e.search.settings?.auth??`none`,apiKey:``,password:``}}}function et({data:e,section:t}){let n=C(),i=ne(),[a,o]=(0,Q.useState)(()=>$e(e)),[c,l]=(0,Q.useState)(!1),[u,p]=(0,Q.useState)(),[m,h]=(0,Q.useState)(``),[g,y]=(0,Q.useState)(!1),b=e.storage.source===`deployment`,x=e.search.source===`deployment`,S=e.broker.source===`deployment`,w=t===`storage`?b:t===`search`?x:t===`events`&&S,T=e=>o(t=>({...t,s3:{...t.s3,...e}})),D=e=>o(t=>({...t,elastic:{...t.elastic,...e}}));async function O(e,t,n=!1){p(void 0),h(``),l(!0);try{await e(),n?A.success(t,{id:`instance-settings`}):h(t)}catch(e){p(e)}finally{l(!1)}}async function P(){let e=d.parse({expectedVersion:a.expectedVersion,...t===`authentication`?{authentication:a.authentication}:{},name:a.name,description:a.description,storage:a.storageProvider===`s3`?{provider:`s3`,...a.s3}:{provider:`local`},broker:a.brokerProvider===`rabbitmq`?{provider:`rabbitmq`,...a.rabbit,url:a.rabbit.url||void 0}:{provider:`postgres`},search:a.searchProvider===`elasticsearch`?{provider:`elasticsearch`,...a.elastic}:{provider:`postgres`}});await E.request(`/api/v1/admin/instance`,v,{method:`PATCH`,headers:{"Content-Type":`application/json`},body:JSON.stringify(e)}),o(e=>({...e,expectedVersion:e.expectedVersion+1,s3:{...e.s3,secretAccessKey:``},elastic:{...e.elastic,apiKey:``,password:``},rabbit:{...e.rabbit,url:``}})),await n.invalidateQueries({queryKey:[`kanvil`,`instance`]}),await n.invalidateQueries({queryKey:[`info`]}),y(!1)}return(0,$.jsxs)(Oe,{title:_(`admin.instance.title`),section:t,children:[[`general`,`storage`,`search`,`events`].map(t=>(0,$.jsx)(q,{value:t,children:(0,$.jsx)(Le,{data:e,section:t,onConfigure:()=>{o($e(e)),p(void 0),h(``),y(!0)}})},t)),!g&&t!==`authentication`&&(0,$.jsx)(k,{error:u}),(0,$.jsx)(M,{title:_(t===`general`?`admin.instance.edit`:t===`storage`?`admin.configure.storageTitle`:t===`search`?`admin.configure.searchTitle`:`admin.configure.eventsTitle`),open:g,onOpenChange:y,busy:c,children:(0,$.jsxs)(`form`,{className:`[&_section]:py-0 [&>[data-slot=field-set]>section:first-child]:pt-0`,onSubmit:e=>{e.preventDefault(),w||O(P,_(`admin.configure.saved`),!0)},children:[(0,$.jsxs)(z,{disabled:c,className:`gap-0`,children:[t===`general`&&(0,$.jsx)($.Fragment,{children:(0,$.jsxs)(`section`,{children:[(0,$.jsx)(`h2`,{children:_(`admin.instance.heading`)}),(0,$.jsx)(L,{label:_(`admin.configure.instanceName`),required:!0,maxLength:100,value:a.name,onChange:e=>o({...a,name:e.target.value})}),(0,$.jsx)(se,{label:_(`admin.configure.instanceDescription`),maxLength:500,value:a.description,onChange:e=>o({...a,description:e.target.value})}),(0,$.jsx)(N,{label:_(`admin.instance.publicUrl`),value:e.deployment.url}),(0,$.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:_(`admin.configure.publicUrlSource`)})]})}),t===`storage`&&(0,$.jsxs)($.Fragment,{children:[(0,$.jsxs)(`section`,{children:[(0,$.jsxs)(`div`,{className:`mb-5 flex items-center gap-3`,children:[(0,$.jsx)(`h2`,{className:`mb-0`,children:_(`admin.instance.section.storage`)}),(0,$.jsx)(W,{variant:`secondary`,children:_(b?`admin.instance.deploymentManaged`:`admin.instance.instanceManaged`)})]}),(0,$.jsx)(`p`,{children:_(`admin.configure.storageIntro`)}),(0,$.jsxs)(R,{label:_(`admin.configure.storageProvider`),disabled:b,value:a.storageProvider,onValueChange:e=>o({...a,storageProvider:e===`s3`?`s3`:`local`}),children:[(0,$.jsx)(j,{value:`local`,children:_(`admin.instance.provider.local`)}),(0,$.jsx)(j,{value:`s3`,children:_(`admin.instance.provider.s3`)})]}),a.storageProvider===`local`?(0,$.jsx)(`p`,{children:_(`admin.configure.localDescription`)}):(0,$.jsxs)(z,{disabled:b,className:`gap-0`,children:[(0,$.jsx)(L,{label:_(`admin.configure.s3Endpoint`),type:`url`,placeholder:`https://s3.example.com`,value:a.s3.endpoint,onChange:e=>T({endpoint:e.target.value})}),(0,$.jsx)(`p`,{className:`-mt-2 text-xs`,children:_(`admin.configure.s3EndpointHint`)}),(0,$.jsxs)(`div`,{className:`grid grid-cols-1 gap-x-5 sm:grid-cols-2`,children:[(0,$.jsx)(L,{label:_(`admin.instance.bucket`),value:a.s3.bucket,required:!0,onChange:e=>T({bucket:e.target.value})}),(0,$.jsx)(L,{label:_(`admin.configure.region`),value:a.s3.region,required:!0,onChange:e=>T({region:e.target.value})})]}),(0,$.jsx)(L,{label:_(`admin.configure.prefix`),value:a.s3.prefix,onChange:e=>T({prefix:e.target.value})}),(0,$.jsx)(L,{label:_(`admin.configure.accessKeyId`),autoComplete:`off`,value:a.s3.accessKeyId,onChange:e=>T({accessKeyId:e.target.value})}),(0,$.jsx)(L,{label:_(`admin.configure.secretAccessKey`),type:`password`,autoComplete:`new-password`,value:a.s3.secretAccessKey,placeholder:e.storage.secretConfigured?_(`admin.configure.savedSecret`):_(`admin.configure.deploymentCredentials`),onChange:e=>T({secretAccessKey:e.target.value})}),(0,$.jsx)(te,{label:_(`admin.configure.pathStyle`),checked:a.s3.forcePathStyle,onCheckedChange:e=>T({forcePathStyle:e})})]}),a.storageProvider===`s3`&&(0,$.jsx)(B,{disabled:c,onClick:()=>{O(()=>E.request(`/api/v1/admin/storage/test`,v,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify(a.s3)}),_(`admin.configure.storageVerified`))},children:_(`admin.configure.testStorage`)}),(0,$.jsx)(`p`,{className:`mt-4 text-xs`,role:`status`,children:e.storage.pending?_(`admin.configure.filesWaiting`,{count:e.storage.pending}):_(`admin.configure.transfersComplete`)}),e.storage.error&&(0,$.jsx)(k,{error:Error(e.storage.error)}),b&&(0,$.jsx)(`p`,{className:`mt-4 text-xs`,children:_(`admin.configure.storageDeployment`)})]}),e.locations.length>0&&(0,$.jsxs)(`section`,{children:[(0,$.jsx)(`h2`,{children:_(`admin.configure.locations`)}),e.locations.map(e=>(0,$.jsxs)(`div`,{className:`flex flex-wrap items-center gap-3 border-b py-2.5 text-xs wrap-anywhere [&>:first-child]:min-w-24 [&>:first-child]:flex-1`,children:[(0,$.jsxs)(`span`,{className:`grid gap-1`,children:[(0,$.jsx)(`strong`,{children:e.bucket}),(0,$.jsxs)(`span`,{className:`text-muted-foreground`,children:[e.endpoint||`Amazon S3`,` / `,e.prefix]})]}),(0,$.jsx)(`span`,{children:_(`admin.configure.locationFiles`,{count:e.objects})})]},e.id)),(0,$.jsx)(`div`,{className:`mt-4`,children:(0,$.jsx)(F,{label:_(`admin.configure.cleanUp`),description:_(`admin.configure.cleanUpConfirm`),onConfirm:async()=>{await E.request(`/api/v1/admin/storage/cleanup`,s({removed:r()}),{method:`POST`}),await n.invalidateQueries({queryKey:[`kanvil`,`instance`]})}})}),(0,$.jsx)(`p`,{className:`mt-3 text-xs`,children:_(`admin.configure.cleanUpNote`)})]})]}),t===`search`&&(0,$.jsx)($.Fragment,{children:(0,$.jsxs)(`section`,{children:[(0,$.jsxs)(`div`,{className:`mb-5 flex items-center gap-3`,children:[(0,$.jsx)(`h2`,{className:`mb-0`,children:_(`admin.instance.section.search`)}),(0,$.jsx)(W,{variant:`secondary`,children:e.search.status===`builtin`?`PostgreSQL`:_(`admin.instance.status.${e.search.status}`)})]}),(0,$.jsxs)(R,{label:_(`admin.configure.searchProvider`),disabled:x,value:a.searchProvider,onValueChange:e=>o({...a,searchProvider:e===`elasticsearch`?`elasticsearch`:`postgres`}),children:[(0,$.jsx)(j,{value:`postgres`,children:_(`admin.configure.searchBuiltIn`)}),(0,$.jsx)(j,{value:`elasticsearch`,children:_(`admin.instance.provider.elasticsearch`)})]}),a.searchProvider===`elasticsearch`&&(0,$.jsxs)($.Fragment,{children:[(0,$.jsxs)(z,{disabled:x,className:`gap-0`,children:[(0,$.jsx)(L,{label:_(`admin.instance.searchEndpoint`),type:`url`,required:!0,value:a.elastic.url,onChange:e=>D({url:e.target.value})}),(0,$.jsx)(L,{label:_(`admin.configure.indexName`),required:!0,value:a.elastic.index,onChange:e=>D({index:e.target.value})}),(0,$.jsxs)(R,{label:_(`admin.configure.searchAuthentication`),value:a.elastic.auth,onValueChange:e=>D({auth:ee.parse(e)}),children:[(0,$.jsx)(j,{value:`none`,children:_(`admin.configure.noAuthentication`)}),(0,$.jsx)(j,{value:`apiKey`,children:_(`admin.configure.apiKey`)}),(0,$.jsx)(j,{value:`basic`,children:_(`admin.configure.basic`)})]}),a.elastic.auth===`apiKey`&&(0,$.jsx)(L,{label:_(`admin.configure.searchApiKey`),type:`password`,autoComplete:`new-password`,placeholder:e.search.secretConfigured?_(`admin.configure.savedSecret`):_(`admin.configure.optional`),value:a.elastic.apiKey,onChange:e=>D({apiKey:e.target.value})}),a.elastic.auth===`basic`&&(0,$.jsxs)(`div`,{className:`grid grid-cols-1 gap-x-5 sm:grid-cols-2`,children:[(0,$.jsx)(L,{label:_(`admin.configure.searchUsername`),autoComplete:`off`,value:a.elastic.username,onChange:e=>D({username:e.target.value})}),(0,$.jsx)(L,{label:_(`admin.configure.searchPassword`),type:`password`,autoComplete:`new-password`,value:a.elastic.password,onChange:e=>D({password:e.target.value})})]})]}),(0,$.jsxs)(`div`,{className:`flex flex-wrap items-center gap-2`,children:[(0,$.jsx)(B,{onClick:()=>{O(()=>E.request(`/api/v1/admin/search/test`,v,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify(a.elastic)}),_(`admin.configure.searchVerified`))},children:_(`admin.configure.testSearch`)}),e.search.provider===`elasticsearch`&&(0,$.jsx)(B,{onClick:()=>{O(()=>E.request(`/api/v1/admin/search/reindex`,v,{method:`POST`}),_(`admin.configure.rebuildQueued`))},children:_(`admin.configure.rebuild`)})]})]}),(0,$.jsx)(`p`,{className:`mt-5`,children:_(`admin.configure.searchNote`)}),e.search.error&&(0,$.jsx)(k,{error:Error(e.search.error)})]})}),t===`events`&&(0,$.jsx)($.Fragment,{children:(0,$.jsxs)(`section`,{children:[(0,$.jsxs)(`div`,{className:`mb-5 flex items-center gap-3`,children:[(0,$.jsx)(`h2`,{className:`mb-0`,children:_(`admin.instance.section.events`)}),(0,$.jsx)(W,{variant:`secondary`,children:e.broker.status===`builtin`?`PostgreSQL`:_(`admin.instance.status.${e.broker.status}`)})]}),(0,$.jsxs)(R,{label:_(`admin.configure.brokerProvider`),disabled:S,value:a.brokerProvider,onValueChange:e=>o({...a,brokerProvider:e===`rabbitmq`?`rabbitmq`:`postgres`}),children:[(0,$.jsx)(j,{value:`postgres`,children:_(`admin.configure.brokerBuiltIn`)}),(0,$.jsx)(j,{value:`rabbitmq`,children:`RabbitMQ`})]}),a.brokerProvider===`rabbitmq`&&(0,$.jsxs)($.Fragment,{children:[(0,$.jsxs)(z,{disabled:S,className:`gap-0`,children:[(0,$.jsx)(L,{label:_(`admin.configure.rabbitUrl`),type:`password`,autoComplete:`new-password`,value:a.rabbit.url,placeholder:e.broker.secretConfigured?_(`admin.configure.savedSecret`):`amqps://user:password@host/vhost`,onChange:e=>o({...a,rabbit:{...a.rabbit,url:e.target.value}})}),e.broker.settings&&(0,$.jsx)(N,{label:_(`admin.configure.savedBrokerEndpoint`),value:e.broker.settings.endpoint}),(0,$.jsx)(L,{label:_(`admin.configure.rabbitExchange`),required:!0,value:a.rabbit.exchange,onChange:e=>o({...a,rabbit:{...a.rabbit,exchange:e.target.value}})})]}),(0,$.jsx)(B,{onClick:()=>{O(()=>E.request(`/api/v1/admin/broker/test`,v,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({...a.rabbit,url:a.rabbit.url||void 0})}),_(`admin.configure.brokerVerified`))},children:_(`admin.configure.testBroker`)})]}),(0,$.jsx)(`p`,{className:`mt-5`,children:_(`admin.configure.brokerNote`)}),S&&(0,$.jsx)(`p`,{className:`mt-4 text-xs`,children:_(`admin.configure.brokerDeployment`)}),e.broker.error&&(0,$.jsx)(k,{error:Error(e.broker.error)})]})})]}),[`general`,`storage`,`search`,`events`].includes(t)&&(0,$.jsxs)(`div`,{className:`sticky bottom-0 mt-6 flex flex-wrap items-center gap-3 border-t bg-background/95 py-4 backdrop-blur`,children:[(0,$.jsx)(B,{disabled:c,onClick:()=>y(!1),children:_(w?`common.action.done`:`common.action.cancel`)}),!w&&(0,$.jsx)(B,{variant:`default`,type:`submit`,disabled:c,children:_(c?`common.state.saving`:`admin.configure.save`)}),m&&(0,$.jsx)(`span`,{role:`status`,className:`text-sm text-muted-foreground`,children:m}),(0,$.jsx)(k,{error:u})]})]})}),(0,$.jsx)(q,{value:`users`,children:(0,$.jsx)(qe,{})}),(0,$.jsx)(q,{value:`workspaces`,children:(0,$.jsx)(Ye,{})}),(0,$.jsx)(q,{value:`authentication`,children:(0,$.jsxs)(`section`,{children:[(0,$.jsx)(V,{title:_(`admin.authentication.title`)}),(0,$.jsxs)(`form`,{className:`mb-6`,onSubmit:e=>{e.preventDefault(),O(P,_(`admin.authentication.saved`),!0)},children:[(0,$.jsx)(J,{title:_(`admin.authentication.emailPassword`),description:_(`admin.authentication.emailPasswordDescription`),children:(0,$.jsx)(ke,{"aria-label":_(`admin.authentication.emailPassword`),checked:a.authentication.emailPasswordEnabled,disabled:c,onCheckedChange:e=>o(t=>({...t,authentication:{...t.authentication,emailPasswordEnabled:e}}))})}),(0,$.jsx)(J,{title:_(`admin.authentication.ssoProvision`),description:_(`admin.authentication.ssoProvisionDescription`),children:(0,$.jsx)(ke,{"aria-label":_(`admin.authentication.ssoProvision`),checked:a.authentication.ssoAutoProvision,disabled:c,onCheckedChange:e=>o(t=>({...t,authentication:{...t.authentication,ssoAutoProvision:e}}))})}),(0,$.jsx)(J,{title:_(`admin.authentication.registration`),children:_(`admin.authentication.registrationClosed`)}),(0,$.jsx)(J,{title:_(`admin.authentication.identityProvider`),children:e.deployment.oidc?.name??_(`admin.authentication.notConfigured`)}),(0,$.jsx)(J,{title:_(`admin.authentication.linkedProviders`),description:_(`admin.authentication.linkedProvidersDescription`),children:f(i.data?.providers.filter(e=>e.id!==`oidc`).map(e=>e.name)??[])||_(`admin.authentication.notConfigured`)}),(0,$.jsx)(H,{layout:`inline`,error:u,pending:c,submitLabel:_(`admin.authentication.save`),pendingLabel:_(`common.state.saving`),submitDisabled:a.authentication.emailPasswordEnabled===e.authentication.emailPasswordEnabled&&a.authentication.ssoAutoProvision===e.authentication.ssoAutoProvision})]}),e.deployment.oidc&&(0,$.jsxs)($.Fragment,{children:[(0,$.jsx)(N,{label:_(`admin.authentication.discoveryUrl`),value:e.deployment.oidc.discoveryUrl}),(0,$.jsx)(N,{label:_(`admin.authentication.clientId`),value:e.deployment.oidc.clientId})]}),(0,$.jsx)(`p`,{children:_(`admin.authentication.oidcSetup`)}),(0,$.jsx)(`h3`,{children:_(`admin.authentication.consumers`)}),(0,$.jsx)(N,{label:`HTTP API`,value:`${e.deployment.url}/api/v1`}),(0,$.jsx)(N,{label:_(`admin.authentication.mcpEndpoint`),value:`${e.deployment.url}/mcp`}),(0,$.jsx)(N,{label:_(`admin.authentication.eventFeed`),value:`${e.deployment.url}/api/v1/events`}),(0,$.jsxs)(`a`,{className:`inline-flex items-center gap-2 text-primary`,href:`/api/openapi.json`,target:`_blank`,rel:`noreferrer`,children:[_(`admin.authentication.openApi`),(0,$.jsx)(Ee,{className:`size-4`})]})]})})]})}export{Qe as AdminSettings};
|