Compare commits

...
9 Commits
Author SHA1 Message Date
Aleksandr Bogomiakov 0173630dff update helm
Checks / test (push) Failing after 4m34s
2026-10-14 12:11:52 +01:00
Aleksandr BogomiakovandClaude abf5be8b82 Bump version to 0.3.0
Checks / test (push) Failing after 4m0s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-07 16:06:19 +01:00
Aleksandr BogomiakovandClaude 38105c65c2 Merge NEXT: planning, generation map and portable configuration on top of the OIDC access model
NEXT predates the access work, so the identity layer follows master: access comes from
configured groups, instance administrators replace the site administration, and accounts are
created by SSO or by an administrator. Kept from NEXT: manual publication with the Review
changes panel and shared generator drafts, portable import/export (meguri/v2), the layered
generation map with binding provenance, JSM provider changes, contact/approval semantics and
the account-owned Person. Dropped: site administration, local sign-up, and the mandatory
unique email with its contact/account migrations. The manual-publication migration is
renumbered m_0010 after m_0009_access.

Accounts without an email still get a Person in every workspace they reach, a changed sign-in
email updates the account's person, and an account that loses access keeps its linked person so
schedules stay valid. Instance administrators may use Sync and shared drafts.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-07 16:06:10 +01:00
Aleksandr BogomiakovandClaude 5ccf2b1108 Show the running version next to the brand; bump to 0.2.1
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-07 15:32:20 +01:00
Aleksandr BogomiakovandClaude 6a55988671 Bump version to 0.2.0
Checks / test (push) Failing after 3m24s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-07 15:06:22 +01:00
Aleksandr BogomiakovandClaude 548494f12e Restrict workspace settings to administrators, add demo countries, Helm chart and instance event log
Employees keep schedule editing but can no longer change workspace preferences or existing
scheduling-directory entries (enforced by the server, hidden in the UI). Demo accounts live in
Cyprus, the United Kingdom and Armenia with matching bank holidays. A Helm chart deploys the
service with a pre-upgrade migrate hook. Refused SSO sign-ins are stored with their support
reference as instance events and searchable on the instance administration page.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-07 15:06:12 +01:00
Aleksandr Bogomiakov 7afdc4be97 Added OIDC workflow and Helm Chart
Checks / test (push) Failing after 4m13s
2026-10-07 14:22:37 +01:00
Aleksandr BogomiakovandClaude 984c7083a1 Build dependencies in a cached layer and document running the image
The instance configuration is mounted at /etc/meguri/config.yaml, which is now
the default MEGURI_CONFIG_FILE; the image ships the cytoscape license too.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-07 13:30:01 +01:00
Aleksandr BogomiakovandClaude 4764f3545f Add OIDC sign-in, group-derived roles and declarative instance configuration
Workspaces, group mappings and sign-in methods now come from a configuration
file synchronized at startup; workspaces dropped from it are disabled, never
deleted. Instance administrators (a new role) see and help every workspace and
may delete disabled ones; workspace administrators and employees are derived
from the user's groups at every SSO sign-in or administrator edit, and the
employee permission set belongs to the workspace. Self-registration is gone:
users come from SSO (only when their groups map) or from the administrator.
Break-glass bootstrap accounts get an HMAC-derived hourly password that all
replicas agree on. First SSO sign-in links a matching email contact to the
user. The demo runs on the same model with a generated per-start password.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-07 13:28:43 +01:00
102 changed files with 8768 additions and 2857 deletions

No files matched your search

Vendored
BIN
View File
Binary file not shown.
+10 -10
View File
@@ -2,8 +2,17 @@ DATABASE_URL=postgresql://meguri:change-me@127.0.0.1:5432/meguri
MEGURI_BIND=127.0.0.1:3000 MEGURI_BIND=127.0.0.1:3000
MEGURI_PUBLIC_URL=http://127.0.0.1:3000 MEGURI_PUBLIC_URL=http://127.0.0.1:3000
MEGURI_ROLE=all MEGURI_ROLE=all
MEGURI_ALLOW_SIGNUP=false # Instance configuration: workspaces, group mappings and sign-in methods. Required to start,
# except in demo mode (MEGURI_SEED_DEMO=true), which uses a built-in configuration and ignores this.
# See deploy/instance-config.example.yaml.
MEGURI_CONFIG_FILE=/etc/meguri/config.yaml
# Deployment secret of the confidential OIDC client; required when the configuration has an oidc block.
# MEGURI_OIDC_CLIENT_SECRET=from-your-secret-manager
# Allows a configuration without workspaces to disable every existing workspace (normally refused).
# MEGURI_ALLOW_DISABLE_ALL=false
MEGURI_SEED_DEMO=false MEGURI_SEED_DEMO=false
# Demo only: adds n empty "Sandbox" workspaces for automated browser checks (0-20).
# MEGURI_DEMO_SANDBOXES=0
# Optional separately provisioned empty demo database. Protected databases are never reset. # Optional separately provisioned empty demo database. Protected databases are never reset.
# MEGURI_DEMO_DATABASE_URL=postgresql://demo:change-me@127.0.0.1:5432/meguri_demo # MEGURI_DEMO_DATABASE_URL=postgresql://demo:change-me@127.0.0.1:5432/meguri_demo
MEGURI_DEMO_WORKSPACE_TTL_SECONDS=86400 MEGURI_DEMO_WORKSPACE_TTL_SECONDS=86400
@@ -16,12 +25,3 @@ RUST_LOG=meguri=info,cot=warn
# Tenant-specific JSM deployment secrets. Replace UUID_WITHOUT_DASHES with uppercase tenant ID. # Tenant-specific JSM deployment secrets. Replace UUID_WITHOUT_DASHES with uppercase tenant ID.
# MEGURI_JSM_UUID_WITHOUT_DASHES_EMAIL=integration@example.com # MEGURI_JSM_UUID_WITHOUT_DASHES_EMAIL=integration@example.com
# MEGURI_JSM_UUID_WITHOUT_DASHES_TOKEN=from-your-secret-manager # MEGURI_JSM_UUID_WITHOUT_DASHES_TOKEN=from-your-secret-manager
# Explicit one-time site administrator grant to an existing local username.
# MEGURI_BOOTSTRAP_ADMIN=admin
# OIDC settings UI is draft-only; the authentication adapter is not active yet.
# MEGURI_OIDC_CLIENT_SECRET=provided-by-deployment-secret-store
# One-time migration of existing login accounts: required only for missing or
# ambiguous emails. Existing unambiguous linked Person emails are recovered.
# MEGURI_ACCOUNT_EMAILS='{"admin":"admin@example.com"}'
+7 -2
View File
@@ -37,7 +37,12 @@ jobs:
- run: cargo fmt --check - run: cargo fmt --check
- run: cargo clippy --locked --all-targets -- -D warnings - run: cargo clippy --locked --all-targets -- -D warnings
- run: cargo test --locked - run: cargo test --locked
- run: cargo test --locked --test integration -- --ignored --test-threads=1 - run: cargo test --locked --tests -- --ignored --test-threads=1
- uses: azure/setup-helm@v4
- name: Helm chart
run: |
helm lint helm/meguri --strict -f helm/meguri/ci/values.yaml
helm template meguri helm/meguri -f helm/meguri/ci/values.yaml > /dev/null
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
@@ -48,7 +53,7 @@ jobs:
env: env:
DATABASE_URL: postgresql://postgres:test-only@postgres:5432/meguri_test DATABASE_URL: postgresql://postgres:test-only@postgres:5432/meguri_test
MEGURI_SEED_DEMO: "true" MEGURI_SEED_DEMO: "true"
MEGURI_ALLOW_SIGNUP: "true" MEGURI_DEMO_SANDBOXES: "14"
MEGURI_BROWSER_CHANNEL: chromium MEGURI_BROWSER_CHANNEL: chromium
run: | run: |
cargo build --locked cargo build --locked
+4 -3
View File
@@ -13,6 +13,7 @@ All built-in UI text, labels, placeholders, accessible names and errors belong i
- `domain`: typed state and permissions; no HTTP, database, or provider dependencies. - `domain`: typed state and permissions; no HTTP, database, or provider dependencies.
- `engine`: deterministic schedule calculation with an explicit clock/range; no I/O. - `engine`: deterministic schedule calculation with an explicit clock/range; no I/O.
- `store` / `service`: Cot ORM and atomic PostgreSQL statement boundaries, authorization, optimistic versions, approval workflow, audit, durable sync requests. - `store` / `service`: Cot ORM and atomic PostgreSQL statement boundaries, authorization, optimistic versions, approval workflow, audit, durable sync requests.
- `access`: pure instance configuration, group-to-role resolution and bootstrap passwords. `identity`: configuration sync, group-derived access, SSO accounts and contact linking. `oidc`: token protocol and verification only. `admin`: instance administrator operations. Anything that grants access goes through `identity::apply_access`.
- `providers`: provider translation and HTTP only. Scheduling decisions belong in the engine. Never silently omit an unsupported provider feature. - `providers`: provider translation and HTTP only. Scheduling decisions belong in the engine. Never silently omit an unsupported provider feature.
- `worker`: retryable reconciliation, drift checks, publication checkpoints. Multiple replicas must not publish a workspace concurrently. - `worker`: retryable reconciliation, drift checks, publication checkpoints. Multiple replicas must not publish a workspace concurrently.
- `web`: thin authenticated handlers. Every workspace operation checks its workspace scope on the server. - `web`: thin authenticated handlers. Every workspace operation checks its workspace scope on the server.
@@ -20,7 +21,7 @@ All built-in UI text, labels, placeholders, accessible names and errors belong i
## Invariants ## Invariants
1. Workspace permissions are checked on every API and background operation. Membership in one workspace never grants access to another. Workspace owners administer only their workspace. There is no workspace hierarchy or implicit team. 1. Workspace permissions are checked on every API and background operation. Membership in one workspace never grants access to another. Workspace administrators administer only their workspace; instance administrators (a flag derived from `instance_admin_groups` or a bootstrap account) act on every enabled workspace without being members and are the only ones who see instance configuration, users and disabled workspaces. Workspaces, their keys and the groups that grant roles come only from the instance configuration; memberships are derived from stored groups by `identity::apply_access` and never granted any other way, and there is no self-registration. Disabled workspaces are inaccessible to everyone and are never deleted automatically. There is no workspace hierarchy or implicit team.
2. A request is not an override until all required participants approve or a user with the force permission applies it. Every action is audited; its comment is optional, including forced application. Revalidate ownership, availability, and overlaps at application time. A subsequent approved replacement supersedes only overlapping effective spans; preserve original requests and history. 2. A request is not an override until all required participants approve or a user with the force permission applies it. Every action is audited; its comment is optional, including forced application. Revalidate ownership, availability, and overlaps at application time. A subsequent approved replacement supersedes only overlapping effective spans; preserve original requests and history.
3. Intervals are half-open `[start, end)`. Every schedule has its own handover in the workspace's IANA zone, snapshotted in workspace history. User timezone affects display only and defaults to the browser zone. Reject ambiguous/nonexistent local handovers instead of guessing. Never substitute a fixed UTC offset. 3. Intervals are half-open `[start, end)`. Every schedule has its own handover in the workspace's IANA zone, snapshotted in workspace history. User timezone affects display only and defaults to the browser zone. Reject ambiguous/nonexistent local handovers instead of guessing. Never substitute a fixed UTC offset.
4. Base rotations have an explicit ordered participant list and anchor. An override does not advance or reset the base rotation. 4. Base rotations have an explicit ordered participant list and anchor. An override does not advance or reset the base rotation.
@@ -29,13 +30,13 @@ All built-in UI text, labels, placeholders, accessible names and errors belong i
7. External drift blocks publication by default. Overwrite is an explicit workspace policy. Reconcile only owned resources. Persist successful remote writes so partial failures can recover. 7. External drift blocks publication by default. Overwrite is an explicit workspace policy. Reconcile only owned resources. Persist successful remote writes so partial failures can recover.
8. Provider secrets come from the deployment secret store/environment, never API responses, audit snapshots, examples, or logs. 8. Provider secrets come from the deployment secret store/environment, never API responses, audit snapshots, examples, or logs.
9. Report approval and publication as separate statuses. Never label an unverified external write as synchronized. 9. Report approval and publication as separate statuses. Never label an unverified external write as synchronized.
10. Scheduling contacts never gain login or workspace access through an email entry. Country defaults to CY. Imports use the same country codes and are add-only, server-previewed, and revision-checked. Resolve external identities exactly before remote writes; never guess accounts from similar names. 10. Scheduling contacts never gain login or workspace access through an email entry. At SSO sign-in an email may bind an existing contact in a workspace the user already belongs to (the contact ID is replaced by the user ID and kept as an alias); this identifies a person but never grants access, and ambiguous matches are skipped. SSO identities are never linked to existing password accounts automatically. Country defaults to CY. Imports use the same country codes and are add-only, server-previewed, and revision-checked. Resolve external identities exactly before remote writes; never guess accounts from similar names.
## Required workflow ## Required workflow
Implement a small coherent change; add meaningful regression tests for scheduling, authorization, workflow, or reconciliation behavior it changes. Run `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test`, and the PostgreSQL integration suite described in `docs/development.md`. Inspect the actual browser for UI work. Do not add tests that only mirror constants or formatting. Implement a small coherent change; add meaningful regression tests for scheduling, authorization, workflow, or reconciliation behavior it changes. Run `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test`, and the PostgreSQL integration suite described in `docs/development.md`. Inspect the actual browser for UI work. Do not add tests that only mirror constants or formatting.
Do not silently skip unavailable checks: record the limitation. Keep `Cargo.lock` committed for this application. New schema changes get a new migration; never edit an applied migration. Do not commit credentials, local database files, build output, or `opsgenie-master/`. Do not silently skip unavailable checks: record the limitation. Keep `Cargo.lock` committed for this application. New schema changes get a new migration; never edit an applied migration. Instance configuration is declarative and owns workspaces and group mappings; never add a UI or API that creates workspaces or edits those mappings. The OIDC client secret and bootstrap passwords never enter configuration files, API responses, audit or logs other than the single documented bootstrap log line. Do not commit credentials, local database files, build output, or `opsgenie-master/`.
The ignored Terraform directory is reference material only. Store anonymized behavioral examples and tests in the repository so other agents do not depend on it. Preserve its business patterns, not its historical workarounds (manual DST tables, cached remote on-call lookup, multiple Terraform applies). The ignored Terraform directory is reference material only. Store anonymized behavioral examples and tests in the repository so other agents do not depend on it. Preserve its business patterns, not its historical workarounds (manual DST tables, cached remote on-call lookup, multiple Terraform applies).
Generated
+461 -10
View File
@@ -261,12 +261,24 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "base16ct"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]] [[package]]
name = "base64ct" name = "base64ct"
version = "1.8.3" version = "1.8.3"
@@ -455,6 +467,12 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]] [[package]]
name = "constant_time_eq" name = "constant_time_eq"
version = "0.4.2" version = "0.4.2"
@@ -651,6 +669,18 @@ version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "crypto-bigint"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.1.7" version = "0.1.7"
@@ -700,6 +730,33 @@ dependencies = [
"cmov", "cmov",
] ]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"curve25519-dalek-derive",
"digest 0.10.7",
"fiat-crypto",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "darling" name = "darling"
version = "0.20.11" version = "0.20.11"
@@ -769,6 +826,17 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"pem-rfc7468",
"zeroize",
]
[[package]] [[package]]
name = "deranged" name = "deranged"
version = "0.5.8" version = "0.5.8"
@@ -839,6 +907,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer 0.10.4", "block-buffer 0.10.4",
"const-oid",
"crypto-common 0.1.7", "crypto-common 0.1.7",
"subtle", "subtle",
] ]
@@ -871,6 +940,44 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "ecdsa"
version = "0.16.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [
"der",
"digest 0.10.7",
"elliptic-curve",
"rfc6979",
"signature",
"spki",
]
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8",
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"serde",
"sha2 0.10.9",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "either" name = "either"
version = "1.18.0" version = "1.18.0"
@@ -880,6 +987,27 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "elliptic-curve"
version = "0.13.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [
"base16ct",
"crypto-bigint",
"digest 0.10.7",
"ff",
"generic-array",
"group",
"hkdf 0.12.4",
"pem-rfc7468",
"pkcs8",
"rand_core 0.6.4",
"sec1",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "email_address" name = "email_address"
version = "0.2.9" version = "0.2.9"
@@ -940,6 +1068,22 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "ff"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
dependencies = [
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.13" version = "0.1.13"
@@ -1083,6 +1227,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [ dependencies = [
"typenum", "typenum",
"version_check", "version_check",
"zeroize",
] ]
[[package]] [[package]]
@@ -1143,6 +1288,17 @@ dependencies = [
"phf 0.11.3", "phf 0.11.3",
] ]
[[package]]
name = "group"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
dependencies = [
"ff",
"rand_core 0.6.4",
"subtle",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.14.5" version = "0.14.5"
@@ -1191,13 +1347,31 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hkdf"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
dependencies = [
"hmac 0.12.1",
]
[[package]] [[package]]
name = "hkdf" name = "hkdf"
version = "0.13.0" version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"
dependencies = [ dependencies = [
"hmac", "hmac 0.13.0",
]
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest 0.10.7",
] ]
[[package]] [[package]]
@@ -1312,7 +1486,7 @@ version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-util", "futures-util",
@@ -1502,6 +1676,30 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "jsonwebtoken"
version = "11.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1"
dependencies = [
"base64 0.22.1",
"ed25519-dalek",
"getrandom 0.2.17",
"hmac 0.12.1",
"js-sys",
"p256",
"p384",
"pem",
"rand 0.8.8",
"rsa",
"serde",
"serde_json",
"sha2 0.10.9",
"signature",
"simple_asn1",
"zeroize",
]
[[package]] [[package]]
name = "lasso" name = "lasso"
version = "0.7.3" version = "0.7.3"
@@ -1516,6 +1714,9 @@ name = "lazy_static"
version = "1.5.0" version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
dependencies = [
"spin",
]
[[package]] [[package]]
name = "libc" name = "libc"
@@ -1523,6 +1724,12 @@ version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]] [[package]]
name = "libsqlite3-sys" name = "libsqlite3-sys"
version = "0.30.1" version = "0.30.1"
@@ -1587,18 +1794,22 @@ dependencies = [
[[package]] [[package]]
name = "meguri" name = "meguri"
version = "0.1.0" version = "0.3.0"
dependencies = [ dependencies = [
"argon2", "argon2",
"base64 0.23.1",
"chrono", "chrono",
"chrono-tz", "chrono-tz",
"cot", "cot",
"csv", "csv",
"email_address", "email_address",
"hmac 0.12.1",
"http-body-util", "http-body-util",
"jsonwebtoken",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml_ng",
"sha2 0.10.9", "sha2 0.10.9",
"thiserror", "thiserror",
"tokio", "tokio",
@@ -1704,12 +1915,57 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-bigint-dig"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
dependencies = [
"lazy_static",
"libm",
"num-integer",
"num-iter",
"num-traits",
"rand 0.8.8",
"smallvec",
"zeroize",
]
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.2" version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
dependencies = [
"num-integer",
"num-traits",
]
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -1717,6 +1973,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [ dependencies = [
"autocfg", "autocfg",
"libm",
] ]
[[package]] [[package]]
@@ -1740,6 +1997,30 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "p256"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
dependencies = [
"ecdsa",
"elliptic-curve",
"primeorder",
"sha2 0.10.9",
]
[[package]]
name = "p384"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
dependencies = [
"ecdsa",
"elliptic-curve",
"primeorder",
"sha2 0.10.9",
]
[[package]] [[package]]
name = "parking" name = "parking"
version = "2.2.1" version = "2.2.1"
@@ -1792,6 +2073,25 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "pem"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64 0.22.1",
"serde_core",
]
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
dependencies = [
"base64ct",
]
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
@@ -1864,6 +2164,27 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkcs1"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
dependencies = [
"der",
"pkcs8",
"spki",
]
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[package]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.34" version = "0.3.34"
@@ -1894,6 +2215,15 @@ dependencies = [
"zerocopy", "zerocopy",
] ]
[[package]]
name = "primeorder"
version = "0.13.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
dependencies = [
"elliptic-curve",
]
[[package]] [[package]]
name = "proc-macro-crate" name = "proc-macro-crate"
version = "3.5.0" version = "3.5.0"
@@ -1995,6 +2325,8 @@ version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
dependencies = [ dependencies = [
"libc",
"rand_chacha 0.3.1",
"rand_core 0.6.4", "rand_core 0.6.4",
] ]
@@ -2004,7 +2336,7 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [ dependencies = [
"rand_chacha", "rand_chacha 0.9.0",
"rand_core 0.9.5", "rand_core 0.9.5",
] ]
@@ -2019,6 +2351,16 @@ dependencies = [
"rand_core 0.10.1", "rand_core 0.10.1",
] ]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core 0.6.4",
]
[[package]] [[package]]
name = "rand_chacha" name = "rand_chacha"
version = "0.9.0" version = "0.9.0"
@@ -2094,7 +2436,7 @@ version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"bytes", "bytes",
"futures-core", "futures-core",
"http", "http",
@@ -2126,6 +2468,16 @@ dependencies = [
"webpki-roots", "webpki-roots",
] ]
[[package]]
name = "rfc6979"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
dependencies = [
"hmac 0.12.1",
"subtle",
]
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@@ -2140,6 +2492,26 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "rsa"
version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
dependencies = [
"const-oid",
"digest 0.10.7",
"num-bigint-dig",
"num-integer",
"num-traits",
"pkcs1",
"pkcs8",
"rand_core 0.6.4",
"signature",
"spki",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "rustc-demangle" name = "rustc-demangle"
version = "0.1.28" version = "0.1.28"
@@ -2233,6 +2605,20 @@ dependencies = [
"sqlx", "sqlx",
] ]
[[package]]
name = "sec1"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [
"base16ct",
"der",
"generic-array",
"pkcs8",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "securer-string" name = "securer-string"
version = "0.1.3" version = "0.1.3"
@@ -2336,6 +2722,19 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "serde_yaml_ng"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f"
dependencies = [
"indexmap",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]] [[package]]
name = "sha1" name = "sha1"
version = "0.11.0" version = "0.11.0"
@@ -2394,12 +2793,34 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest 0.10.7",
"rand_core 0.6.4",
]
[[package]] [[package]]
name = "simdutf8" name = "simdutf8"
version = "0.1.5" version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "simple_asn1"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
dependencies = [
"num-bigint",
"num-traits",
"thiserror",
"time",
]
[[package]] [[package]]
name = "siphasher" name = "siphasher"
version = "1.0.3" version = "1.0.3"
@@ -2440,6 +2861,16 @@ dependencies = [
"lock_api", "lock_api",
] ]
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]] [[package]]
name = "sqlx" name = "sqlx"
version = "0.9.0" version = "0.9.0"
@@ -2459,7 +2890,7 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"bytes", "bytes",
"cfg-if", "cfg-if",
"chrono", "chrono",
@@ -2560,7 +2991,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e"
dependencies = [ dependencies = [
"atoi", "atoi",
"base64", "base64 0.22.1",
"bitflags", "bitflags",
"byteorder", "byteorder",
"chrono", "chrono",
@@ -2571,8 +3002,8 @@ dependencies = [
"futures-core", "futures-core",
"futures-util", "futures-util",
"hex", "hex",
"hkdf", "hkdf 0.13.0",
"hmac", "hmac 0.13.0",
"itoa", "itoa",
"log", "log",
"md-5", "md-5",
@@ -2940,7 +3371,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "568531ec3dfcf3ffe493de1958ae5662a0284ac5d767476ecdb6a34ff8c6b06c" checksum = "568531ec3dfcf3ffe493de1958ae5662a0284ac5d767476ecdb6a34ff8c6b06c"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64", "base64 0.22.1",
"futures", "futures",
"http", "http",
"parking_lot", "parking_lot",
@@ -3084,6 +3515,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"
@@ -3490,6 +3927,20 @@ name = "zeroize"
version = "1.9.0" version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
+5 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "meguri" name = "meguri"
version = "0.1.0" version = "0.3.0"
edition = "2024" edition = "2024"
rust-version = "1.97" rust-version = "1.97"
description = "Provider-independent on-call scheduling and configuration control plane" description = "Provider-independent on-call scheduling and configuration control plane"
@@ -23,6 +23,10 @@ tower-http = { version = "0.6", features = ["trace", "limit"] }
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1.23", features = ["v4", "serde"] } uuid = { version = "1.23", features = ["v4", "serde"] }
serde_yaml_ng = "0.10.0"
hmac = "0.12"
base64 = "0.23.1"
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["rust_crypto", "use_pem"] }
[dev-dependencies] [dev-dependencies]
http-body-util = "0.1" http-body-util = "0.1"
+19 -3
View File
@@ -1,15 +1,31 @@
# syntax=docker/dockerfile:1
# Build: docker build -t meguri .
# Run: docker run -p 3000:3000 \
# -e DATABASE_URL=postgresql://USER:PASSWORD@HOST:5432/meguri \
# -e MEGURI_PUBLIC_URL=https://meguri.example.internal \
# -e MEGURI_OIDC_CLIENT_SECRET=... \
# -v ./instance-config.yaml:/etc/meguri/config.yaml:ro meguri
# The instance configuration (workspaces, group mappings, sign-in) is mounted, never baked in.
# See deploy/instance-config.example.yaml and docs/deployment.md.
FROM rust:1.97-bookworm AS build FROM rust:1.97-bookworm AS build
WORKDIR /build WORKDIR /build
# Compile dependencies in their own layer so source edits do not rebuild them.
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo 'fn main() {}' > src/main.rs && touch src/lib.rs \
&& cargo build --locked --release && rm -rf src
COPY src ./src COPY src ./src
COPY static ./static COPY static ./static
RUN cargo build --locked --release RUN touch src/main.rs src/lib.rs && cargo build --locked --release
FROM debian:bookworm-slim FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /build/target/release/meguri /usr/local/bin/meguri COPY --from=build /build/target/release/meguri /usr/local/bin/meguri
COPY static/vendor/ALPINE-LICENSE.md /usr/share/licenses/meguri/alpine-MIT.md COPY static/vendor/ALPINE-LICENSE.md /usr/share/licenses/meguri/alpine-MIT.md
COPY static/vendor/cytoscape.LICENSE /usr/share/licenses/meguri/cytoscape-MIT.md
USER 65532:65532 USER 65532:65532
ENV MEGURI_BIND=0.0.0.0:3000 ENV MEGURI_BIND=0.0.0.0:3000 \
MEGURI_CONFIG_FILE=/etc/meguri/config.yaml
EXPOSE 3000 EXPOSE 3000
ENTRYPOINT ["/usr/local/bin/meguri"] ENTRYPOINT ["/usr/local/bin/meguri"]
+7 -7
View File
@@ -10,13 +10,13 @@ On Windows with the PostgreSQL server binaries installed:
./scripts/dev.ps1 ./scripts/dev.ps1
``` ```
Open http://127.0.0.1:3000. Explicit demo accounts: `alice`, `bob`, `carol`; password `meguri-demo-only`. Stop with `./scripts/stop-dev.ps1`. Pass `-PostgresBin PATH` if PostgreSQL is elsewhere. Local data and logs live in ignored `target/local/`. Open http://127.0.0.1:3000. The script starts disposable demo mode: accounts `admin` (instance administrator), `alice` (administrator of the demo workspaces) and `bob`, `carol`, `dave`, `erin` (employees) share one password that is generated on every start and shown on the login screen and in `target/local/app.log`. Stop with `./scripts/stop-dev.ps1`. Pass `-PostgresBin PATH` if PostgreSQL is elsewhere. Local data and logs live in ignored `target/local/`.
On other platforms, create a PostgreSQL database, set the environment variables in `.env.example`, then run `cargo run --locked`. Use `postgresql://` URLs. Enable `MEGURI_ALLOW_SIGNUP=true` to create accounts; `MEGURI_SEED_DEMO=true` works only on loopback. Environment files are not automatically loaded. On other platforms, create a PostgreSQL database, set the environment variables in `.env.example`, then run `cargo run --locked`. Use `postgresql://` URLs. `MEGURI_SEED_DEMO=true` starts the disposable demo and works only on loopback. Any other run needs an instance configuration file (`MEGURI_CONFIG_FILE`, see [`deploy/instance-config.example.yaml`](deploy/instance-config.example.yaml)); without it startup fails. Environment files are not automatically loaded.
## Implemented ## Implemented
- Independent workspaces, local accounts, granular workspace grants, and durable audit/history. - Independent workspaces declared in an instance configuration file, OpenID Connect sign-in with group-to-role mapping (instance administrator, workspace administrator, employee), optional password and bootstrap accounts, and durable audit/history.
- Calendar-day rotations with IANA timezones, ordered participants, anchors, absence fallback, holidays, and escalation backup schedules. - Calendar-day rotations with IANA timezones, ordered participants, anchors, absence fallback, holidays, and escalation backup schedules.
- Replacements and atomic reciprocal swaps; confirmation by the other employee or forced application with a reason. - Replacements and atomic reciprocal swaps; confirmation by the other employee or forced application with a reason.
- Durable publication, optimistic revisions, worker leases, retries, checkpoints, external drift protection, and a downloadable preview. - Durable publication, optimistic revisions, worker leases, retries, checkpoints, external drift protection, and a downloadable preview.
@@ -30,15 +30,15 @@ Start with [AGENTS.md](AGENTS.md). Describe intended behavior and examples to th
cargo fmt --check cargo fmt --check
cargo clippy --all-targets -- -D warnings cargo clippy --all-targets -- -D warnings
cargo test cargo test
cargo test --test integration -- --ignored --test-threads=1 cargo test --locked --tests -- --ignored --test-threads=1
``` ```
The integration command requires `MEGURI_TEST_DATABASE_URL` for a disposable PostgreSQL database. CI runs it explicitly; ordinary unit tests need no database or external account. Browser checks are in `tests/browser.cjs`. The ignored suites require `MEGURI_TEST_DATABASE_URL` for a disposable PostgreSQL database whose user may create databases. CI runs them explicitly; ordinary unit tests need no database or external account. Browser checks (`npm run test:browser`) run against a freshly started demo.
## Current boundaries ## Current boundaries
OIDC identity linking has a schema boundary but no login adapter yet. JSM has local HTTP contract coverage and still needs acceptance testing against a dedicated JSM test team and your subscription. GKE manifests are deployment templates, not an already deployed environment. No alert ingestion or notification engine is included. Sign-in is OpenID Connect (code flow with PKCE) and was tested against a fake provider only; verify your Keycloak client and group mapper as described in [deployment](docs/deployment.md#single-sign-on-with-keycloak) before relying on it. There is no self-registration and no in-app workspace creation. JSM has local HTTP contract coverage and still needs acceptance testing against a dedicated JSM test team and your subscription. GKE manifests are deployment templates, not an already deployed environment. No alert ingestion or notification engine is included.
The ignored `opsgenie-master/` directory is local reference material. Its behavioral examples are captured in the product contract and tests; application and CI do not depend on it. The ignored `opsgenie-master/` directory is local reference material. Its behavioral examples are captured in the product contract and tests; application and CI do not depend on it.
Demo mode is disposable: `MEGURI_SEED_DEMO=true` resets only positively marked demo data on restart and expires temporary resources while running. Unmarked/normal databases are protected and trigger an isolated demo database with a permanent warning. See [demo lifecycle and retention settings](docs/development.md#demo-lifecycle). Alice leads two generated examples; Bob demonstrates Engineering on-call, Carol daily Support shifts. All use `meguri-demo-only`. Demo mode is disposable: `MEGURI_SEED_DEMO=true` resets only positively marked demo data on restart and expires temporary resources while running. Unmarked/normal databases are protected and trigger an isolated demo database with a permanent warning. See [demo lifecycle and retention settings](docs/development.md#demo-lifecycle). Alice administers four generated examples; Bob demonstrates Engineering on-call, Carol daily Support shifts.
+75 -1
View File
@@ -9,6 +9,71 @@ metadata:
--- ---
apiVersion: v1 apiVersion: v1
kind: ConfigMap kind: ConfigMap
metadata:
name: meguri-instance-config
data:
# Declarative instance configuration (see deploy/instance-config.example.yaml for the commented
# reference and docs/deployment.md). Replace the placeholders. The migrate Job synchronizes it;
# replicas only read it. Change it, run the migrate Job for the release, then restart replicas.
config.yaml: |
auth:
# Lets ordinary password accounts (created by an instance administrator) sign in.
# Keep false in production where everyone signs in through SSO.
password_login: false
# Break-glass instance administrators. Omit this whole block in production; add it only to get
# back in when SSO is broken, then remove it again. While present, the generated password is
# written to the log of the process that synchronized the configuration (the migrate Job), and
# anyone who can read that log can sign in as an instance administrator. The password is derived
# from a database-held secret, the username and the current UTC hour; it rotates every hour.
# bootstrap_admin:
# usernames: [rescue]
# OpenID Connect provider. Confidential client, authorization-code flow with PKCE.
# Redirect URI to register: {MEGURI_PUBLIC_URL}/api/oidc/callback
oidc:
issuer: https://sso.example.internal/realms/corp # HTTPS; discovery is read from <issuer>/.well-known/openid-configuration
client_id: meguri
scopes: [openid, profile, email] # default; must include openid; email links people planned by email
# Where the groups are. A dotted path into the token claims (for example realm_access.roles).
# Keycloak: add a "Group Membership" mapper with this claim name, "Add to ID token" and
# "Add to userinfo" on. Matching below is by exact string, so "Full group path" decides
# whether values look like /payments/leads or leads.
groups_claim: groups
groups_sources: [id_token, userinfo] # searched in this order
# Groups whose members are instance administrators: they see and may act in every enabled
# workspace, manage password users and delete disabled workspaces. Not tied to any workspace.
instance_admin_groups:
- /meguri/admins
# Workspaces. `key` is the permanent identifier (3-64 characters: a-z, 0-9, hyphen); never reuse
# it for something else. `name` is unique and is what people see; changing it renames the
# workspace. A workspace that exists without a key and has exactly this name is adopted.
# Removing an entry here DISABLES the workspace (nobody can open it, nothing is published, data is
# kept); listing it again re-enables it. Deleting it for good is a manual action on the
# instance administrator's page.
workspaces:
- key: payments
name: Payments
# Workspace administrators (team leads): every permission in this workspace only. At least one group.
admin_groups:
- /payments/leads
# Employees: the workspace's employee permission set, by default calendar, schedule editing
# and replacement requests. Administrators change that set in the workspace settings.
member_groups:
- /payments/team
- key: customer-support
name: Customer Support
admin_groups:
- /support/leads
member_groups:
- /support/team
- /support/contractors
---
apiVersion: v1
kind: ConfigMap
metadata: metadata:
name: meguri-config name: meguri-config
data: data:
@@ -16,7 +81,7 @@ data:
MEGURI_PUBLIC_URL: https://meguri.example.internal MEGURI_PUBLIC_URL: https://meguri.example.internal
MEGURI_ROLE: all MEGURI_ROLE: all
MEGURI_SKIP_MIGRATIONS: "true" MEGURI_SKIP_MIGRATIONS: "true"
MEGURI_ALLOW_SIGNUP: "false" MEGURI_CONFIG_FILE: /etc/meguri/config.yaml
MEGURI_SEED_DEMO: "false" MEGURI_SEED_DEMO: "false"
RUST_LOG: meguri=info,cot=warn RUST_LOG: meguri=info,cot=warn
--- ---
@@ -52,6 +117,9 @@ spec:
httpGet: {path: /startup, port: 9090} httpGet: {path: /startup, port: 9090}
periodSeconds: 2 periodSeconds: 2
failureThreshold: 30 failureThreshold: 30
volumes:
- name: instance-config
configMap: {name: meguri-instance-config}
containers: containers:
- name: meguri - name: meguri
image: REGION-docker.pkg.dev/PROJECT_ID/REPOSITORY/meguri:RELEASE image: REGION-docker.pkg.dev/PROJECT_ID/REPOSITORY/meguri:RELEASE
@@ -59,6 +127,12 @@ spec:
envFrom: envFrom:
- configMapRef: {name: meguri-config} - configMapRef: {name: meguri-config}
- secretRef: {name: meguri-secrets} - secretRef: {name: meguri-secrets}
env:
- name: MEGURI_OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef: {name: meguri-secrets, key: MEGURI_OIDC_CLIENT_SECRET, optional: true}
volumeMounts:
- {name: instance-config, mountPath: /etc/meguri, readOnly: true}
securityContext: securityContext:
readOnlyRootFilesystem: true readOnlyRootFilesystem: true
allowPrivilegeEscalation: false allowPrivilegeEscalation: false
+63
View File
@@ -0,0 +1,63 @@
# Meguri instance configuration. The process refuses to start without it (MEGURI_CONFIG_FILE).
# This file is the only place that defines workspaces and which identity-provider groups give
# which role; there is no UI or API for either. The OIDC client secret is NOT stored here: set
# MEGURI_OIDC_CLIENT_SECRET in the deployment secret store.
#
# Applied by the migrate Job (or a single process without MEGURI_SKIP_MIGRATIONS) at every release.
# Unknown fields and invalid values are rejected at startup.
auth:
# Lets ordinary password accounts (created by an instance administrator) sign in.
# Keep false in production where everyone signs in through SSO.
password_login: false
# Break-glass instance administrators. Omit this whole block in production; add it only to get
# back in when SSO is broken, then remove it again. While present, the generated password is
# written to the log of the process that synchronized the configuration (the migrate Job), and
# anyone who can read that log can sign in as an instance administrator. The password is derived
# from a database-held secret, the username and the current UTC hour; it rotates every hour.
# bootstrap_admin:
# usernames: [rescue]
# OpenID Connect provider. Confidential client, authorization-code flow with PKCE.
# Redirect URI to register: {MEGURI_PUBLIC_URL}/api/oidc/callback
oidc:
issuer: https://sso.example.internal/realms/corp # HTTPS; discovery is read from <issuer>/.well-known/openid-configuration
client_id: meguri
scopes: [openid, profile, email] # default; must include openid; email links people planned by email
# Where the groups are. A dotted path into the token claims (for example realm_access.roles).
# Keycloak: add a "Group Membership" mapper with this claim name, "Add to ID token" and
# "Add to userinfo" on. Matching below is by exact string, so "Full group path" decides
# whether values look like /payments/leads or leads.
groups_claim: groups
groups_sources: [id_token, userinfo] # searched in this order
# Groups whose members are instance administrators: they see and may act in every enabled
# workspace, manage password users and delete disabled workspaces. Not tied to any workspace.
instance_admin_groups:
- /meguri/admins
# Workspaces. `key` is the permanent identifier (3-64 characters: a-z, 0-9, hyphen); never reuse
# it for something else. `name` is unique and is what people see; changing it renames the
# workspace. A workspace that exists without a key and has exactly this name is adopted.
# Removing an entry here DISABLES the workspace (nobody can open it, nothing is published, data is
# kept); listing it again re-enables it. Deleting it for good is a manual action on the
# instance administrator's page.
workspaces:
- key: payments
name: Payments
# Workspace administrators (team leads): every permission in this workspace only. At least one group.
admin_groups:
- /payments/leads
# Employees: the workspace's employee permission set, by default calendar, schedule editing
# and replacement requests. Administrators change that set in the workspace settings.
member_groups:
- /payments/team
- key: customer-support
name: Customer Support
admin_groups:
- /support/leads
member_groups:
- /support/team
- /support/contractors
+8
View File
@@ -28,6 +28,9 @@ spec:
httpGet: {path: /startup, port: 9090} httpGet: {path: /startup, port: 9090}
periodSeconds: 2 periodSeconds: 2
failureThreshold: 30 failureThreshold: 30
volumes:
- name: instance-config
configMap: {name: meguri-instance-config}
containers: containers:
- name: migrate - name: migrate
image: REGION-docker.pkg.dev/PROJECT_ID/REPOSITORY/meguri:RELEASE image: REGION-docker.pkg.dev/PROJECT_ID/REPOSITORY/meguri:RELEASE
@@ -37,6 +40,11 @@ spec:
env: env:
- {name: MEGURI_ROLE, value: migrate} - {name: MEGURI_ROLE, value: migrate}
- {name: MEGURI_SKIP_MIGRATIONS, value: "false"} - {name: MEGURI_SKIP_MIGRATIONS, value: "false"}
- name: MEGURI_OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef: {name: meguri-secrets, key: MEGURI_OIDC_CLIENT_SECRET, optional: true}
volumeMounts:
- {name: instance-config, mountPath: /etc/meguri, readOnly: true}
securityContext: securityContext:
readOnlyRootFilesystem: true readOnlyRootFilesystem: true
allowPrivilegeEscalation: false allowPrivilegeEscalation: false
+19 -13
View File
@@ -6,13 +6,13 @@ A modular Rust application serves the web/API and runs a durable publication wor
## Persistence and concurrency ## Persistence and concurrency
Users, sessions, workspaces, memberships, audit, history, provider checkpoints, and sync jobs are Cot ORM models. Bounded typed documents use serialized JSON in text columns with a PostgreSQL JSON validity constraint and optimistic revision. Cot 0.7 lacks JSON/UUID model fields, so UUIDs also use validated strings. Do not introduce another ORM for these types. Users, sessions, external identities, workspaces, memberships, audit, history, provider checkpoints, sync jobs, OIDC flows, instance state and instance audit are Cot ORM models. Bounded typed documents use serialized JSON in text columns with a PostgreSQL JSON validity constraint and optimistic revision. Cot 0.7 lacks JSON/UUID model fields, so UUIDs also use validated strings. Do not introduce another ORM for these types.
Cot 0.7 has no transaction API. `Store::commit` uses one parameterized data-modifying PostgreSQL CTE through Cot `Database::raw_with`: compare-and-swap checks revision and authorization, then writes membership, history, audit, and the durable sync request atomically. A constraint failure rolls the entire statement back. Ordinary reads, sessions, and checkpoints use Cot queries/models. Pending requests do not change effective coverage. Published state is separate from desired state. Cot 0.7 has no transaction API. `Store::commit` uses one parameterized data-modifying PostgreSQL CTE through Cot `Database::raw_with`: compare-and-swap checks revision and authorization (a workspace grant, or instance administrator), then writes employee-role grants when they changed, history, audit, and the durable sync request atomically. `Store::system_commit` is the same statement without a grant check for configuration sync and identity linking; it never takes request input directly. A constraint failure rolls the entire statement back. Ordinary reads, sessions, and checkpoints use Cot queries/models. Pending requests do not change effective coverage. Published state is separate from desired state.
The worker conditionally claims a five-minute lease token. Publication has a four-minute deadline and HTTP requests a twenty-second timeout; no document lock spans HTTP. Verified operations get checkpoints. Completion updates status and audit atomically, guarded by the lease token. Newer revisions remain staged when older runs succeed or fail. Authorized jobs retry their pinned document with bounded backoff; a successful publication runs again only after another explicit Sync. Crashed workers leave recoverable leases. This is not a distributed fencing protocol: read-back/conflict handling remains essential after process suspension or an uncertain remote timeout. The worker conditionally claims a five-minute lease token. Publication has a four-minute deadline and HTTP requests a twenty-second timeout; no document lock spans HTTP. Verified operations get checkpoints. Completion updates status and audit atomically, guarded by the lease token. Newer revisions remain staged when older runs succeed or fail. Authorized jobs retry their pinned document with bounded backoff; a successful publication runs again only after another explicit Sync. Crashed workers leave recoverable leases. This is not a distributed fencing protocol: read-back/conflict handling remains essential after process suspension or an uncertain remote timeout.
Run Cot migrations once in a deployment job, then start replicas with `MEGURI_SKIP_MIGRATIONS=true`. The initial migration creates frozen Cot models; the second adds scoped foreign keys, constraints, and indexes in one atomic PostgreSQL block. A JSM destination can have only one local workspace binding. Never edit applied migrations. Run Cot migrations once in a deployment job, then start replicas with `MEGURI_SKIP_MIGRATIONS=true`. The process that owns migrations (the `migrate` role, or a single `all` process without the skip flag) also synchronizes the instance configuration; replicas only read it. The initial migration creates frozen Cot models; the second adds scoped foreign keys, constraints, and indexes in one atomic PostgreSQL block. Migration 9 makes passwords optional and adds user email, groups, instance-administrator and bootstrap flags, workspace key and disabled timestamp, a unique `(issuer, subject)` index, `instance_state`, `instance_audit` and `oidc_flows`. A JSM destination can have only one local workspace binding. Never edit applied migrations.
## Scheduling model ## Scheduling model
@@ -28,7 +28,17 @@ The engine receives explicit state and time bounds. It must produce the same res
## Identity and permissions ## Identity and permissions
Session cookies are HttpOnly, SameSite=Lax, and Secure under HTTPS. Mutations require same-origin requests and a session CSRF token. Passwords use Argon2. Resource lookup always includes workspace context and server-side membership. External identity linking is a future adapter, never a replacement for internal user IDs. Session cookies are HttpOnly, SameSite=Lax, and Secure under HTTPS; a session lasts 12 hours. Mutations require same-origin requests and a session CSRF token. Passwords use Argon2. Resource lookup always includes workspace context and server-side membership; instance administrators pass the permission check through the `users.instance_admin` flag, in `Store::permissions`/`owner` and inside the commit statement itself. Disabled and demo-expired workspaces are not found by `Store::workspace`. External identities bind `(issuer, subject)` to an internal user ID and never replace it.
### Instance access modules
- `access` is pure: configuration types and strict validation, `resolve_access(config, groups, bootstrap)` (exact group match, administrator beats employee), claim extraction with literal and dotted paths, and bootstrap password derivation (HMAC-SHA256 over `meguri-bootstrap:{username}:{UTC YYYYMMDDHH}` with a database-held secret, first 20 bytes base32). The loaded configuration is installed process-wide for request handlers; service functions take it as an argument.
- `identity` owns every write that turns configuration or provider data into state: `sync_config`, `ensure_workspace`, `apply_access`, bootstrap accounts, `sso_sign_in` and `link_contacts`. Cot has no transactions, so each step is an idempotent statement and concurrent replicas converge; `preflight` rejects configuration-only problems (listing no workspaces while enabled ones exist, ambiguous adoption by name, a bootstrap name owned by an SSO account) before the first write. Sync order: workspaces (create/adopt/enable/rename), disable unlisted workspaces (their sync job and memberships are removed in the same statement), bootstrap accounts, then `reconcile_all`.
- `apply_access` is the single place that turns groups into access. One CTE stores the user's groups and instance-administrator flag and rewrites their `workspace_members` rows: rows outside the desired set are deleted, administrators get every permission, employees get the workspace document's `member_permissions` read in the same statement. SSO sign-in, the administrator's password-user editor and startup reconciliation all end here. Membership rows are a materialized view of groups; nothing grants a row any other way.
- `oidc` implements discovery (cached ten minutes, issuer must match), JWKS (cached, refreshed at most every 15 seconds for an unknown `kid`), PKCE, the one-time `oidc_flows` record, code exchange with client-secret basic authentication, and RS256 token verification (signature, issuer, audience, expiry, nonce, authorized party). It returns a trusted profile; `identity::sso_sign_in` decides what the profile may do.
- `admin` implements the instance administrator's operations (overview, password users, group edits, password reset, workspace deletion) and writes `instance_audit`. Every function re-checks `instance_admin`; the web layer only routes.
The employee permission set lives in `WorkspaceDocument.member_permissions` (default `view`, `edit_schedules`, `request_overrides`). Changing it is an ordinary revision-checked commit that also updates every employee membership row, so the set applies at once. Because a commit's authorization is evaluated in SQL against `workspace_members`, those rows hold the effective permissions.
Replacement preview is a read-only service using the same engine, revision, participant and permission checks as submission. Calendar actions use server-generated interval boundaries; inclusive range inputs are converted through schedule handovers on the server. Volunteering changes the initiator, not the approval requirements. Comments remain in the backward-compatible reason/force_reason fields but are optional action annotations. Current-shift clipping uses one explicit server timestamp per validation/application. Replacement preview is a read-only service using the same engine, revision, participant and permission checks as submission. Calendar actions use server-generated interval boundaries; inclusive range inputs are converted through schedule handovers on the server. Volunteering changes the initiator, not the approval requirements. Comments remain in the backward-compatible reason/force_reason fields but are optional action annotations. Current-shift clipping uses one explicit server timestamp per validation/application.
@@ -58,9 +68,9 @@ Random recipients and escalation repeats require JSM Premium/Enterprise. Provide
JSM documents `repeat.waitInterval=0` as disabling repeats even when count is nonzero; the reference Terraform uses this combination. Enabling repeats requires a positive interval. A disabled local count maps to provider count 1 / interval 0 because the provider count minimum is 1. The reference's immediate step uses delay 0; verify it in live acceptance because the published schema lists a minimum of 1. Never silently change immediate notification to a one-minute delay. JSM documents `repeat.waitInterval=0` as disabling repeats even when count is nonzero; the reference Terraform uses this combination. Enabling repeats requires a positive interval. A disabled local count maps to provider count 1 / interval 0 because the provider count minimum is 1. The reference's immediate step uses delay 0; verify it in live acceptance because the published schema lists a minimum of 1. Never silently change immediate notification to a one-minute delay.
## Scheduling metadata and import boundaries ## Scheduling metadata and import boundaries
WorkspaceDocument owns versioned scheduling people, country holidays, and per-schedule handovers. Contacts do not become authentication principals. Workspace-directory reads are workspace-scoped; identity IDs cannot be invented or linked through unverified email. CSV parsing and comparison are pure functions; preview is read-only, and import rechecks the workspace revision and commits history, audit, and outbox together. Working intervals, variables, schedules and access updates share the same workspace revision; working-interval changes atomically update its route snapshots. WorkspaceDocument owns versioned scheduling people, country holidays, and per-schedule handovers. Contacts do not become authentication principals. Workspace-directory reads are workspace-scoped; identity IDs cannot be invented. At SSO sign-in `identity::link_contacts` merges a contact whose email equals the signed-in user's into that user (the contact ID is rewritten across the current document by exact string replacement, kept as `Person.aliases`, and resolved by audit and the UI for historical snapshots) through `system_commit` with `person.linked` audit, only in workspaces where the user is already a member; linking never creates access. CSV parsing and comparison are pure functions; preview is read-only, and import rechecks the workspace revision and commits history, audit, and outbox together. Working intervals, variables, schedules and access updates share the same workspace revision; working-interval changes atomically update its route snapshots.
Workspace creation atomically creates its document, owner membership, initial history, audit and sync request. There is no parent entity or hidden default team. Migration 8 replaces empty legacy tenant/team tables and refuses populated legacy databases; demo provenance checks run before migration. Workspaces are created only by configuration sync, which atomically creates the document, initial history, audit and sync request with its configured `key` and no members; memberships appear when users are reconciled. There is no parent entity or hidden default team. Migration 8 replaces empty legacy tenant/team tables and refuses populated legacy databases; demo provenance checks run before migration.
Migration 3 upgrades old JSON snapshots without changing old handover boundaries or global-holiday meaning and introduces workspace working intervals. Migration 4 adds user country (CY) and an optional display timezone (null follows the browser). Current scheduling state copies the workspace timezone; historical documents retain the timezone effective at their revision. Country-profile changes propagate as dated workspace revisions, while display-timezone preferences remain outside the engine. Migration 3 upgrades old JSON snapshots without changing old handover boundaries or global-holiday meaning and introduces workspace working intervals. Migration 4 adds user country (CY) and an optional display timezone (null follows the browser). Current scheduling state copies the workspace timezone; historical documents retain the timezone effective at their revision. Country-profile changes propagate as dated workspace revisions, while display-timezone preferences remain outside the engine.
@@ -70,7 +80,7 @@ Email-only recipients resolve through [Jira user search](https://developer.atlas
`demo_runtime` chooses the database before Cot migrations. A separate `public.meguri_runtime_guard` records last startup mode; proof and reset recheck it under a PostgreSQL advisory transaction lock. Empty-DB claiming checks user relations. Reset enumerates only Meguri application tables, uses no CASCADE, and preserves migration/provenance metadata. Distinct application names detect another active client. A protected configured DB triggers a separate database and an immutable process notice surfaced by `/api/session`; no credentials are exposed. Normal startup marks its database normal and never schedules cleanup. `demo_runtime` chooses the database before Cot migrations. A separate `public.meguri_runtime_guard` records last startup mode; proof and reset recheck it under a PostgreSQL advisory transaction lock. Empty-DB claiming checks user relations. Reset enumerates only Meguri application tables, uses no CASCADE, and preserves migration/provenance metadata. Distinct application names detect another active client. A protected configured DB triggers a separate database and an immutable process notice surfaced by `/api/session`; no credentials are exposed. Normal startup marks its database normal and never schedules cleanup.
Migration 6 adds nullable demo deletion timestamps and seed pins. Cleanup gates every write on demo provenance and uses explicit creation timestamps and a supplied clock. Workspaces expire as complete configuration aggregates, revisions invalidate in-flight edits, listings/access exclude expired resources, and a later CTE deletes dependent rows in one statement. Request expiry saves a new historical document and outbox job. History compaction retains a boundary snapshot. A minute timer refreshes expired seed workspaces through the ordinary services; only the five baseline account identities are permanent during a run. External provider writes are disabled in demo at both configuration and dispatch boundaries. Migration 6 adds nullable demo deletion timestamps and seed pins. Cleanup gates every write on demo provenance and uses explicit creation timestamps and a supplied clock. Workspaces expire as complete configuration aggregates, revisions invalidate in-flight edits, listings/access exclude expired resources, and a later CTE deletes dependent rows in one statement. Request expiry saves a new historical document and outbox job. History compaction retains a boundary snapshot. A minute timer runs the ordinary configuration sync against the built-in demo configuration (`src/demo-config.yaml`, the production format), recreates expired seed workspaces and reseeds them through the ordinary services; the six seeded accounts (`admin` bootstrap administrator, `alice`, `bob`, `carol`, `dave`, `erin`) are pinned and created through the same administrator operations as in production, with groups. They share one random password per process start, kept in the process-wide demo notice and published by `/api/session`; accounts created later by an administrator expire. External provider writes are disabled in demo at both configuration and dispatch boundaries.
## Presentation catalog ## Presentation catalog
@@ -107,7 +117,7 @@ Generation rule results use the actual authorized server expansion, including sc
## Workspace aggregate ## Workspace aggregate
`WorkspaceRow` stores one `WorkspaceDocument` and one revision. The document owns working interval, drift policy, variables, rules, schedules, people, holidays, absences, requests and provider configuration. `WorkspaceMemberRow` stores per-user grants and ownership; there is no parent entity. History, audit, jobs and checkpoints reference one workspace ID. Generator activation and working-interval updates use the ordinary atomic workspace commit. JSM team IDs identify external provider destinations, not internal teams. `WorkspaceRow` stores one `WorkspaceDocument` and one revision. The document owns working interval, drift policy, variables, rules, schedules, people, holidays, absences, requests and provider configuration. `WorkspaceMemberRow` stores a user's effective permissions and administrator flag, derived from groups; there is no parent entity. `key` links the row to the configuration and `disabled_at` hides it. History, audit, jobs and checkpoints reference one workspace ID. Generator activation and working-interval updates use the ordinary atomic workspace commit. JSM team IDs identify external provider destinations, not internal teams.
Variable list summaries render nested nodes by type. Object entries prefer a nonempty text `name` or `title`; otherwise they show a bounded key/count summary. People resolve through the scoped directory. Summaries never read a container’s null scalar slot as its content. Variable list summaries render nested nodes by type. Object entries prefer a nonempty text `name` or `title`; otherwise they show a bounded key/count summary. People resolve through the scoped directory. Summaries never read a container’s null scalar slot as its content.
@@ -179,10 +189,6 @@ Generation steps preserve backward-compatible `team_all` and `team_random` flags
## Site control plane ## Site control plane
`site` owns global authorization, section-versioned settings, account access flags and an installation audit stream. Site mutations compare a global control revision and the actor role inside a single PostgreSQL statement; workspace metadata also compares its own revision. Site permissions never bypass workspace membership. Account suspension does not mutate historical scheduling state. Authentication configuration is stored as a typed section, but is intentionally not connected to login enforcement yet. A future identity adapter will consume the section after secure OIDC execution is implemented.
Approval calculation is shared by replacement and absence preview/save. Only actual workspace account participants other than the requester are approvers; unlinked email contacts never gain login, membership or privileges. Empty approval sets apply immediately through the same validated, atomic history/audit/staged-sync boundary, without forced_by. Requests involving a contact may be submitted on its behalf with EditSchedules; changes solely between other account holders retain the force requirement. Preview exposes the exact approval set for truthful UI status. Approval calculation is shared by replacement and absence preview/save. Only actual workspace account participants other than the requester are approvers; unlinked email contacts never gain login, membership or privileges. Empty approval sets apply immediately through the same validated, atomic history/audit/staged-sync boundary, without forced_by. Requests involving a contact may be submitted on its behalf with EditSchedules; changes solely between other account holders retain the force requirement. Preview exposes the exact approval set for truthful UI status.
Account identity stores required unique normalized email in the user row. The domain User embeds its canonical Person (same ID); workspace Person records are snapshots, not independently editable account profiles. Directory, provider identity resolution and portable configuration use this email. Editing a linked person cannot change account identity; profile changes update current snapshots atomically while historical snapshots remain intact. Password signup never claims planning contacts merely by email. Account identity keeps an optional, normalized, non-unique email in the user row. The domain User embeds its canonical Person (same ID); workspace Person records are snapshots refreshed from the account on every regular commit (`store::account_people`), not independently editable profiles. Directory, provider identity resolution and portable configuration use this email. Editing a linked person cannot change account identity; profile changes update current snapshots atomically while historical snapshots remain intact. SSO sign-in binds an unlinked contact with the same email to the account (see `identity::linked_document`); nothing is claimed by email alone at registration because there is none.
The preparatory contact/account migration runs before account email enforcement on legacy databases. An explicit deployment email mapping authorizes association only in workspaces where the account is already a member. It rewrites current scheduling references, person-valued generator inputs, shared drafts and explicit provider identity keys, retaining resource IDs and old history/publication checkpoints. Missing/ambiguous mappings, conflicting provider identities or collapsed invalid schedules/replacements block the repair before writes. Already email-migrated installations are skipped. Regression coverage includes migration ordering, retry after failure, generator drafts and unchanged access/history.
+53 -6
View File
@@ -1,16 +1,63 @@
# GKE and Cloud SQL # GKE and Cloud SQL
Target: GKE, Cloud SQL for PostgreSQL 17, and Cloud SQL Auth Proxy using Workload Identity. PostgreSQL is the durable queue and state store; no Redis is required. Templates are in `deploy/`. They have not been applied to a cloud project. Target: GKE, Cloud SQL for PostgreSQL 17, and Cloud SQL Auth Proxy using Workload Identity. PostgreSQL is the durable queue and state store; no Redis is required. Plain templates are in `deploy/`; the Helm chart in `helm/meguri` (see its README) deploys the same layout and runs the migrate Job as a pre-install/pre-upgrade hook, so the steps below are what the chart automates. Neither has been applied to a cloud project.
1. Provision a private-IP Cloud SQL instance, database and application login; enable backups/PITR. Use a VPC-native GKE cluster with access to the database VPC. Configure Workload Identity and grant its Google service account Cloud SQL Client. Follow [Google's GKE connection guide](https://cloud.google.com/sql/docs/postgres/connect-kubernetes-engine). 1. Provision a private-IP Cloud SQL instance, database and application login; enable backups/PITR. Use a VPC-native GKE cluster with access to the database VPC. Configure Workload Identity and grant its Google service account Cloud SQL Client. Follow [Google's GKE connection guide](https://cloud.google.com/sql/docs/postgres/connect-kubernetes-engine).
2. Build `Dockerfile`, push your image, and replace project, region, instance, release, service-account, image, and public URL placeholders. Review/pin the proxy image for your rollout; the template uses the version in Google's documented example. Native sidecars are required. Docker/GKE deployment must be validated in your environment. 2. Build `Dockerfile`, push your image, and replace project, region, instance, release, service-account, image, and public URL placeholders. Review/pin the proxy image for your rollout; the template uses the version in Google's documented example. Native sidecars are required. Docker/GKE deployment must be validated in your environment.
3. Create the Kubernetes service account and ConfigMap from `gke.yaml`, then the `meguri-secrets` Secret through your deployment secret manager. It needs `DATABASE_URL=postgresql://USER:PERCENT_ENCODED_PASSWORD@127.0.0.1:5432/meguri`. Proxy-to-database transport is encrypted; loopback stays inside the pod. Add tenant-specific JSM `_EMAIL` / `_TOKEN` variables only for configured tenants. Never commit rendered secrets. 3. Write the instance configuration (see below; start from `deploy/instance-config.example.yaml`) into the `meguri-instance-config` ConfigMap in `gke.yaml`. Create the Kubernetes service account and ConfigMaps from `gke.yaml`, then the `meguri-secrets` Secret through your deployment secret manager. It needs `DATABASE_URL=postgresql://USER:PERCENT_ENCODED_PASSWORD@127.0.0.1:5432/meguri` and, when OIDC is configured, `MEGURI_OIDC_CLIENT_SECRET`. Proxy-to-database transport is encrypted; loopback stays inside the pod. Add tenant-specific JSM `_EMAIL` / `_TOKEN` variables only for configured tenants. Never commit rendered secrets.
4. Apply only `migrate.yaml`, wait for its successful completion, then apply the Deployment, Service and PDB. Migrations use the same image with `MEGURI_ROLE=migrate`. Replicas have `MEGURI_SKIP_MIGRATIONS=true`; do not race migrations during rollout. 4. Apply only `migrate.yaml`, wait for its successful completion, then apply the Deployment, Service and PDB. The Job uses the same image with `MEGURI_ROLE=migrate` and mounts the same configuration: it runs the Cot migrations and then synchronizes the configuration (creates, adopts, renames, enables and disables workspaces, applies bootstrap accounts, recomputes every user's memberships). Replicas have `MEGURI_SKIP_MIGRATIONS=true`; they read the configuration but never synchronize it, so a configuration change takes effect by running the Job for the release first and then restarting replicas. Do not race migrations during rollout. A bootstrap password, if enabled, is in the Job's log.
5. Add your internal HTTPS ingress. `MEGURI_PUBLIC_URL` must exactly match the browser origin (scheme, host, port); mutations and cookies depend on it. The default Service is ClusterIP. Keep access inside your organization while OIDC is being added. To bootstrap local accounts, temporarily enable signup in that restricted environment, create accounts, then disable signup. Never enable demo seeding outside loopback. 5. Add your internal HTTPS ingress. `MEGURI_PUBLIC_URL` must exactly match the browser origin (scheme, host, port); mutations, cookies and the OIDC redirect URI (`{MEGURI_PUBLIC_URL}/api/oidc/callback`) depend on it. The default Service is ClusterIP. There is no registration or manual account bootstrap: people appear when they sign in with SSO and their groups map to a workspace or instance role. For a first deployment before SSO works, list a bootstrap account (below), sign in with the logged password, fix the setup, then remove the block. Never enable demo seeding outside loopback.
6. Run health checks, a two-user replacement, a tenant-isolation check, and the provider acceptance checklist in `testing.md`. Enable real JSM integration only after preview and acceptance. 6. Run health checks, a two-user replacement, a workspace-isolation check (an employee of one workspace cannot see another), a sign-in with a user whose groups map to nothing (expect the explanatory message and no new account), and the provider acceptance checklist in `testing.md`. Enable real JSM integration only after preview and acceptance.
## Instance configuration
Meguri refuses to start without `MEGURI_CONFIG_FILE` (every role; only demo mode uses a built-in file). The file is YAML; unknown fields and invalid values fail startup with a message. It is the only place where workspaces and the groups that grant access are defined; the application has no UI or API for creating workspaces or editing group mappings.
```yaml
auth:
password_login: false # ordinary password accounts may sign in (default false)
bootstrap_admin: # optional break-glass instance administrators; omit in production
usernames: [rescue]
oidc: # optional; the client secret is MEGURI_OIDC_CLIENT_SECRET
issuer: https://sso.example.internal/realms/corp
client_id: meguri
scopes: [openid, profile, email] # default; must include openid
groups_claim: groups # default; dotted path, e.g. realm_access.roles
groups_sources: [id_token, userinfo] # default; searched in this order
instance_admin_groups: [/meguri/admins]
workspaces:
- key: payments # stable identifier, 3-64 chars: a-z 0-9 -
name: Payments # unique, 1-120 characters
admin_groups: [/payments/leads] # at least one; workspace administrators
member_groups: [/payments/team] # employees
```
At least one sign-in method (`oidc`, `password_login` or `bootstrap_admin`) is required, `oidc.issuer` must be HTTPS (loopback may use HTTP), and keys and names must be unique. Groups are compared by exact string: with Keycloak's *Full group path* the values look like `/payments/leads`, without it `leads`. A group in several workspaces is allowed; a person with both an administrator and an employee group of one workspace is an administrator.
Synchronization (run by the migrate Job, or by a single process without `MEGURI_SKIP_MIGRATIONS`) makes the database match the file:
- A configured key without a workspace creates it. A workspace that has no key and exactly the configured name is adopted; if several match, startup fails and nothing is changed. A different name renames the workspace as a normal revision.
- A workspace missing from the file is *disabled*, never deleted: nobody can open it (including open sessions), it is not published, its memberships and queued publication are removed, and its data stays. Listing it again re-enables it and queues a publication. If the file lists no workspaces while enabled ones exist, startup is refused unless `MEGURI_ALLOW_DISABLE_ALL=true`; a mistyped key therefore creates a new empty workspace and disables the old one, and fixing the file restores both.
- Disabled workspaces appear on the instance administrator's page. Deleting one is a deliberate, permanent action there (type its key; refused while it is configured or publishing). Remote provider resources it published are not removed.
- Every user's memberships are recomputed from the groups stored at their last sign-in (or set by an administrator), so changed group mappings apply to everyone at the release without waiting for logins. Group membership changes in the identity provider apply at the user's next SSO sign-in (sessions last 12 hours).
- Instance-level events (workspace created/adopted/renamed/enabled/disabled/deleted, bootstrap password set, password user created, groups changed, password reset) are written to `instance_audit`.
## Single sign-on with Keycloak
Create a confidential OpenID Connect client for Meguri: client authentication on, *Standard flow* only (no implicit, direct-access or service accounts), valid redirect URI `{MEGURI_PUBLIC_URL}/api/oidc/callback`, and put the generated secret into `MEGURI_OIDC_CLIENT_SECRET`. Request the `openid`, `profile` and `email` scopes; the email is used to link people already planned by email. Add a **Group Membership** mapper to the client with *Add to ID token* and *Add to userinfo* enabled (userinfo is the fallback source) and a token claim name equal to `oidc.groups_claim` (`groups` by default). *Full group path* decides whether values are `/payments/leads` or `leads`; the configuration must use exactly what the token contains. Keycloak does not send groups unless this mapper exists. Meguri verifies RS256 signatures against the realm's JWKS, so keep RS256 as the client's signature algorithm.
A sign-in whose token carries no groups claim is treated as having no groups; the log shows `the identity provider returned no groups claim` and the user sees the same explanation as for unmapped groups ("the instance could not map your groups") with a short reference. The log entry `SSO sign-in was not completed` carries that reference, the code (`no_access`, `failed`, `unavailable`) and the cause, including the groups that were received. Sign-out ends only the Meguri session; the identity provider session is untouched.
## Bootstrap administrators
`auth.bootstrap_admin.usernames` creates the listed accounts as instance administrators at synchronization and logs one line per account: `Bootstrap administrator enabled: sign in as NAME with password PASSWORD`. The password is derived from a secret stored in the database, the username and the current UTC hour, so every process that synchronizes in the same hour logs the same password; it changes in the next hour and ends the account's sessions. Replicas started across an hour boundary can disagree, which a restart resolves. These accounts may sign in even when `password_login` is false. To recover from a broken SSO setup, add the block, run the migrate Job, read its log, sign in, repair, and remove the block: removal clears the password, ends sessions and revokes the rights while keeping the user record. Treat log access as administrator access while the block exists. A bootstrap name that belongs to an SSO account stops synchronization.
The default Deployment has two replicas serving both web and workers. Conditional database leases serialize each team's publication. Roles can later be split into `web` and `worker` deployments without changing the binary. On interruption, a completed remote step remains checkpointed; an unfinished lease expires after five minutes. The default Deployment has two replicas serving both web and workers. Conditional database leases serialize each team's publication. Roles can later be split into `web` and `worker` deployments without changing the binary. On interruption, a completed remote step remains checkpointed; an unfinished lease expires after five minutes.
Monitor failed/conflict sync jobs, oldest `last_success`, and `window_end` approaching the current time. Meguri publishes a finite horizon and must keep running to extend it. Configure operational monitoring around these values; Meguri does not send incident notifications itself. Readiness checks PostgreSQL; liveness checks the process. Monitor failed/conflict sync jobs, oldest `last_success`, and `window_end` approaching the current time. Meguri publishes a finite horizon and must keep running to extend it. Configure operational monitoring around these values; Meguri does not send incident notifications itself. Readiness checks PostgreSQL; liveness checks the process.
The current polling worker and history/audit reads target team-scale workloads. Benchmark and add query pagination/queue batching before broad multi-tenant SaaS rollout. Configure ingress-level rate limits in addition to per-process login throttling. Password recovery, session administration, OIDC, history archival, and live provider acceptance are subsequent operational work. The current polling worker and history/audit reads target team-scale workloads. Benchmark and add query pagination/queue batching before broad multi-tenant SaaS rollout. Configure ingress-level rate limits in addition to per-process login throttling. Password recovery, session administration, history archival, and live provider acceptance are subsequent operational work.
## Upgrading a database that used local accounts
Memberships are now derived entirely from user groups. The first configuration sync on an upgraded database recomputes every membership from the stored groups; users created before this change have no groups, so their old workspace memberships are removed until the instance administrator assigns groups (password users) or they sign in through SSO. Workspaces without a configuration key are adopted by exact name or disabled; none is deleted. Review the first sync log and the instance audit before opening the instance to users.
+16 -34
View File
@@ -2,9 +2,9 @@
Requirements: Rust 1.97 (Cot itself requires 1.94), PostgreSQL 17+, and a browser. Node is only needed for browser automation or upgrading vendored browser assets; serving the application has no Node build step. Requirements: Rust 1.97 (Cot itself requires 1.94), PostgreSQL 17+, and a browser. Node is only needed for browser automation or upgrading vendored browser assets; serving the application has no Node build step.
Use `.env.example` as the list of supported settings. Environment files are not loaded automatically. Start a dedicated PostgreSQL database, set `DATABASE_URL`, then run `cargo run`. Database migrations run before the server accepts requests. Use `.env.example` as the list of supported settings. Environment files are not loaded automatically. Start a dedicated PostgreSQL database and either run the disposable demo (`MEGURI_SEED_DEMO=true`, loopback only, built-in configuration, see below) or write an instance configuration file (start from `deploy/instance-config.example.yaml`; `auth.password_login: true` plus `auth.bootstrap_admin` gives a local administrator without an identity provider), set `DATABASE_URL` and `MEGURI_CONFIG_FILE`, then run `cargo run`. Startup fails without a configuration file. Database migrations and the configuration sync run before the server accepts requests; the generated bootstrap password is logged. There is no registration: create further password users on the instance page, or sign in through an OIDC provider.
For an isolated Windows development database, use `scripts/dev.ps1`. It uses installed PostgreSQL binaries, creates data under ignored `target/`, binds the database and app to loopback, enables registration, and explicitly seeds demonstration accounts. `scripts/stop-dev.ps1` stops only the processes started for this workspace. For an isolated Windows development database, use `scripts/dev.ps1`. It uses installed PostgreSQL binaries, creates data under ignored `target/`, binds the database and app to loopback, and starts disposable demo mode. The demo password is generated on every start; it appears on the login screen and in `target/local/app.log`. `scripts/stop-dev.ps1` stops only the processes started for this workspace.
Required checks: Required checks:
@@ -14,11 +14,11 @@ cargo clippy --all-targets -- -D warnings
cargo test cargo test
``` ```
PostgreSQL integration tests require `MEGURI_TEST_DATABASE_URL` pointing at a disposable database, with CREATEDB for the isolated demo-lifecycle tests. Run `cargo test --test integration -- --ignored --test-threads=1`. Most tests create isolated workspaces/users; lifecycle tests create and remove uniquely named databases to test protection, reset, retention, and reseeding. Never use a production database. PostgreSQL integration tests require `MEGURI_TEST_DATABASE_URL` pointing at a disposable database with CREATEDB (lifecycle and configuration-sync tests create and drop uniquely named databases, because sync disables every unconfigured workspace). Run `cargo test --locked --tests -- --ignored --test-threads=1`; this covers `tests/integration.rs`, `tests/access.rs`, `tests/oidc.rs` and `tests/demo_lifecycle.rs`. Most scheduling tests create uniquely keyed workspaces and users through the real configuration and group path; lifecycle tests test protection, reset, retention, and reseeding. Never use a production database.
CI uses an explicit Debian Node job container and the PostgreSQL service hostname `postgres`, for both Gitea Actions and GitHub Actions. Loopback inside the job does not reach the database service. No host database port is published. A bounded authenticated `psql SELECT 1` check runs before compilation; database networking/authentication failures stop the job rather than producing a pool timeout in every integration test. The application and browser tests still share job-container loopback. CI uses an explicit Debian Node job container and the PostgreSQL service hostname `postgres`, for both Gitea Actions and GitHub Actions. Loopback inside the job does not reach the database service. No host database port is published. A bounded authenticated `psql SELECT 1` check runs before compilation; database networking/authentication failures stop the job rather than producing a pool timeout in every integration test. The application and browser tests still share job-container loopback.
Browser checks: sign in; inspect the calendar; create a request as one user and approve it as another; verify the publication status separately; verify that a user outside the workspace cannot read it. Use both desktop and narrow viewports. A test failure is not a reason to relax an authorization or scheduling invariant. Browser checks: sign in with a demo account; inspect the calendar; create a request as one user and approve it as another; verify the publication status separately; verify that a user outside the workspace cannot read it. Use both desktop and narrow viewports. A test failure is not a reason to relax an authorization or scheduling invariant.
## Operational limitations ## Operational limitations
@@ -28,7 +28,7 @@ Provider display names use the resource name followed by a stable ownership mark
An exact-time JSM schedule can show a base rotation containing the current assignee (or `noone` before coverage begins) and generated future overrides in the provider UI. These are the exported effective assignments, not additional approval requests. A previously failed native-rotation write with no checkpoint is not automatically adopted: inspect it and explicitly overwrite the owned test schedule when repairing it. A successful remote create alone is never a synchronized publication. An exact-time JSM schedule can show a base rotation containing the current assignee (or `noone` before coverage begins) and generated future overrides in the provider UI. These are the exported effective assignments, not additional approval requests. A previously failed native-rotation write with no checkpoint is not automatically adopted: inspect it and explicitly overwrite the owned test schedule when repairing it. A successful remote create alone is never a synchronized publication.
For automated browser checks, run `npm ci`, `npx playwright install chromium`, then `npm run test:browser` against a seeded local app with signup enabled. By default the tests use installed Chrome; set `MEGURI_BROWSER_CHANNEL=chromium` for downloaded Chromium. The original suite creates its own workspace. `tests/browser-calendars.cjs` creates an isolated test account and checks email participants/absences, country defaults, reactive CSV import, workspace working hours, policy flows, and display-timezone isolation. `tests/browser-configuration.cjs` uses another isolated account to check removal dependencies/redirects, native routing drag/drop, saved positions, insertion/removal renumbering, and mobile controls. Screenshots go under ignored `test-results/`. For automated browser checks, run `npm ci`, `npx playwright install chromium`, then `npm run test:browser` against a freshly started demo (`MEGURI_SEED_DEMO=true`). The suites read the generated password from `/api/session` (`tests/support/browser.cjs`), so they need no configuration; set `MEGURI_BROWSER_URL` for a non-default address. By default the tests use installed Chrome; set `MEGURI_BROWSER_CHANNEL=chromium` for downloaded Chromium or `MEGURI_BROWSER_EXECUTABLE` for a specific binary. Workspaces cannot be created from the UI or API, so suites that need an isolated workspace create a fresh password user through the instance administrator API with groups from the demonstration configuration. `tests/browser-access.cjs` covers the login guide, absence of registration, the SSO error banner, the instance page and its read-only rules, employee permission editing (including force without approval), employee and outsider views, and workspace isolation. `tests/browser-calendars.cjs` checks email participants/absences, country defaults, reactive CSV import, workspace working hours, policy flows, and display-timezone isolation. `tests/browser-configuration.cjs` checks removal dependencies/redirects, native routing drag/drop, saved positions, insertion/removal renumbering, and mobile controls. Screenshots go under ignored `test-results/`. The suites modify demo data (some empty a demo workspace); restart the demo before repeating a run.
Windows development needs complete PostgreSQL server binaries and their `share/` data files, not a client-only installation. Pass `-PostgresBin` to use a portable distribution. The loopback-only local database uses trust authentication and is not a production configuration. The script serves a copy of the executable so Cargo can rebuild while it runs. Windows development needs complete PostgreSQL server binaries and their `share/` data files, not a client-only installation. Pass `-PostgresBin` to use a portable distribution. The loopback-only local database uses trust authentication and is not a production configuration. The script serves a copy of the executable so Cargo can rebuild while it runs.
@@ -36,17 +36,17 @@ Read [testing.md](testing.md) when changing rules and [deployment.md](deployment
## Demo lifecycle ## Demo lifecycle
`MEGURI_SEED_DEMO=true` enables disposable demo mode, not just seed insertion. It is limited to loopback and a single `all` or `web` process. Startup verifies the previous database mode before migrations, resets only positively marked demo application data, migrates, and seeds two examples. Normal startup records `normal`, including migration/worker roles. A missing or unreadable provenance marker never grants deletion authority. The existing development `meguri` DB will therefore be preserved on its first transition to this mode. `MEGURI_SEED_DEMO=true` enables disposable demo mode, not just seed insertion. It is limited to loopback and a single `all` or `web` process, uses the built-in configuration `src/demo-config.yaml` (any `MEGURI_CONFIG_FILE` is ignored) and needs no OIDC. Startup verifies the previous database mode before migrations, resets only positively marked demo application data, migrates, synchronizes the built-in configuration and seeds four examples. Normal startup records `normal`, including migration/worker roles. A missing or unreadable provenance marker never grants deletion authority. The existing development `meguri` DB will therefore be preserved on its first transition to this mode.
When the configured DB is protected, Meguri selects a separate `meguri_demo_<stable fingerprint>_<slot>` DB. The application login needs CREATEDB, or an administrator must supply an empty `MEGURI_DEMO_DATABASE_URL` with suitable permissions. Credentials are never shown in the browser/log notice. Protected fallback candidates are skipped, never dropped or overwritten. Isolation failure aborts startup. Do not manually mark a production database as demo. Another active client blocks reset; stop the old demo process before restarting. When the configured DB is protected, Meguri selects a separate `meguri_demo_<stable fingerprint>_<slot>` DB. The application login needs CREATEDB, or an administrator must supply an empty `MEGURI_DEMO_DATABASE_URL` with suitable permissions. Credentials are never shown in the browser/log notice. Protected fallback candidates are skipped, never dropped or overwritten. Isolation failure aborts startup. Do not manually mark a production database as demo. Another active client blocks reset; stop the old demo process before restarting.
The permanent header explains creation-based expiry and any protected-DB fallback. Defaults: workspaces 24 hours; visitor accounts 48 hours; requests 2 hours; audit/history 6 hours; physical purge 10 minutes after soft deletion. Configure seconds with the `MEGURI_DEMO_*` settings in `.env.example` (60 seconds–30 days). Sweeps run every minute. Nested configuration expires with its owning aggregate to preserve dependencies. Seed accounts remain; baseline workspaces are reseeded after expiry. No cleanup runs in normal mode. Demo rejects real provider integrations/publication. The permanent header explains creation-based expiry and any protected-DB fallback. Defaults: workspaces 24 hours; accounts created by an administrator during the run 48 hours; requests 2 hours; audit/history 6 hours; physical purge 10 minutes after soft deletion. Configure seconds with the `MEGURI_DEMO_*` settings in `.env.example` (60 seconds–30 days). Sweeps run every minute and also re-run the configuration sync and seeding. Nested configuration expires with its owning aggregate to preserve dependencies. The seeded accounts remain; baseline workspaces are recreated and reseeded after expiry. No cleanup runs in normal mode. Demo rejects real provider integrations/publication.
Alice (`meguri-demo-only`) leads both examples. Bob belongs to Engineering; Carol belongs to Support. Engineering uses `projects`, `responsible`, and `on_call`; Support has independent `queues`, `agents`, and `lead` variables. All generated state is activated through the regular preview/commit code path with the mock provider. Accounts (one password per start, shown on the login screen and in the log; there is no registration): `admin` is the instance administrator (a bootstrap account), `alice` administers all four workspaces, and `bob`, `carol`, `dave`, `erin` are employees. Groups are assigned as an instance administrator would assign them, and the built-in configuration is in the production format. Bob belongs to Engineering and Regional Platform; Carol to Engineering, Support and Commerce; Dave and Erin to Engineering. Engineering uses `projects`, `responsible`, and `on_call`; Support has independent `queues`, `agents`, and `lead` variables. All generated state is activated through the regular preview/commit code path with the mock provider.
Additional browser suites: `browser-absence.cjs` checks collapsed holidays, DD-MM-YYYY input, live bulk absence impact, range replacements and force; `browser-generation.cjs` checks demos, their permanent notice and isolated memberships, visual data/binding editing, early errors, preview without writes, stable generated IDs, and mobile forms. Restart after browser tests to return to the four clean examples. Additional browser suites: `browser-absence.cjs` checks collapsed holidays, DD-MM-YYYY input, live bulk absence impact, range replacements and force; `browser-generation.cjs` checks demos, their permanent notice and isolated memberships, visual data/binding editing, early errors, preview without writes, stable generated IDs, and mobile forms. Restart after browser tests to return to the four clean examples.
OIDC is intentionally deferred; local password authentication is implemented first. No external JSM account is needed to run the mock provider. JSM writes require explicit integration configuration and workspace-scoped environment credentials. Contract tests use a fake HTTP server, never a production account. Authentication is OpenID Connect plus optional password accounts; see [deployment](deployment.md#instance-configuration) for configuration and Keycloak setup. No external JSM account is needed to run the mock provider. JSM writes require explicit integration configuration and workspace-scoped environment credentials. Contract tests use a fake HTTP server, never a production account.
Workspace configuration edits take effect when saved. Historical queries use saved configuration revisions. There is no user-configurable delayed activation of a whole configuration revision in the first release. Workspace configuration edits take effect when saved. Historical queries use saved configuration revisions. There is no user-configurable delayed activation of a whole configuration revision in the first release.
@@ -55,11 +55,11 @@ Workspace configuration edits take effect when saved. Historical queries use sav
Navigation and language coverage: tests/browser-navigation.cjs checks direct menu/workspace links, reload and browser history, saved language versus browser preference, Russian/French display, safe interpolation, catalog completeness and the prohibition on literal template copy. It runs in npm run test:browser. Navigation and language coverage: tests/browser-navigation.cjs checks direct menu/workspace links, reload and browser history, saved language versus browser preference, Russian/French display, safe interpolation, catalog completeness and the prohibition on literal template copy. It runs in npm run test:browser.
Demo workspaces import `src/demo-holidays-2026.csv` through the regular holiday service: 15 Cyprus dates supplied by the project owner and 18 Japanese holidays/days off from https://www8.cao.go.jp/chosei/shukujitsu/gaiyou.html (2026 table, verified 2026-09-24). This is a fixed 2026 example, not a recurring calendar or an annual-date extrapolation. Demo workspaces import `src/demo-holidays-2026.csv` through the regular holiday service: 15 Cyprus dates supplied by the project owner, the 8 England and Wales bank holidays of 2026 from https://www.gov.uk/bank-holidays.json (verified 2026-10-07), and the 13 statutory Armenian public holidays/days off of 2026 (listed from the Labour Code, not re-verified against an official calendar). This is a fixed 2026 example, not a recurring calendar or an annual-date extrapolation. Demo accounts also differ in scheduling country so that country holidays are visible: alice and dave Cyprus, bob and erin United Kingdom (`GB`), carol Armenia (`AM`).
Demo workspaces: Acme Engineering (project priorities + weekly cover), Customer Support (daily queues), Regional Platform (dictionary regions → nested enabled services, region-specific handovers/rotation lengths, shared commander), Commerce Operations (product → tier dictionary lookup, variable escalation delays/repeats/working windows, shared weekly fallback). Alice owns all four; Bob is a member in Acme/Regional, Carol in Support/Commerce. Regional times use the workspace scheduling zone, not a per-region zone. Disabled Sandbox and Legacy export illustrate filtering; enabling the latter requires adding its missing retired tier or choosing an existing tier. All examples use normal generation preview/apply and demo cleanup; provider remains local preview. Demo workspaces: Acme Engineering (project priorities + weekly cover), Customer Support (daily queues), Regional Platform (dictionary regions → nested enabled services, region-specific handovers/rotation lengths, shared commander), Commerce Operations (product → tier dictionary lookup, variable escalation delays/repeats/working windows, shared weekly fallback). Alice administers all four; Bob is an employee in Acme/Regional, Carol in Acme/Support/Commerce. Regional times use the workspace scheduling zone, not a per-region zone. Disabled Sandbox and Legacy export illustrate filtering; enabling the latter requires adding its missing retired tier or choosing an existing tier. All examples use normal generation preview/apply and demo cleanup; provider remains local preview.
The main Acme Engineering demo contains six services (Payments, Search, Billing, Identity, Data platform, Developer portal), each with two or three ordered owners drawn from five login members: alice, bob, carol, dave, erin. All five share the weekly Monday 11:00 Time off rotation. Dave and Erin only access Acme; Carol also retains Support/Commerce access. Other demo workspaces remain separate. Login password remains `meguri-demo-only`. The main Acme Engineering demo contains six services (Payments, Search, Billing, Identity, Data platform, Developer portal), each with two or three ordered owners drawn from five login members: alice, bob, carol, dave, erin. All five share the weekly Monday 11:00 Time off rotation. Dave and Erin only access Acme; Carol also has Support/Commerce access through her groups. Other demo workspaces remain separate. The login password is the one generated for the run.
## Single workspace schema ## Single workspace schema
@@ -69,6 +69,10 @@ The default Engineering example stores projects as one list of dictionaries: eac
Generation map uses locally vendored Cytoscape.js 3.33.1 (`static/vendor/cytoscape.js`, adjacent MIT license), with no CDN requests or Node build requirement. Its preset lane layout and inspector live in `static/generation-map.js` / `.html`; engine trace data is returned by the authorized generation-preview endpoint. Run `node tests/browser-generation-map.cjs` against the disposable demo for nested iterations, filtering, diagnostics, read-only behavior, canvas interactions and mobile layout. Generation map uses locally vendored Cytoscape.js 3.33.1 (`static/vendor/cytoscape.js`, adjacent MIT license), with no CDN requests or Node build requirement. Its preset lane layout and inspector live in `static/generation-map.js` / `.html`; engine trace data is returned by the authorized generation-preview endpoint. Run `node tests/browser-generation-map.cjs` against the disposable demo for nested iterations, filtering, diagnostics, read-only behavior, canvas interactions and mobile layout.
## Browser-check sandboxes
Browser suites that change data cannot reuse the example workspaces: approved replacements are permanent history, so a schedule that has one can never be removed. Start the demo with `MEGURI_DEMO_SANDBOXES=14` to add empty `Sandbox 1..14` workspaces, each with its own administrator group `sandbox-N-leads` and employee group `sandbox-N-staff`. Every mutating suite creates a throwaway password user in its own sandbox through the instance-administrator API. Restart the demo before each full run; sandboxes are not reset.
Deleting an exported resource in JSM does not remove the desired configuration in Meguri: the next publication recreates it and its managed coverage, without requiring overwrite. To remove it permanently, remove it in Meguri. Existing external edits still obey the drift policy. Deleting an exported resource in JSM does not remove the desired configuration in Meguri: the next publication recreates it and its managed coverage, without requiring overwrite. To remove it permanently, remove it in Meguri. Existing external edits still obey the drift policy.
## Explicit publication and shared drafts ## Explicit publication and shared drafts
@@ -121,26 +125,4 @@ The Import / Export dialog includes a localized Guide tab with a complete compac
To exercise random team escalation, select A random team member in a generator or import `{"after":30,"team":"random"}` in meguri/v2. No person binding is required. Use the local fake provider for verification; notification selection belongs to the external provider. To exercise random team escalation, select A random team member in a generator or import `{"after":30,"team":"random"}` in meguri/v2. No person binding is required. Use the local fake provider for verification; notification selection belongs to the external provider.
Site administration bootstrap: set `MEGURI_BOOTSTRAP_ADMIN=admin` for the existing local username and restart once, then remove it. It never creates the account. Authentication settings are a draft-only form: OIDC execution and password-disable enforcement are not implemented yet. The client-secret field stores an environment-variable name, not its value. No credentials are written to the database or returned to the browser. Users, Workspaces, Authentication and Administration history are separate administration sections; future typed sections extend this structure.
See [site administration](site-administration.md) for bootstrap, role boundaries and extending settings sections.
Email-only contacts do not approve replacements. Test ordinary requests, swaps and absence groups with account/contact combinations, including immediate application with no approvers and retained approval for real members. OIDC/email account linking remains deferred; never infer membership from an email match. Email-only contacts do not approve replacements. Test ordinary requests, swaps and absence groups with account/contact combinations, including immediate application with no approvers and retained approval for real members. OIDC/email account linking remains deferred; never infer membership from an email match.
Account/person migration requires real email addresses for existing login accounts. Recover unambiguous email from linked workspace Person snapshots; resolve missing or conflicting mappings explicitly rather than inventing addresses. New signup forms and test fixtures supply email. OIDC remains deferred.
For a pre-migration database, start once with an explicit mapping, for example
`MEGURI_ACCOUNT_EMAILS='{"admin":"admin@example.com"}'` alongside the usual
launch environment. The map is keyed by existing username. Missing or ambiguous
linked emails are listed in the migration error; unknown usernames, duplicate
emails and unapproved contact collisions abort the migration. An explicit mapping
permits linking a matching contact to an account already in that workspace. Supply every missing
address together and retry. Remove the variable after successful migration.
No placeholder addresses are assigned. A failed migration preserves the schema,
workspace revisions and history. Successful migration adds canonical current
member snapshots and a new audit/history revision without authorizing publication.
Account email is displayed read-only for now; verified identity/email changes and
contact association belong to the later OIDC work.
The preparatory contact/account migration runs before account email enforcement on legacy databases. An explicit deployment email mapping authorizes association only in workspaces where the account is already a member. It rewrites current scheduling references, person-valued generator inputs, shared drafts and explicit provider identity keys, retaining resource IDs and old history/publication checkpoints. Missing/ambiguous mappings, conflicting provider identities or collapsed invalid schedules/replacements block the repair before writes. Already email-migrated installations are skipped. Regression coverage includes migration ordering, retry after failure, generator drafts and unchanged access/history.
+27 -15
View File
@@ -1,6 +1,26 @@
# Product contract # Product contract
Meguri is a multi-tenant on-call configuration service. Workspace is the single tenant boundary: there are no nested teams. A user may belong to several independent workspaces. Workspace grants separately control reading, schedule editing, replacement requests, forced application, membership administration, provider configuration, and audit access. Workspace owners administer their workspace and its drift policy. Meguri is a multi-tenant on-call configuration service. Workspace is the single tenant boundary: there are no nested teams. A user may belong to several independent workspaces. Three roles exist: instance administrators, workspace administrators, and employees. Workspaces and the groups that grant roles are declared in the instance configuration; the application never lets anyone create them.
## Access and administration
**Instance configuration.** Everything about who may enter which workspace is declared in one file read at startup (`MEGURI_CONFIG_FILE`, YAML; JSON is valid YAML). The process refuses to start without it, except demo mode, which carries a built-in configuration of the same format. Unknown fields and invalid values fail startup with the reason. The file declares: `auth.password_login` (default false); optional `auth.bootstrap_admin.usernames`; optional `oidc` (`issuer`, `client_id`, `scopes` default `openid profile email`, `groups_claim` default `groups` as a dotted path, `groups_sources` default `[id_token, userinfo]`); `instance_admin_groups`; and `workspaces`, each with a stable `key` (3–64 lowercase letters, digits and hyphens), a unique `name`, at least one `admin_groups` entry and optional `member_groups`. At least one sign-in method must be configured. The OIDC client secret is a deployment secret (`MEGURI_OIDC_CLIENT_SECRET`), never part of the file. [`deploy/instance-config.example.yaml`](../deploy/instance-config.example.yaml) is a commented example.
**Roles.** An *instance administrator* sees every enabled workspace, may act in it with every permission without being a member, and manages password users and disabled workspaces from the instance page; configuration, workspaces and group mappings are read-only there. A *workspace administrator* holds every permission in exactly that workspace and cannot see, create or affect any other. An *employee* holds the workspace's employee permission set: calendar, schedule editing and replacement requests by default (`view`, `edit_schedules`, `request_overrides`). Administrators change that set in workspace settings and it applies to every current and future employee immediately; `view` is mandatory and `manage_members`/`manage_integration` can never be granted to employees. Granting `force_overrides` lets every employee apply replacements without confirmation; leaving it out requires approval.
**Employees and workspace settings.** `edit_schedules` lets an employee plan: schedules, escalations, routing, absences, holidays and new email contacts. It never covers the workspace preferences (name, planning horizon), existing entries of the scheduling directory, the working interval, the drift policy, the provider integration or access; those are workspace administrator (and instance administrator) actions that employees may view but not change. The server rejects such edits with 403 and the UI hides the controls.
**Groups decide roles.** A person's groups are compared to the configured groups by exact string match (case, slashes and prefixes matter). Workspace administrator beats employee when both match; matching `instance_admin_groups` makes an instance administrator. Memberships are recomputed from the stored groups at every SSO sign-in, whenever an administrator edits a password user's groups, and for every user at each configuration sync, so removing a group or a workspace removes access. A sign-in whose groups map to nothing is refused with an explanation and a support reference; no account is created or kept enabled for it.
**Accounts.** There is no self-registration. Accounts come into being only through an SSO sign-in whose groups map to something, or when an instance administrator creates a password user (username, display name, password of 12–256 characters, optional email, groups chosen from the configured list). SSO accounts have no password and their groups belong to the identity provider (read-only in Meguri); password accounts have groups edited by an instance administrator, who cannot edit their own, and can have their password reset (which ends their sessions). An SSO identity is never linked to an existing password account automatically. Ordinary password sign-in works only when `auth.password_login` is true.
**Workspace lifecycle.** On startup the configuration is synchronized: a configured key without a workspace creates one; an unkeyed workspace with exactly the configured name is adopted (several candidates abort startup); a changed name renames it through an ordinary revision; a workspace missing from configuration is disabled, never deleted. A disabled workspace is invisible and inaccessible to everyone including sessions already open, receives no publication and keeps all its data; configuring it again re-enables it with its previous data and queues a publication. A configuration that lists no workspace while enabled ones exist is refused unless `MEGURI_ALLOW_DISABLE_ALL=true`. An instance administrator may permanently delete a workspace that is disabled and not in the configuration by typing its key (its name if it never had one); history, audit and provider checkpoints go with it, but resources already published to a provider are not removed. Instance-level changes are recorded in `instance_audit`.
**Bootstrap administrators.** Optional break-glass accounts listed in `auth.bootstrap_admin.usernames` are created at startup as instance administrators with a generated password written to the log of the process that synchronized the configuration. The password is derived from a secret held in the database, the username and the current UTC hour, so replicas restarted in the same hour agree; a new hour rotates it and ends the account's sessions. They may sign in even when `auth.password_login` is false. Removing the block locks the accounts (password cleared, sessions ended, administrator rights revoked) but keeps their records. While enabled, anyone who can read the logs can become an instance administrator; production normally omits the block.
**Single sign-on.** Authorization code with PKCE for a confidential client. The browser carries a one-time state cookie bound to a database record; callbacks without a matching, unused, unexpired record are refused. ID tokens must be RS256 with a valid signature, issuer, audience, expiry and nonce. Groups come from the configured claim in the ID token, then from userinfo; a missing claim is treated as no groups and logged as a provider misconfiguration. Failures return to the login screen with `login_error` of `no_access`, `failed` or `unavailable` and a short reference that support finds in the log. Sign-out ends only the Meguri session.
**Email linking.** The sign-in email is lowercased. In each workspace the user belongs to, an existing email-only contact with that email becomes the user: the contact's ID is replaced by the user's ID throughout the current document (schedules, absences, requests, variables), the old ID is kept as an alias for history and audit, and the contact's name and country are kept. A user without a matching contact gains a person carrying the email, which providers need to resolve them. Ambiguous or conflicting data (several contacts, a contact already tied to another login, a person with a different email) is skipped. An email never grants access; only groups do.
## Scheduling ## Scheduling
@@ -52,7 +72,7 @@ Schedule removal shows a server-generated dependency summary before applying. Us
Routing rules are evaluated from top to bottom. Drag-and-drop and keyboard/touch move buttons save the ordered list of rule IDs; the server requires an exact permutation and assigns consecutive priorities. The rule editor selects an insertion position instead of exposing conflicting numeric priorities. Reordering requires schedule-edit permission and rejects stale versions. Routing rules are evaluated from top to bottom. Drag-and-drop and keyboard/touch move buttons save the ordered list of rule IDs; the server requires an exact permutation and assigns consecutive priorities. The rule editor selects an insertion position instead of exposing conflicting numeric priorities. Reordering requires schedule-edit permission and rejects stale versions.
Local password authentication is the initial identity adapter. User IDs, memberships, sessions, and audit identities are provider-independent; later OIDC identities bind `(issuer, subject)` to an existing local user ID. Demo mode must be explicitly enabled and uses only the local preview provider. Identity is provider-independent: user IDs, memberships, sessions and audit identities are internal; an SSO identity binds `(issuer, subject)` to one user. Sessions last 12 hours; sign-in attempts are throttled per username. Demo mode must be explicitly enabled and uses only the local preview provider.
Daily use starts on the calendar with incoming requests and one-click Accept/Decline, outgoing pending requests, and the selected schedule's current/next personal shift. Schedules are searchable. Clicking a duty day prefills owner, schedule and exact local boundaries. Own days offer searchable teammates; other people's days preselect the signed-in employee. An exact incoming request can be accepted directly from that day. Split days explicitly choose a segment; past days are read-only. Start/end dates and clock times are always visible and are validated on the server before send. Reciprocal swaps are optional and have their own explicit start/end boundaries. Force permission adds a separate Apply without approval action. Daily use starts on the calendar with incoming requests and one-click Accept/Decline, outgoing pending requests, and the selected schedule's current/next personal shift. Schedules are searchable. Clicking a duty day prefills owner, schedule and exact local boundaries. Own days offer searchable teammates; other people's days preselect the signed-in employee. An exact incoming request can be accepted directly from that day. Split days explicitly choose a segment; past days are read-only. Start/end dates and clock times are always visible and are validated on the server before send. Reciprocal swaps are optional and have their own explicit start/end boundaries. Force permission adds a separate Apply without approval action.
@@ -62,9 +82,9 @@ Infrequent administration lives under Setup. Dialogs name their action. Project-
Before migrations or deletion, startup checks a separate database provenance marker recording the last runtime mode. Only positive previous-demo provenance permits resetting application data. An unmarked database, a normal-mode database, or an unreadable marker is protected. Demo startup switches to a separately named database (or an explicitly supplied empty demo database), with a permanent prominent header and server-log warning requiring administrator attention. An empty database can be claimed only after proving it has no user relations. If isolation cannot be established, startup fails without changing protected data. No DROP DATABASE, schema deletion, or TRUNCATE CASCADE is used. Migration history and runtime metadata survive resets; another active database client blocks reset. Before migrations or deletion, startup checks a separate database provenance marker recording the last runtime mode. Only positive previous-demo provenance permits resetting application data. An unmarked database, a normal-mode database, or an unreadable marker is protected. Demo startup switches to a separately named database (or an explicitly supplied empty demo database), with a permanent prominent header and server-log warning requiring administrator attention. An empty database can be claimed only after proving it has no user relations. If isolation cannot be established, startup fails without changing protected data. No DROP DATABASE, schema deletion, or TRUNCATE CASCADE is used. Migration history and runtime metadata survive resets; another active database client blocks reset.
During a run, a minute-based maintenance task soft-deletes visitor workspaces and their configuration graphs after 24 hours from creation, and visitor accounts after 48 hours. They disappear from access/listing immediately; dependent data is physically removed after a ten-minute grace period, preserving referential integrity. Nested schedules, contacts, variables, rules, absences, and memberships expire with their owning workspace rather than leaving broken references. Requests expire independently after two hours from creation; audit/history have six-hour retention with a historical boundary snapshot retained. Editing never extends creation-based lifetime. Times are configurable. Demo accounts remain usable; seeded workspaces refresh after expiry. The permanent header describes retention and restart reset. Normal mode never runs demo retention. During a run, a minute-based maintenance task soft-deletes demonstration workspaces and their configuration graphs after 24 hours from creation, and accounts that an instance administrator created during the run after 48 hours. They disappear from access/listing immediately; dependent data is physically removed after a ten-minute grace period, preserving referential integrity. Nested schedules, contacts, variables, rules, absences, and memberships expire with their owning workspace rather than leaving broken references. Requests expire independently after two hours from creation; audit/history have six-hour retention with a historical boundary snapshot retained. Editing never extends creation-based lifetime. Times are configurable. The seeded accounts are pinned and remain usable; seeded workspaces are re-created from the demonstration configuration and reseeded after expiry. The permanent header describes retention and restart reset. Normal mode never runs demo retention.
The minimal examples are Acme Engineering / Project matrix (six services with two or three ordered owners during working hours and a shared weekly Monday 11:00 Time off rotation) and Customer Support / Daily support (daily 09:00 shifts and a 15-minute escalation). Alice leads both, Bob, Dave and Erin demonstrate Engineering swaps; Carol participates in Engineering and demonstrates Support approvals. The Commerce example includes an email-only contact without login access. The minimal examples are Acme Engineering / Project matrix (six services with two or three ordered owners during working hours and a shared weekly Monday 11:00 Time off rotation) and Customer Support / Daily support (daily 09:00 shifts and a 15-minute escalation). Demo mode uses the built-in configuration `src/demo-config.yaml`, which has the same format as a production file, and signs in only with passwords (no SSO, no registration). Every start generates one password for all demonstration accounts, writes it to the log and shows the account list with the password on the login screen. `admin` is the instance administrator (a bootstrap account); `alice` administers all four workspaces; `bob`, `carol`, `dave` and `erin` are employees. Bob, Dave and Erin demonstrate Engineering swaps; Carol participates in Engineering and demonstrates Support approvals. The Commerce example includes an email-only contact without login access.
## Anonymized reference behavior ## Anonymized reference behavior
@@ -73,7 +93,7 @@ Alice, Bob, and Carol rotate weekly from Monday at 11:00. Payments primary is Al
Project escalation: primary at minute 0, secondary at minute 10, all team members at minute 30, if unacknowledged. General escalation: on-call at minute 0, random team member at minute 30, named lead at minute 45. Both may repeat 10 times, reset recipient states, and leave the alert open. Working-hours routes select project policies on weekdays 11:00–19:00; a catch-all route selects the general policy. Development alerts may be routed to no notification. Project escalation: primary at minute 0, secondary at minute 10, all team members at minute 30, if unacknowledged. General escalation: on-call at minute 0, random team member at minute 30, named lead at minute 45. Both may repeat 10 times, reset recipient states, and leave the alert open. Working-hours routes select project policies on weekdays 11:00–19:00; a catch-all route selects the general policy. Development alerts may be routed to no notification.
## People, calendars, and working intervals ## People, calendars, and working intervals
A login account owns its canonical Person; planning-only people have no login account. A workspace can select its members or add an email-only contact without creating an account or granting access. Workspace state retains versioned snapshots of name, email, and ISO country; for account-owned people these fields come from the account profile. Country defaults to Cyprus (CY); the UI offers all 249 ISO country codes. Existing login IDs remain stable; an email is never proof of identity. Only authenticated, authorized users can approve requests; email-only contacts are planning entities and require no approval of their own. Future verified OIDC linking must preserve their scheduling identity, but is not implemented by this workflow change. Selecting a workspace user does not grant them workspace access. A login account owns its canonical Person; planning-only people have no login account. A workspace can select its members (people who reached it through groups) or add an email-only contact without creating an account or granting access. Workspace state retains versioned snapshots of name, email, and ISO country; for account-owned people these fields come from the account profile. Country defaults to Cyprus (CY); the UI offers all 249 ISO country codes. Existing login IDs remain stable and an email never grants access; at SSO sign-in it can bind a contact to the signing-in user (see Access and administration). Only authenticated, authorized users can approve requests; email-only contacts are planning entities and require no approval of their own. Selecting a workspace user does not grant them workspace access.
Each schedule has its own local handover time (11:00 by default) in the workspace's IANA timezone. Duration is still measured in calendar days. Dependencies resolve their own handover boundaries. Workspace working days, start/end time, and IANA timezone are shared by all routing rules with “Only during working hours” enabled. Changing this workspace interval atomically updates workspace snapshots/revisions and queues publication. Old migrations preserve historical handovers and timezone values. Each schedule has its own local handover time (11:00 by default) in the workspace's IANA timezone. Duration is still measured in calendar days. Dependencies resolve their own handover boundaries. Workspace working days, start/end time, and IANA timezone are shared by all routing rules with “Only during working hours” enabled. Changing this workspace interval atomically updates workspace snapshots/revisions and queues publication. Old migrations preserve historical handovers and timezone values.
@@ -162,7 +182,7 @@ The rule editor materializes absent optional escalation repeat count/wait fields
Server-preview changes use labeled green/create, blue/update, red/remove and gray/unchanged badges. The expanded list scrolls at 320px (280px on mobile), with a compact footer toggle. Server-preview changes use labeled green/create, blue/update, red/remove and gray/unchanged badges. The expanded list scrolls at 320px (280px on mobile), with a compact footer toggle.
The main Acme Engineering demo contains six services (Payments, Search, Billing, Identity, Data platform, Developer portal), each with two or three ordered owners drawn from five login members: alice, bob, carol, dave, erin. All five share the weekly Monday 11:00 Time off rotation. Dave and Erin only access Acme; Carol also retains Support/Commerce access. Other demo workspaces remain separate. Login password remains `meguri-demo-only`. The main Acme Engineering demo contains six services (Payments, Search, Billing, Identity, Data platform, Developer portal), each with two or three ordered owners drawn from five login members: alice, bob, carol, dave, erin. All five share the weekly Monday 11:00 Time off rotation. Dave and Erin only access Acme; Carol also retains Support/Commerce access. Other demo workspaces remain separate. Access comes from groups defined in the built-in demo configuration, exactly as in production; the accounts share one password generated at every start.
Generation rule results use the actual authorized server expansion, including schedules, escalation policies and routes, with resolved names and compact settings. The panel is always present with explicit checking, invalid, disabled and empty states; it respects iteration filters and does not simulate generation in the browser. Preview data contains only scheduling resources and scoped people, never integration configuration. Generation rule results use the actual authorized server expansion, including schedules, escalation policies and routes, with resolved names and compact settings. The panel is always present with explicit checking, invalid, disabled and empty states; it respects iteration filters and does not simulate generation in the browser. Preview data contains only scheduling resources and scoped people, never integration configuration.
@@ -236,14 +256,6 @@ The Import / Export dialog includes a localized Guide tab with a complete compac
Generation escalation steps can notify a random member of the workspace provider team. The provider chooses the recipient when executing the step; this is neither a random schedule nor sequential notification of all colleagues. Generation escalation steps can notify a random member of the workspace provider team. The provider chooses the recipient when executing the step; this is neither a random schedule nor sequential notification of all colleagues.
## Site administration and identity
Site administrators are an explicit installation-wide role, separate from workspace ownership. They manage login display names, suspension, administrator grants, workspace names and owner access through audited site APIs. Ordinary workspace APIs still require membership; administration never silently joins every workspace. Suspension immediately prevents login/session access but does not remove scheduling contacts or history. The last active site administrator cannot be demoted or disabled. Bootstrap names an existing local username through deployment configuration; it never promotes the first visitor.
Site settings have independently versioned sections. The Authentication section is a saved configuration draft only: OIDC enabled, password enabled, issuer URL, client ID, deployment-secret environment variable name, login button label and new-account policy. Saving this form does not change current password login, signup or sessions. The UI explicitly communicates this limitation; OIDC execution and login-page integration are future work. No client-secret value is accepted or returned. Future OIDC login identities must bind issuer/subject. A verified email may associate an authenticated account with a planning contact while preserving scheduling history; an unverified email must never grant access or merge login accounts.
## Account-owned person ## Account-owned person
Every login account owns a Person with the same stable ID and a mandatory normalized, installation-unique email. An account is the authentication/access extension of that person, not a second scheduling identity. Email-only people remain contacts without login credentials or membership. Workspace documents contain versioned Person snapshots; linked account email is authoritative and cannot be edited through a workspace contact form. New password signups require email but do not claim existing contacts or grant access by matching an unverified address. Future OIDC will associate a verified identity/email with existing planning references explicitly, preserving history and applying the access policy. OIDC execution remains deferred. Every login account owns a Person with the same stable ID. An account is the authentication/access extension of that person, not a second scheduling identity. Email-only people remain contacts without login credentials or membership. Workspace documents contain versioned Person snapshots; the account's name, email and country are authoritative and cannot be edited through a workspace contact form, and every regular workspace save refreshes the snapshots of the members. An account's email is optional (password users may have none; SSO accounts take it from the provider) and is not unique: an email shared with a contact links only when exactly one unlinked contact matches (see Access and administration), otherwise nothing is merged. An account that loses access (its groups changed or its workspace was disabled) keeps its person and the link, so schedules and history stay valid; it simply stops being a member and can no longer approve. An unverified or ambiguous email never grants access or merges login accounts.
The preparatory contact/account migration runs before account email enforcement on legacy databases. An explicit deployment email mapping authorizes association only in workspaces where the account is already a member. It rewrites current scheduling references, person-valued generator inputs, shared drafts and explicit provider identity keys, retaining resource IDs and old history/publication checkpoints. Missing/ambiguous mappings, conflicting provider identities or collapsed invalid schedules/replacements block the repair before writes. Already email-migrated installations are skipped. Regression coverage includes migration ordering, retry after failure, generator drafts and unchanged access/history.
-34
View File
@@ -1,34 +0,0 @@
# Site administration
The site administrator controls the installation, independently of workspace owners. Workspace membership still governs calendars, generation, approvals, provider configuration and publication. Administrators can explicitly grant themselves owner access when they need to work inside a workspace; this is audited, never implicit.
## Bootstrap
Create the local account normally, then start the application once with `MEGURI_BOOTSTRAP_ADMIN=admin` (or another exact existing username). The grant is idempotent and recorded in the installation audit. Remove the variable after bootstrap so later demotion or suspension is not undone by a restart. An unknown username fails startup rather than creating an account or elevating a visitor. The role survives application restarts.
The navigation entry appears only to site administrators, including accounts with no workspace memberships. The server rechecks the role for every site API request. Suspending an account ends its sessions and prevents password login; the scheduling person and approved history remain intact. Other administrators can restore it. The last active administrator cannot be suspended or demoted, including concurrent edits.
## Sections
- **Users:** search accounts, edit display names, grant/revoke the site role and suspend/restore login access. User IDs and usernames remain stable. This is not a scheduling-contact editor.
- **Workspaces:** list workspaces, rename them and grant owner access to an existing active account. Existing owners retain access. Scheduling and integration edits remain inside the workspace's existing settings.
- **Authentication:** save the future OIDC configuration, described below.
- **Administration history:** show the latest 100 administrative actions, their actor, target, changes and time. Workspace administrative edits also enter the workspace audit and versioned history.
Administrative edits require confirmation. Optimistic revisions reject stale site writes. Workspace changes additionally compare their workspace revision and update history, audit and the staged sync revision atomically. They never authorize a provider publication.
## Authentication configuration is not active yet
The form saves a typed draft under the `authentication` section. Password login, registration and the login page continue to use the existing implementation. This is stated next to the form and returned as `authentication_applied: false` by the API.
Fields: enable OIDC, allow password login, HTTPS issuer URL, client ID, environment-variable name holding the client secret, login button label, and whether SSO may create new accounts. At least one login method must be selected in the draft. The issuer cannot contain URL credentials, query parameters or fragments. For Keycloak, use the realm issuer URL, not an authorization endpoint. The future adapter will use standard discovery for authorization/token/JWKS endpoints.
The secret field accepts a reference such as `MEGURI_OIDC_CLIENT_SECRET`, never the client secret itself. Environment values are not read by the form or exposed in API responses and audit. New accounts are intended to receive no automatic workspace access or administrator role. OIDC protocol execution, identity linking and application of password-disable settings are deliberately deferred.
## Extending the settings surface
`static/site.js` owns the section registry, loading and explicit save actions; `static/site.html` contains section-specific ordinary forms. New sections add a catalog label and typed form, without changing the workspace modal system. Every built-in string is in the English/Russian/French catalog.
`site_settings` stores a section key, section revision and serialized typed payload. `AuthenticationSettings` rejects unknown fields. The independent `control` revision serializes administrative writes against stale privilege and last-admin snapshots. Add future sections as separate typed payloads and handlers; never accept unrestricted document merges from the browser. Site access and site audit have dedicated tables, separate from workspace grants, scheduling documents and provider secrets.
The browser suite accepts `MEGURI_BROWSER_SITE_USERNAME` and optional `MEGURI_BROWSER_SITE_PASSWORD`, and must run only on an isolated loopback test installation with a dedicated administrator. It edits that account and the authentication draft, and creates its own ordinary user and workspace. PostgreSQL integration tests cover permissions, suspension, stale writes, ownership and concurrent last-admin protection.
+14 -10
View File
@@ -10,9 +10,13 @@ Scheduling errors are product failures. A change to ownership, time boundaries,
| Configuration graph | `src/configuration.rs` tests | None | Dependency removal/redirect, last-step and fallback protection, pending swap cancellation, approved-history retention, exact route permutation | | Configuration graph | `src/configuration.rs` tests | None | Dependency removal/redirect, last-step and fallback protection, pending swap cancellation, approved-history retention, exact route permutation |
| CSV import | `src/holidays.rs` tests | None | Country/date keys, quoting, BOM, duplicates, conflicts, invalid rows | | CSV import | `src/holidays.rs` tests | None | Country/date keys, quoting, BOM, duplicates, conflicts, invalid rows |
| Provider compilation | `src/providers/` tests | None | Unsupported capabilities fail; normalization detects changed ownership | | Provider compilation | `src/providers/` tests | None | Unsupported capabilities fail; normalization detects changed ownership |
| Instance access, pure | `src/access.rs`, `src/identity.rs` tests | None | Configuration validation (unknown fields, key/name/group rules, sign-in method required), exact group matching and administrator-over-employee, claim paths, HMAC bootstrap passwords per hour/user/secret, contact-merge rules, username derivation |
| Application/persistence | `tests/integration.rs`, `tests/support/` | Disposable PostgreSQL | Real Cot migrations, scoped authorization, concurrent edits, atomic audit/history/outbox, approval revalidation | | Application/persistence | `tests/integration.rs`, `tests/support/` | Disposable PostgreSQL | Real Cot migrations, scoped authorization, concurrent edits, atomic audit/history/outbox, approval revalidation |
| Instance access, database | `tests/access.rs` | PostgreSQL with CREATEDB | Configuration sync (create, adopt, rename, disable, re-enable, safety refusals, ambiguity), group-derived roles and revocation, instance administrators acting without membership, employee permission set, bootstrap accounts across hours and concurrent replicas, administrator operations and workspace deletion, SSO account creation/revocation and email contact linking |
| SSO over HTTP | `tests/oidc.rs`, `tests/support/fake_oidc.rs` | PostgreSQL with CREATEDB + loopback | The real application behind a fake OpenID provider: state cookie and one-time flow, PKCE, RS256 verification, rejected issuer/audience/expiry/nonce/signature, groups from ID token and userinfo, no-groups refusal without an account, session cookies, no registration endpoint |
| Demo lifecycle | `tests/demo_lifecycle.rs` | PostgreSQL with CREATEDB | Database protection and reset, demo configuration seeding with the six accounts, retention and reseeding (own binary because the demonstration notice is process-wide) |
| HTTP provider contract | Integration suite + stateful local fake | PostgreSQL + loopback | Wire methods/payloads, partial 429 failure, checkpoint recovery, read-back, drift, external overrides, owned-only deletion | | HTTP provider contract | Integration suite + stateful local fake | PostgreSQL + loopback | Wire methods/payloads, partial 429 failure, checkpoint recovery, read-back, drift, external overrides, owned-only deletion |
| Browser/API | `tests/browser.cjs`, `tests/browser-calendars.cjs`, `tests/browser-configuration.cjs` | Seeded local app + Chromium | Forms, calendar, approval, workspace isolation, CSRF/origin, people/imports/timezones, removal dependencies, native routing drag/drop and position editing, desktop/mobile | | Browser/API | `tests/browser*.cjs` (all listed in `npm run test:browser`) | Freshly started demo + Chromium | Forms, calendar, approval, workspace isolation, CSRF/origin, people/imports/timezones, removal dependencies, native routing drag/drop and position editing, desktop/mobile; `browser-access.cjs`: demo login guide, no registration, SSO error banner, instance page and read-only rules, employee permission editing, role-based views |
| Live acceptance | Dedicated JSM test team | Test credentials | Actual provider timeline, subscription-specific escalations and routing; never ordinary CI | | Live acceptance | Dedicated JSM test team | Test credentials | Actual provider timeline, subscription-specific escalations and routing; never ordinary CI |
## Invariants ## Invariants
@@ -39,7 +43,7 @@ Exact-time JSM coverage tests model half-hour rounding for rotations and millise
Before an immediate worker tick, integration fixtures wait until their latest database-generated history timestamp is effective on the worker clock. This tolerates bounded container/host clock skew without treating a future revision as already active; skew of two seconds or more fails the fixture explicitly. Before an immediate worker tick, integration fixtures wait until their latest database-generated history timestamp is effective on the worker clock. This tolerates bounded container/host clock skew without treating a future revision as already active; skew of two seconds or more fails the fixture explicitly.
`cargo test` runs pure tests and explicitly reports database tests as ignored. CI also runs `cargo test --test integration -- --ignored --test-threads=1` with PostgreSQL 17. Fixtures use UUID names and never truncate shared tables. Use a disposable database. Run this suite serially because worker tests inspect the global queue. Worker tests explicitly prioritize only their own job so a bounded batch cannot skip it because of jobs left by a prior run. `cargo test` runs pure tests and explicitly reports database tests as ignored. With `MEGURI_TEST_DATABASE_URL` pointing at a disposable PostgreSQL 17 database whose user has CREATEDB, CI and developers run everything with `cargo test --locked --tests -- --ignored --test-threads=1`. Scheduling fixtures create uniquely keyed workspaces and users through the real sync and group path and never truncate shared tables; tests whose subject is whole-instance state (sync, bootstrap, administration, SSO) use `IsolatedDatabase`, a uniquely named database dropped at the end, because sync disables every unconfigured workspace. Run serially because worker tests inspect the global queue. Worker tests explicitly prioritize only their own job so a bounded batch cannot skip it because of jobs left by a prior run. `tests/oidc.rs` installs process-wide configuration and therefore holds one test function.
The fake models JSM responses and is not a substitute for live acceptance. New endpoints need verification against official documentation, a wire assertion, and a failure/recovery test. Do not make the fake accept an invented contract to get tests green. The fake models JSM responses and is not a substitute for live acceptance. New endpoints need verification against official documentation, a wire assertion, and a failure/recovery test. Do not make the fake accept an invented contract to get tests green.
@@ -111,13 +115,13 @@ Binding selection uses one anchored, viewport-clamped popover beside the origina
`browser-repeat-defaults.cjs` verifies absent escalation defaults render as editable numbers, edited values survive reopening the draft, and explicit dictionary lookups remain intact. `browser-repeat-defaults.cjs` verifies absent escalation defaults render as editable numbers, edited values survive reopening the draft, and explicit dictionary lookups remain intact.
The main Acme Engineering demo contains six services (Payments, Search, Billing, Identity, Data platform, Developer portal), each with two or three ordered owners drawn from five login members: alice, bob, carol, dave, erin. All five share the weekly Monday 11:00 Time off rotation. Dave and Erin only access Acme; Carol also retains Support/Commerce access. Other demo workspaces remain separate. Login password remains `meguri-demo-only`. The main Acme Engineering demo contains six services (Payments, Search, Billing, Identity, Data platform, Developer portal), each with two or three ordered owners drawn from five login members: alice, bob, carol, dave, erin. All five share the weekly Monday 11:00 Time off rotation. Dave and Erin only access Acme; Carol also has Support/Commerce access through her groups. Other demo workspaces remain separate. The login password is the one generated for the run.
Generation rule results use the actual authorized server expansion, including schedules, escalation policies and routes, with resolved names and compact settings. The panel is always present with explicit checking, invalid, disabled and empty states; it respects iteration filters and does not simulate generation in the browser. Preview data contains only scheduling resources and scoped people, never integration configuration. Generation rule results use the actual authorized server expansion, including schedules, escalation policies and routes, with resolved names and compact settings. The panel is always present with explicit checking, invalid, disabled and empty states; it respects iteration filters and does not simulate generation in the browser. Preview data contains only scheduling resources and scoped people, never integration configuration.
Permanent assignees are tested as single-participant rotations, including absence, replacement approval and publication blocking. The removed fixed-assignee schedule and generation types are rejected rather than normalized. Permanent assignees are tested as single-participant rotations, including absence, replacement approval and publication blocking. The removed fixed-assignee schedule and generation types are rejected rather than normalized.
Workspace isolation regressions exercise independent memberships and variables, a single revision for configuration and working-time changes, read-only generation preview, stale review rejection, atomic activation/history/outbox, and owner settings permissions. Workspace isolation regressions exercise independent memberships and variables, a single revision for configuration and working-time changes, read-only generation preview, stale review rejection, atomic activation/history/outbox, and workspace-administrator settings permissions.
The default Engineering example stores projects as one list of dictionaries: each entry has `name` and an ordered `responsible` person list. The Project iteration reads both fields directly from its current entry; no parallel responsible lookup variable is needed. The independent `on_call` list still defines the shared weekly rotation. The default Engineering example stores projects as one list of dictionaries: each entry has `name` and an ordered `responsible` person list. The Project iteration reads both fields directly from its current entry; no parallel responsible lookup variable is needed. The independent `on_call` list still defines the shared weekly rotation.
@@ -133,6 +137,12 @@ The map’s all-passes input overview lists deduplicated upstream sources, not e
Expanded map resources use type-specific labeled fields and the same compact reference cards as inputs, never JSON dumps. Priority schedules show ordered people, fallback and zoned handover; rotations add anchor/cycle; policies show recipient/timing/condition and repeat behavior; routes show conditions, working window and destination. Reference names resolve from the preview snapshot. Browser regression checks visible names, participant order and absence of UUID metadata. Expanded map resources use type-specific labeled fields and the same compact reference cards as inputs, never JSON dumps. Priority schedules show ordered people, fallback and zoned handover; rotations add anchor/cycle; policies show recipient/timing/condition and repeat behavior; routes show conditions, working window and destination. Reference names resolve from the preview snapshot. Browser regression checks visible names, participant order and absence of UUID metadata.
## Access and identity regressions
Authorization boundaries are product failures like scheduling ones. A change to configuration sync, group mapping, roles, SSO, bootstrap or administration needs a regression in `tests/access.rs`, `tests/oidc.rs` or the pure tests. Existing cases pin: exact (not prefix/case-insensitive) group matching; membership removal when a group or workspace disappears, including for open sessions; instance administrators acting on workspaces without membership and being audited under their own ID; employees unable to administer integrations or members; the employee permission set applying to existing and future employees at once, with `view` mandatory and administrator-only permissions refused; refusal of a configuration that would disable everything and of ambiguous adoption before any write; bootstrap passwords equal for replicas in one UTC hour, rotated (ending sessions) in the next, and locked out when the block is removed; no SSO account takeover by a bootstrap name; SSO accounts created only when groups map, revoked at the next sign-in, never given a password; administrators unable to edit their own groups or SSO groups; deletion only of disabled, unconfigured workspaces with the typed key; contact linking rewriting the current document while history keeps the old ID and audit still resolves it, skipping ambiguous or conflicting data and never granting access.
The browser access suite also runs against the real demo. No test may weaken these checks to make a failing sign-in pass; fix the cause or record the limitation.
JSM deletion regressions remove checkpointed schedules (including coverage), escalations, routes and individual managed overrides, then republish with drift blocking enabled. Verify replacement IDs, refreshed dependent references, lost replacement-response recovery, no child reads against missing parents, and an unchanged subsequent publication without duplicate writes. JSM deletion regressions remove checkpointed schedules (including coverage), escalations, routes and individual managed overrides, then republish with drift blocking enabled. Verify replacement IDs, refreshed dependent references, lost replacement-response recovery, no child reads against missing parents, and an unchanged subsequent publication without duplicate writes.
## Explicit publication and shared drafts ## Explicit publication and shared drafts
@@ -187,10 +197,4 @@ The Import / Export dialog includes a localized Guide tab with a complete compac
Random team escalation regression coverage includes generator compilation with an unused invalid binding, conflicting recipient flags, portable v1/v2 round trips, fake-JSM `notifyType: random`, and browser selection/shared-draft reload. Random team escalation regression coverage includes generator compilation with an unused invalid binding, conflicting recipient flags, portable v1/v2 round trips, fake-JSM `notifyType: random`, and browser selection/shared-draft reload.
Site administration tests cover denied non-admin access, explicit bootstrap, suspension/session invalidation, stale writes, last-admin protection, workspace owner changes without ambient membership, authentication draft validation and audit. Saving disabled-password/OIDC settings must not alter current authentication until the identity adapter is implemented. Browser checks cover navigation without workspace membership, user/workspace editing, settings persistence and mobile layout.
Contact approval regressions cover account-to-contact immediate application, contact-to-account pending approval, contact-only absence planning by an editor, actor permissions, and unchanged account/membership counts. Existing regressions retain account-holder approval, swap and workspace isolation checks. `tests/browser-contact-approval.cjs` verifies immediate application without a force flag, readable preview, and calendar persistence. Contact approval regressions cover account-to-contact immediate application, contact-to-account pending approval, contact-only absence planning by an editor, actor permissions, and unchanged account/membership counts. Existing regressions retain account-holder approval, swap and workspace isolation checks. `tests/browser-contact-approval.cjs` verifies immediate application without a force flag, readable preview, and calendar persistence.
Account/person regressions verify mandatory normalized unique account emails, account-owned Person IDs, canonical directory/export email, prevention of workspace-level identity edits, and no privileges or automatic contact claiming on password signup. Migration must preserve IDs/history and reject missing or ambiguous legacy emails without partial writes. Browser signup checks required email and account profile display.
The preparatory contact/account migration runs before account email enforcement on legacy databases. An explicit deployment email mapping authorizes association only in workspaces where the account is already a member. It rewrites current scheduling references, person-valued generator inputs, shared drafts and explicit provider identity keys, retaining resource IDs and old history/publication checkpoints. Missing/ambiguous mappings, conflicting provider identities or collapsed invalid schedules/replacements block the repair before writes. Already email-migrated installations are skipped. Regression coverage includes migration ordering, retry after failure, generator drafts and unchanged access/history.
+5
View File
@@ -0,0 +1,5 @@
.git
.gitignore
ci/
*.swp
*.bak
+7
View File
@@ -0,0 +1,7 @@
apiVersion: v2
name: meguri
description: On-call schedules, replacements and escalation policies as a source of truth
type: application
version: 0.3.0
appVersion: "0.3.0"
kubeVersion: ">=1.29.0-0"
+79
View File
@@ -0,0 +1,79 @@
# Meguri Helm chart
Deploys the web/worker Deployment, Service, optional Ingress and PodDisruptionBudget, and runs a
**pre-install/pre-upgrade migrate Job** that applies Cot migrations and the declarative instance
configuration *before* the Deployment is updated. The chart contains no secrets and no Helm-managed
database. Requires Kubernetes 1.29+ (native sidecars) and Helm 3.
## What you provide
1. An image: `ultradesu/meguri:latest` by default (build your own from the repository `Dockerfile` and set `image.repository` / `image.tag`; pin a tag for reproducible releases).
2. A Secret in the release namespace, named by `secretName`. All of its keys become environment
variables:
```sh
kubectl -n meguri create secret generic meguri-secrets \
--from-literal=DATABASE_URL='postgresql://USER:PERCENT_ENCODED_PASSWORD@HOST:5432/meguri' \
--from-literal=MEGURI_OIDC_CLIENT_SECRET='...'
```
Per-tenant provider credentials (`MEGURI_JSM_<TENANT>_EMAIL` / `_TOKEN`) go in the same Secret. `extraSecretNames` lists further Secrets injected the same way, for example one that holds only `MEGURI_OIDC_CLIENT_SECRET`.
3. Your values. The two required ones are `publicUrl` (the exact browser origin) and `secretName`;
everything about people and access lives under `instanceConfig`.
## Install and upgrade
```sh
helm upgrade --install meguri ./helm/meguri -n meguri --create-namespace -f my-values.yaml
kubectl -n meguri logs job/meguri-migrate-1 # what the sync did; revision number increments
```
`instanceConfig` is rendered verbatim into `/etc/meguri/config.yaml` (field reference:
`deploy/instance-config.example.yaml`, behavior: `docs/deployment.md`). The chart schema rejects
unknown fields and obviously invalid values at `helm lint`/`install`; the application validates it
again, and a rejected configuration fails the migrate Job, so the release stops before any pod
changes. A configuration change rolls the Deployment (checksum annotation) after the Job succeeded.
```yaml
publicUrl: https://meguri.example.internal
secretName: meguri-secrets
image: {repository: ultradesu/meguri, tag: "1.4.0"}
instanceConfig:
auth: {password_login: false}
oidc:
issuer: https://sso.example.internal/realms/corp
client_id: meguri
instance_admin_groups: [/meguri/admins]
workspaces:
- key: payments
name: Payments
admin_groups: [/payments/leads]
member_groups: [/payments/team]
- key: customer-support
name: Customer Support
admin_groups: [/support/leads]
member_groups: [/support/team, /support/contractors]
```
Removing a workspace from `workspaces` **disables** it at the next release (data is kept; an
instance administrator can delete it permanently). Giving a new team access is one more list entry.
## Break-glass administrator
Add `instanceConfig.auth.bootstrap_admin.usernames: [rescue]` and upgrade. The generated password is
printed in the migrate Job log (`kubectl logs job/meguri-migrate-N`) and rotates each UTC hour; anyone who can read
that log can sign in as an instance administrator, so remove the block as soon as SSO works again.
Finished Jobs are deleted after `migrate.ttlSecondsAfterFinished` (a day).
## Notable values
| Value | Purpose |
|---|---|
| `replicaCount`, `resources`, `podDisruptionBudget` | Sizing and availability (publication is serialized by database leases, so replicas are safe). |
| `cloudSqlProxy.*` | Cloud SQL Auth Proxy native sidecar for the app and the Job; point `DATABASE_URL` at `127.0.0.1:5432`. |
| `serviceAccount.create` / `.annotations` | Workload Identity account, created once at install (the pre-install hook needs it). Later annotation changes need `kubectl annotate`. |
| `ingress.*` | Host and TLS follow `publicUrl`. |
| `allowDisableAll` | Permit a release whose `workspaces` list is empty to disable every workspace. |
| `extraEnv`, `nodeSelector`, `tolerations`, `affinity`, `topologySpreadConstraints` | Usual scheduling and environment hooks. |
The demonstration mode is not available in the chart: it only runs on loopback.
+14
View File
@@ -0,0 +1,14 @@
# Values for `helm lint` and `helm template` in CI; not a deployment example.
publicUrl: https://meguri.example.internal
secretName: meguri-secrets
serviceAccount:
create: true
annotations:
iam.gke.io/gcp-service-account: meguri@project.iam.gserviceaccount.com
cloudSqlProxy:
enabled: true
instanceConnectionName: project:region:instance
ingress:
enabled: true
className: nginx
tlsSecretName: meguri-tls
+17
View File
@@ -0,0 +1,17 @@
Meguri {{ .Chart.AppVersion }} is released as {{ .Release.Name }}.
Open {{ .Values.publicUrl }} (register {{ .Values.publicUrl | trimSuffix "/" }}/api/oidc/callback as the redirect URI of the OIDC client).
Workspaces and group mappings come only from `instanceConfig`; the migrate Job applied them
before the Deployment was updated. See what it did:
kubectl -n {{ .Release.Namespace }} logs job/{{ include "meguri.fullname" . }}-migrate-{{ .Release.Revision }}
{{- if (.Values.instanceConfig.auth).bootstrap_admin }}
WARNING: bootstrap_admin is enabled. Its generated password is in that Job log, so anyone who can
read the log can sign in as an instance administrator. Remove the block when SSO works again.
{{- end }}
{{- if not .Values.instanceConfig.workspaces }}
No workspaces are configured.
{{- end }}
+84
View File
@@ -0,0 +1,84 @@
{{- define "meguri.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 40 | trimSuffix "-" -}}
{{- end -}}
{{- define "meguri.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 40 | trimSuffix "-" -}}
{{- else if contains .Chart.Name .Release.Name -}}
{{- .Release.Name | trunc 40 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 40 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- define "meguri.selectorLabels" -}}
app.kubernetes.io/name: {{ include "meguri.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}
{{- define "meguri.labels" -}}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{ include "meguri.selectorLabels" . }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end -}}
{{- define "meguri.image" -}}
{{- printf "%s:%s" .Values.image.repository (default .Chart.AppVersion .Values.image.tag) -}}
{{- end -}}
{{- define "meguri.serviceAccountName" -}}
{{- if .Values.serviceAccount.create -}}
{{- default (include "meguri.fullname" .) .Values.serviceAccount.name -}}
{{- else -}}
{{- default "default" .Values.serviceAccount.name -}}
{{- end -}}
{{- end -}}
{{/* The configuration file exactly as the application reads it. */}}
{{- define "meguri.instanceConfig" -}}
{{- toYaml .Values.instanceConfig -}}
{{- end -}}
{{/* Fails the render early with a readable message. Outputs nothing. */}}
{{- define "meguri.requirements" -}}
{{- $_ := required "publicUrl is required: the exact browser origin, for example https://meguri.example.internal" .Values.publicUrl -}}
{{- $_ := required "secretName is required: a Secret with DATABASE_URL (and MEGURI_OIDC_CLIENT_SECRET when oidc is configured)" .Values.secretName -}}
{{- end -}}
{{- define "meguri.cloudSqlProxy" -}}
- name: cloud-sql-proxy
restartPolicy: Always
image: {{ .Values.cloudSqlProxy.image | quote }}
args:
{{- if .Values.cloudSqlProxy.privateIp }}
- --private-ip
{{- end }}
- --structured-logs
- --port={{ .Values.cloudSqlProxy.port }}
- --health-check
- --http-address=0.0.0.0
- --http-port=9090
- {{ required "cloudSqlProxy.instanceConnectionName is required when the proxy is enabled" .Values.cloudSqlProxy.instanceConnectionName | quote }}
securityContext:
allowPrivilegeEscalation: false
capabilities: {drop: [ALL]}
resources:
{{- toYaml .Values.cloudSqlProxy.resources | nindent 4 }}
startupProbe:
httpGet: {path: /startup, port: 9090}
periodSeconds: 2
failureThreshold: 30
{{- end -}}
{{/* Environment shared by the application and the migrate Job. */}}
{{- define "meguri.env" -}}
- {name: MEGURI_BIND, value: "0.0.0.0:3000"}
- {name: MEGURI_PUBLIC_URL, value: {{ .Values.publicUrl | trimSuffix "/" | quote }}}
- {name: MEGURI_CONFIG_FILE, value: /etc/meguri/config.yaml}
- {name: RUST_LOG, value: {{ .Values.logLevel | quote }}}
{{- with .Values.extraEnv }}
{{ toYaml . }}
{{- end }}
{{- end -}}
+25
View File
@@ -0,0 +1,25 @@
{{- include "meguri.requirements" . -}}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "meguri.fullname" . }}-instance-config
labels:
{{- include "meguri.labels" . | nindent 4 }}
data:
config.yaml: |
{{- include "meguri.instanceConfig" . | nindent 4 }}
---
{{- /* The migrate Job runs before ordinary resources are updated, so it reads its own copy. */}}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "meguri.fullname" . }}-instance-config-migrate
labels:
{{- include "meguri.labels" . | nindent 4 }}
annotations:
helm.sh/hook: pre-install,pre-upgrade
helm.sh/hook-weight: "-10"
helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
data:
config.yaml: |
{{- include "meguri.instanceConfig" . | nindent 4 }}
+89
View File
@@ -0,0 +1,89 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "meguri.fullname" . }}
labels:
{{- include "meguri.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "meguri.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: app
template:
metadata:
labels:
{{- include "meguri.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: app
{{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
annotations:
checksum/instance-config: {{ include "meguri.instanceConfig" . | sha256sum }}
{{- with .Values.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
serviceAccountName: {{ include "meguri.serviceAccountName" . }}
terminationGracePeriodSeconds: 60
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
securityContext:
runAsNonRoot: true
seccompProfile: {type: RuntimeDefault}
{{- if .Values.cloudSqlProxy.enabled }}
initContainers:
{{- include "meguri.cloudSqlProxy" . | nindent 8 }}
{{- end }}
volumes:
- name: instance-config
configMap:
name: {{ include "meguri.fullname" . }}-instance-config
containers:
- name: meguri
image: {{ include "meguri.image" . | quote }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports: [{name: http, containerPort: 3000}]
envFrom:
- secretRef: {name: {{ required "secretName is required" .Values.secretName | quote }}}
{{- range .Values.extraSecretNames }}
- secretRef: {name: {{ . | quote }}}
{{- end }}
env:
{{- include "meguri.env" . | nindent 12 }}
- {name: MEGURI_ROLE, value: {{ .Values.role | quote }}}
- {name: MEGURI_SKIP_MIGRATIONS, value: "true"}
volumeMounts:
- {name: instance-config, mountPath: /etc/meguri, readOnly: true}
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities: {drop: [ALL]}
resources:
{{- toYaml .Values.resources | nindent 12 }}
startupProbe:
httpGet: {path: /health/ready, port: http}
periodSeconds: 2
failureThreshold: 60
readinessProbe:
httpGet: {path: /health/ready, port: http}
livenessProbe:
httpGet: {path: /health/live, port: http}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.topologySpreadConstraints }}
topologySpreadConstraints:
{{- toYaml . | nindent 8 }}
{{- end }}
+32
View File
@@ -0,0 +1,32 @@
{{- if .Values.ingress.enabled }}
{{- $url := urlParse (required "publicUrl is required" .Values.publicUrl) }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "meguri.fullname" . }}
labels:
{{- include "meguri.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- with .Values.ingress.className }}
ingressClassName: {{ . }}
{{- end }}
{{- if .Values.ingress.tlsSecretName }}
tls:
- hosts: [{{ $url.hostname | quote }}]
secretName: {{ .Values.ingress.tlsSecretName }}
{{- end }}
rules:
- host: {{ $url.hostname | quote }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ include "meguri.fullname" . }}
port: {name: http}
{{- end }}
+74
View File
@@ -0,0 +1,74 @@
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "meguri.fullname" . }}-migrate-{{ .Release.Revision }}
labels:
{{- include "meguri.labels" . | nindent 4 }}
app.kubernetes.io/component: migrate
annotations:
helm.sh/hook: pre-install,pre-upgrade
helm.sh/hook-weight: "0"
helm.sh/hook-delete-policy: before-hook-creation
spec:
backoffLimit: 0
activeDeadlineSeconds: {{ .Values.migrate.activeDeadlineSeconds }}
ttlSecondsAfterFinished: {{ .Values.migrate.ttlSecondsAfterFinished }}
template:
metadata:
labels:
{{- include "meguri.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: migrate
spec:
restartPolicy: Never
serviceAccountName: {{ include "meguri.serviceAccountName" . }}
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
securityContext:
runAsNonRoot: true
seccompProfile: {type: RuntimeDefault}
{{- if .Values.cloudSqlProxy.enabled }}
initContainers:
{{- include "meguri.cloudSqlProxy" . | nindent 8 }}
{{- end }}
volumes:
- name: instance-config
configMap:
name: {{ include "meguri.fullname" . }}-instance-config-migrate
containers:
- name: migrate
image: {{ include "meguri.image" . | quote }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
envFrom:
- secretRef: {name: {{ required "secretName is required" .Values.secretName | quote }}}
{{- range .Values.extraSecretNames }}
- secretRef: {name: {{ . | quote }}}
{{- end }}
env:
{{- include "meguri.env" . | nindent 12 }}
- {name: MEGURI_ROLE, value: migrate}
- {name: MEGURI_SKIP_MIGRATIONS, value: "false"}
{{- if .Values.allowDisableAll }}
- {name: MEGURI_ALLOW_DISABLE_ALL, value: "true"}
{{- end }}
volumeMounts:
- {name: instance-config, mountPath: /etc/meguri, readOnly: true}
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities: {drop: [ALL]}
resources:
{{- toYaml .Values.migrate.resources | nindent 12 }}
+14
View File
@@ -0,0 +1,14 @@
{{- if and .Values.podDisruptionBudget.enabled (gt (int .Values.replicaCount) 1) }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "meguri.fullname" . }}
labels:
{{- include "meguri.labels" . | nindent 4 }}
spec:
minAvailable: {{ .Values.podDisruptionBudget.minAvailable }}
selector:
matchLabels:
{{- include "meguri.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: app
{{- end }}
+13
View File
@@ -0,0 +1,13 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "meguri.fullname" . }}
labels:
{{- include "meguri.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
selector:
{{- include "meguri.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: app
ports:
- {name: http, port: {{ .Values.service.port }}, targetPort: http}
+16
View File
@@ -0,0 +1,16 @@
{{- if .Values.serviceAccount.create }}
{{- /* A hook, because the pre-install migrate Job needs it before ordinary resources exist. */}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "meguri.serviceAccountName" . }}
labels:
{{- include "meguri.labels" . | nindent 4 }}
annotations:
helm.sh/hook: pre-install
helm.sh/hook-weight: "-20"
helm.sh/hook-delete-policy: before-hook-creation
{{- with .Values.serviceAccount.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}
+88
View File
@@ -0,0 +1,88 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["publicUrl", "secretName", "instanceConfig"],
"properties": {
"image": {
"type": "object",
"required": ["repository"],
"properties": {
"repository": {"type": "string", "minLength": 1},
"tag": {"type": "string"},
"pullPolicy": {"enum": ["Always", "IfNotPresent", "Never"]}
}
},
"publicUrl": {"type": "string", "pattern": "^https?://[^/\\s]+/?$"},
"secretName": {"type": "string", "minLength": 1},
"extraSecretNames": {"type": "array", "items": {"type": "string", "minLength": 1}},
"replicaCount": {"type": "integer", "minimum": 1},
"role": {"enum": ["all", "web", "worker"]},
"logLevel": {"type": "string"},
"allowDisableAll": {"type": "boolean"},
"cloudSqlProxy": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"instanceConnectionName": {"type": "string"},
"port": {"type": "integer", "minimum": 1, "maximum": 65535}
}
},
"instanceConfig": {
"type": "object",
"additionalProperties": false,
"properties": {
"auth": {
"type": "object",
"additionalProperties": false,
"properties": {
"password_login": {"type": "boolean"},
"bootstrap_admin": {
"type": "object",
"additionalProperties": false,
"required": ["usernames"],
"properties": {
"usernames": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {"type": "string", "pattern": "^[a-z0-9._-]{3,64}$"}
}
}
}
}
},
"oidc": {
"type": "object",
"additionalProperties": false,
"required": ["issuer", "client_id"],
"properties": {
"issuer": {"type": "string", "pattern": "^https://[^\\s]+$"},
"client_id": {"type": "string", "minLength": 1},
"scopes": {"type": "array", "items": {"type": "string"}, "contains": {"const": "openid"}},
"groups_claim": {"type": "string", "minLength": 1},
"groups_sources": {
"type": "array",
"minItems": 1,
"items": {"enum": ["id_token", "userinfo"]}
}
}
},
"instance_admin_groups": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}},
"workspaces": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["key", "name", "admin_groups"],
"properties": {
"key": {"type": "string", "pattern": "^[a-z0-9]([a-z0-9-]{1,62})[a-z0-9]$"},
"name": {"type": "string", "minLength": 1, "maxLength": 120},
"admin_groups": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}},
"member_groups": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}
}
}
}
}
}
}
}
+116
View File
@@ -0,0 +1,116 @@
# Meguri Helm chart. Secrets are never part of the chart: create the Secret named by
# `secretName` first (see README.md). Everything under `instanceConfig` is rendered verbatim into
# the instance configuration file; the chart schema and the application validate it strictly.
image:
repository: ultradesu/meguri
# Pin an immutable tag or digest for reproducible releases; `latest` is re-pulled on every restart.
tag: latest
pullPolicy: Always
imagePullSecrets: []
# Exact browser origin (scheme, host, port) of the service. Cookies, CSRF checks and the OIDC
# redirect URI ({publicUrl}/api/oidc/callback) depend on it. Required.
publicUrl: ""
# Name of a Secret holding at least DATABASE_URL (postgresql://USER:PERCENT_ENCODED_PASSWORD@HOST:5432/DB)
# and, when instanceConfig.oidc is set, MEGURI_OIDC_CLIENT_SECRET. All of its keys become environment
# variables, so per-tenant provider credentials (MEGURI_JSM_<TENANT>_EMAIL / _TOKEN) belong there too.
# Required.
secretName: ""
# Further Secrets whose keys are also injected as environment variables (for example a separate
# MEGURI_OIDC_CLIENT_SECRET kept apart from the database credentials).
extraSecretNames: []
replicaCount: 2
# all = web and publication worker in each replica; web = HTTP only (add a separate release
# with role worker if you split them).
role: all
logLevel: meguri=info,cot=warn
# Disables every existing workspace when instanceConfig.workspaces is empty. Leave false.
allowDisableAll: false
# The instance configuration. Written to /etc/meguri/config.yaml. The migrate hook applies it at
# every install and upgrade: workspaces are created, adopted, renamed, enabled or DISABLED (never
# deleted) so that the database matches this list. Field reference: deploy/instance-config.example.yaml.
instanceConfig:
auth:
# Lets ordinary password accounts (created by an instance administrator) sign in.
password_login: false
# Break-glass instance administrators. Remove in production. While present, their generated
# password is written to the migrate Job log.
# bootstrap_admin:
# usernames: [rescue]
oidc:
issuer: https://sso.example.internal/realms/corp
client_id: meguri
scopes: [openid, profile, email]
groups_claim: groups
groups_sources: [id_token, userinfo]
instance_admin_groups:
- /meguri/admins
workspaces:
- key: payments
name: Payments
admin_groups:
- /payments/leads
member_groups:
- /payments/team
migrate:
# Runs the Cot migrations and the configuration sync before the Deployment is touched.
activeDeadlineSeconds: 300
# Finished Jobs (and the log that may contain bootstrap passwords) are removed after this long.
ttlSecondsAfterFinished: 86400
resources:
requests: {cpu: 250m, memory: 128Mi}
limits: {memory: 256Mi}
resources:
requests: {cpu: 250m, memory: 256Mi}
limits: {memory: 512Mi}
# Optional Cloud SQL Auth Proxy as a native sidecar (Kubernetes 1.29+), for the application and
# the migrate Job. Point DATABASE_URL at 127.0.0.1:<port>.
cloudSqlProxy:
enabled: false
image: gcr.io/cloud-sql-connectors/cloud-sql-proxy:2.14.1
instanceConnectionName: "" # PROJECT:REGION:INSTANCE
privateIp: true
port: 5432
resources:
requests: {cpu: 100m, memory: 128Mi}
limits: {memory: 256Mi}
serviceAccount:
# Created once, at install, because the migrate hook runs before ordinary resources exist.
# Later annotation changes need `kubectl annotate`. Leave create false to use an existing
# account named `name` (or the namespace default).
create: false
name: ""
annotations: {}
# iam.gke.io/gcp-service-account: meguri@PROJECT.iam.gserviceaccount.com
service:
type: ClusterIP
port: 80
ingress:
enabled: false
className: ""
annotations: {}
# Host and TLS follow publicUrl. Set tlsSecretName for an https publicUrl terminated at the ingress.
tlsSecretName: ""
podDisruptionBudget:
enabled: true
minAvailable: 1
podAnnotations: {}
podLabels: {}
extraEnv: []
nodeSelector: {}
tolerations: []
affinity: {}
topologySpreadConstraints: []
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "meguri-browser-tests", "name": "meguri-browser-tests",
"private": true, "private": true,
"scripts": { "scripts": {
"test:browser": "node tests/browser-account-person.cjs && node tests/browser-contact-approval.cjs && node tests/browser-portable-config.cjs && node tests/browser-generation-team.cjs && node tests/browser-binding-path.cjs && node tests/browser-manual-sync.cjs && node tests/browser-generation-map.cjs && node tests/browser-variable-editor.cjs && node tests/browser-rule-result.cjs && node tests/browser-repeat-defaults.cjs && node tests/browser-binding-popover.cjs && node tests/browser-resource-details.cjs && node tests/browser-coverage-status.cjs && node tests/browser-diagnostics.cjs && node tests/browser-advanced-demos.cjs && node tests/browser.cjs && node tests/browser-calendars.cjs && node tests/browser-configuration.cjs && node tests/browser-absence.cjs && node tests/browser-generation.cjs && node tests/browser-cover.cjs && node tests/browser-navigation.cjs && node tests/browser-binding-preview.cjs && node tests/browser-generation-scroll.cjs && node tests/browser-setup-layout.cjs" "test:browser": "node tests/browser-account-person.cjs && node tests/browser-contact-approval.cjs && node tests/browser-portable-config.cjs && node tests/browser-generation-team.cjs && node tests/browser-binding-path.cjs && node tests/browser-manual-sync.cjs && node tests/browser-generation-map.cjs && node tests/browser-variable-editor.cjs && node tests/browser-rule-result.cjs && node tests/browser-repeat-defaults.cjs && node tests/browser-binding-popover.cjs && node tests/browser-resource-details.cjs && node tests/browser-coverage-status.cjs && node tests/browser-diagnostics.cjs && node tests/browser-advanced-demos.cjs && node tests/browser.cjs && node tests/browser-calendars.cjs && node tests/browser-configuration.cjs && node tests/browser-absence.cjs && node tests/browser-generation.cjs && node tests/browser-cover.cjs && node tests/browser-navigation.cjs && node tests/browser-binding-preview.cjs && node tests/browser-generation-scroll.cjs && node tests/browser-setup-layout.cjs && node tests/browser-access.cjs"
}, },
"devDependencies": { "devDependencies": {
"playwright": "1.62.1" "playwright": "1.62.1"
+2 -2
View File
@@ -40,10 +40,10 @@ Push-Location $repo
try { cargo build --locked; if ($LASTEXITCODE) { throw 'Build failed' } } finally { Pop-Location } try { cargo build --locked; if ($LASTEXITCODE) { throw 'Build failed' } } finally { Pop-Location }
$env:MEGURI_BIND = "127.0.0.1:$Port" $env:MEGURI_BIND = "127.0.0.1:$Port"
$env:MEGURI_PUBLIC_URL = "http://127.0.0.1:$Port" $env:MEGURI_PUBLIC_URL = "http://127.0.0.1:$Port"
$env:MEGURI_ALLOW_SIGNUP = 'true'
$env:MEGURI_SEED_DEMO = 'true' $env:MEGURI_SEED_DEMO = 'true'
$env:MEGURI_ROLE = 'all' $env:MEGURI_ROLE = 'all'
Copy-Item -LiteralPath (Join-Path $repo 'target/debug/meguri.exe') -Destination $appExecutable Copy-Item -LiteralPath (Join-Path $repo 'target/debug/meguri.exe') -Destination $appExecutable
$app = Start-Process -FilePath $appExecutable -WorkingDirectory $repo -WindowStyle Hidden -PassThru -RedirectStandardOutput (Join-Path $localDir 'app.log') -RedirectStandardError (Join-Path $localDir 'app-error.log') $app = Start-Process -FilePath $appExecutable -WorkingDirectory $repo -WindowStyle Hidden -PassThru -RedirectStandardOutput (Join-Path $localDir 'app.log') -RedirectStandardError (Join-Path $localDir 'app-error.log')
$app.Id | Set-Content $pidFile $app.Id | Set-Content $pidFile
Write-Output "Meguri starting at $env:MEGURI_PUBLIC_URL — demo: alice / meguri-demo-only" Write-Output "Meguri starting at $env:MEGURI_PUBLIC_URL as a disposable demo. Accounts: admin (instance administrator), alice (workspace administrator), bob, carol, dave, erin (employees)."
Write-Output "One password is generated on every start: it is shown on the login screen and logged in $(Join-Path $localDir 'app.log')."
+544
View File
@@ -0,0 +1,544 @@
//! Pure instance access model: declarative configuration, group-to-role resolution and
//! bootstrap passwords. No HTTP or database dependencies.
use crate::error::{Error, Result};
use chrono::{DateTime, Utc};
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::Sha256;
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default)]
pub auth: AuthConfig,
#[serde(default)]
pub oidc: Option<OidcConfig>,
#[serde(default)]
pub instance_admin_groups: Vec<String>,
#[serde(default)]
pub workspaces: Vec<WorkspaceConfig>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct AuthConfig {
/// Allows every password account to sign in. Bootstrap accounts always may.
#[serde(default)]
pub password_login: bool,
/// Break-glass instance administrators with generated passwords. Omit in production.
#[serde(default)]
pub bootstrap_admin: Option<BootstrapConfig>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct BootstrapConfig {
pub usernames: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GroupSource {
IdToken,
Userinfo,
}
fn default_scopes() -> Vec<String> {
vec!["openid".into(), "profile".into(), "email".into()]
}
fn default_groups_claim() -> String {
"groups".into()
}
fn default_group_sources() -> Vec<GroupSource> {
vec![GroupSource::IdToken, GroupSource::Userinfo]
}
/// The client secret is a deployment secret (`MEGURI_OIDC_CLIENT_SECRET`), never configuration.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct OidcConfig {
pub issuer: String,
pub client_id: String,
#[serde(default = "default_scopes")]
pub scopes: Vec<String>,
/// Dotted path into the token claims, for example `groups` or `realm_access.roles`.
#[serde(default = "default_groups_claim")]
pub groups_claim: String,
#[serde(default = "default_group_sources")]
pub groups_sources: Vec<GroupSource>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceConfig {
pub key: String,
pub name: String,
#[serde(default)]
pub admin_groups: Vec<String>,
#[serde(default)]
pub member_groups: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Role {
Employee,
Admin,
}
/// What a set of groups grants. Administrators win over employees in the same workspace.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Access {
pub instance_admin: bool,
pub workspaces: BTreeMap<String, Role>,
}
impl Access {
pub fn is_empty(&self) -> bool {
!self.instance_admin && self.workspaces.is_empty()
}
}
fn invalid(message: impl Into<String>) -> Error {
Error::Validation(format!("Invalid configuration: {}", message.into()))
}
pub fn valid_username(username: &str) -> bool {
(3..=64).contains(&username.len())
&& username
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "._-".contains(c))
}
fn valid_key(key: &str) -> bool {
(3..=64).contains(&key.len())
&& key
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
&& !key.starts_with('-')
&& !key.ends_with('-')
}
fn validate_groups(owner: &str, groups: &[String]) -> Result<()> {
let mut seen = BTreeSet::new();
for group in groups {
if group.trim().is_empty() || group.len() > 256 || group.chars().any(char::is_control) {
return Err(invalid(format!(
"{owner} has an empty, oversized or unprintable group name"
)));
}
if !seen.insert(group) {
return Err(invalid(format!("{owner} lists group {group:?} twice")));
}
}
Ok(())
}
impl Config {
pub fn parse(text: &str) -> Result<Self> {
let config: Self =
serde_yaml_ng::from_str(text).map_err(|e| invalid(format!("cannot parse: {e}")))?;
config.validate()?;
Ok(config)
}
pub fn validate(&self) -> Result<()> {
if let Some(oidc) = &self.oidc {
let issuer = reqwest::Url::parse(&oidc.issuer)
.map_err(|_| invalid("oidc.issuer must be an absolute URL"))?;
let loopback = matches!(issuer.host_str(), Some("127.0.0.1" | "localhost" | "[::1]"));
if issuer.scheme() != "https" && !(issuer.scheme() == "http" && loopback) {
return Err(invalid("oidc.issuer must use HTTPS outside localhost"));
}
if oidc.client_id.trim().is_empty() {
return Err(invalid("oidc.client_id is required"));
}
if !oidc.scopes.iter().any(|s| s == "openid") {
return Err(invalid("oidc.scopes must include openid"));
}
if oidc.groups_claim.trim().is_empty() {
return Err(invalid("oidc.groups_claim is required"));
}
if oidc.groups_sources.is_empty() {
return Err(invalid("oidc.groups_sources needs at least one source"));
}
}
if let Some(bootstrap) = &self.auth.bootstrap_admin {
if bootstrap.usernames.is_empty() {
return Err(invalid(
"auth.bootstrap_admin.usernames must not be empty; remove the block instead",
));
}
let mut seen = BTreeSet::new();
for name in &bootstrap.usernames {
if !valid_username(name) || !seen.insert(name) {
return Err(invalid(format!(
"bootstrap username {name:?} must be unique and use 3-64 lowercase letters, numbers, dots, underscores or hyphens"
)));
}
}
}
if self.oidc.is_none() && !self.auth.password_login && self.auth.bootstrap_admin.is_none() {
return Err(invalid(
"configure at least one way to sign in: oidc, auth.password_login or auth.bootstrap_admin",
));
}
validate_groups("instance_admin_groups", &self.instance_admin_groups)?;
let mut keys = BTreeSet::new();
let mut names = BTreeSet::new();
for workspace in &self.workspaces {
if !valid_key(&workspace.key) {
return Err(invalid(format!(
"workspace key {:?} must use 3-64 lowercase letters, numbers and hyphens, without leading/trailing hyphen",
workspace.key
)));
}
if !keys.insert(workspace.key.as_str()) {
return Err(invalid(format!(
"workspace key {:?} is used twice",
workspace.key
)));
}
if crate::domain::validate_name(&workspace.name).is_err() {
return Err(invalid(format!(
"workspace {:?} needs a name of 1-120 characters",
workspace.key
)));
}
if !names.insert(workspace.name.trim().to_lowercase()) {
return Err(invalid(format!(
"workspace name {:?} is used twice",
workspace.name
)));
}
if workspace.admin_groups.is_empty() {
return Err(invalid(format!(
"workspace {:?} needs at least one admin group",
workspace.key
)));
}
validate_groups(
&format!("workspace {:?} admin_groups", workspace.key),
&workspace.admin_groups,
)?;
validate_groups(
&format!("workspace {:?} member_groups", workspace.key),
&workspace.member_groups,
)?;
}
Ok(())
}
/// Every group that grants something; password-user assignments are chosen from this list.
pub fn known_groups(&self) -> Vec<String> {
let mut groups: BTreeSet<&String> = self.instance_admin_groups.iter().collect();
for workspace in &self.workspaces {
groups.extend(&workspace.admin_groups);
groups.extend(&workspace.member_groups);
}
groups.into_iter().cloned().collect()
}
pub fn password_login_enabled(&self) -> bool {
self.auth.password_login
}
pub fn bootstrap_usernames(&self) -> &[String] {
self.auth
.bootstrap_admin
.as_ref()
.map_or(&[], |b| b.usernames.as_slice())
}
pub fn password_form_available(&self) -> bool {
self.auth.password_login || !self.bootstrap_usernames().is_empty()
}
}
static CURRENT: std::sync::OnceLock<Config> = std::sync::OnceLock::new();
/// Installs the process-wide configuration read by request handlers.
pub fn install(config: Config) {
let _ = CURRENT.set(config);
}
pub fn current() -> Result<&'static Config> {
CURRENT
.get()
.ok_or_else(|| Error::Internal("the instance configuration is not loaded".into()))
}
/// Exact string matching of provider groups against the configured groups.
pub fn resolve_access(config: &Config, groups: &[String], bootstrap: bool) -> Access {
let held: BTreeSet<&str> = groups.iter().map(String::as_str).collect();
let any = |wanted: &[String]| wanted.iter().any(|g| held.contains(g.as_str()));
let mut access = Access {
instance_admin: bootstrap || any(&config.instance_admin_groups),
workspaces: BTreeMap::new(),
};
for workspace in &config.workspaces {
let role = if any(&workspace.admin_groups) {
Some(Role::Admin)
} else if any(&workspace.member_groups) {
Some(Role::Employee)
} else {
None
};
if let Some(role) = role {
access.workspaces.insert(workspace.key.clone(), role);
}
}
access
}
/// Reads a group list from a claims object. A literal key wins over a dotted path.
/// `None` means the claim is missing or has an unusable type, which is a provider
/// misconfiguration, not an empty membership.
pub fn claim_groups(claims: &Value, path: &str) -> Option<Vec<String>> {
let value = claims.get(path).or_else(|| {
path.split('.')
.try_fold(claims, |value, part| value.get(part))
})?;
match value {
Value::String(group) => Some(vec![group.clone()]),
Value::Array(items) => Some(
items
.iter()
.filter_map(|v| v.as_str().map(str::to_owned))
.collect(),
),
_ => None,
}
}
const BASE32: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567";
fn base32(bytes: &[u8]) -> String {
let mut output = String::new();
let (mut buffer, mut bits) = (0u32, 0u32);
for byte in bytes {
buffer = (buffer << 8) | u32::from(*byte);
bits += 8;
while bits >= 5 {
bits -= 5;
output.push(BASE32[((buffer >> bits) & 31) as usize] as char);
}
}
if bits > 0 {
output.push(BASE32[((buffer << (5 - bits)) & 31) as usize] as char);
}
output
}
/// Replicas that start in the same UTC hour derive the same password, and it cannot be
/// guessed without the secret stored in the database.
pub fn bootstrap_password(secret: &[u8], username: &str, now: DateTime<Utc>) -> String {
let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(secret).expect("HMAC accepts any key");
mac.update(format!("meguri-bootstrap:{username}:{}", now.format("%Y%m%d%H")).as_bytes());
base32(&mac.finalize().into_bytes()[..20])
}
/// A fresh high-entropy password for single-process demonstration runs.
pub fn random_password() -> String {
let mut bytes = [0u8; 20];
bytes[..16].copy_from_slice(uuid::Uuid::new_v4().as_bytes());
bytes[16..].copy_from_slice(&uuid::Uuid::new_v4().as_bytes()[..4]);
base32(&bytes)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn config() -> Config {
Config::parse(
r#"
auth: {password_login: true}
instance_admin_groups: ["/meguri/admins"]
workspaces:
- {key: payments, name: Payments, admin_groups: ["/payments/leads"], member_groups: ["/payments/team", "/everyone"]}
- {key: search, name: Search, admin_groups: ["/search/leads", "/everyone"], member_groups: ["/search/team"]}
"#,
)
.unwrap()
}
fn groups(items: &[&str]) -> Vec<String> {
items.iter().map(|s| s.to_string()).collect()
}
#[test]
fn groups_map_to_roles_by_exact_match_and_admin_wins() {
let c = config();
let access = resolve_access(&c, &groups(&["/payments/team", "/payments/leads"]), false);
assert_eq!(access.workspaces["payments"], Role::Admin);
assert!(!access.workspaces.contains_key("search"));
assert!(!access.instance_admin);
let access = resolve_access(&c, &groups(&["/everyone"]), false);
assert_eq!(access.workspaces["payments"], Role::Employee);
assert_eq!(access.workspaces["search"], Role::Admin);
for near_miss in [
"/payments/team/",
"payments/team",
"/Payments/Team",
"/payments",
] {
assert!(resolve_access(&c, &groups(&[near_miss]), false).is_empty());
}
assert!(resolve_access(&c, &[], false).is_empty());
}
#[test]
fn instance_admin_comes_from_a_group_or_the_bootstrap_flag_without_workspace_access() {
let c = config();
let access = resolve_access(&c, &groups(&["/meguri/admins"]), false);
assert!(access.instance_admin && access.workspaces.is_empty());
assert!(!access.is_empty());
assert!(resolve_access(&c, &[], true).instance_admin);
}
#[test]
fn claims_support_literal_and_dotted_paths_and_report_missing_claims() {
let claims = serde_json::json!({
"groups": ["/a", "/b", 7],
"realm_access": {"roles": ["admin"]},
"https://example.com/groups": "/single",
"bad": {"x": 1}
});
assert_eq!(claim_groups(&claims, "groups"), Some(groups(&["/a", "/b"])));
assert_eq!(
claim_groups(&claims, "realm_access.roles"),
Some(groups(&["admin"]))
);
assert_eq!(
claim_groups(&claims, "https://example.com/groups"),
Some(groups(&["/single"]))
);
assert_eq!(claim_groups(&claims, "missing"), None);
assert_eq!(claim_groups(&claims, "bad"), None);
assert_eq!(
claim_groups(&serde_json::json!({"groups": []}), "groups"),
Some(vec![])
);
}
#[test]
fn bootstrap_passwords_are_stable_within_an_hour_and_depend_on_secret_user_and_hour() {
let hour = Utc.with_ymd_and_hms(2026, 10, 7, 14, 5, 0).unwrap();
let same_hour = Utc.with_ymd_and_hms(2026, 10, 7, 14, 59, 59).unwrap();
let next_hour = Utc.with_ymd_and_hms(2026, 10, 7, 15, 0, 0).unwrap();
let password = bootstrap_password(b"secret-one", "admin", hour);
assert_eq!(
password,
bootstrap_password(b"secret-one", "admin", same_hour)
);
assert_ne!(
password,
bootstrap_password(b"secret-one", "admin", next_hour)
);
assert_ne!(password, bootstrap_password(b"secret-two", "admin", hour));
assert_ne!(password, bootstrap_password(b"secret-one", "other", hour));
assert!(password.len() >= 24 && password.chars().all(|c| c.is_ascii_alphanumeric()));
assert_ne!(random_password(), random_password());
}
#[test]
fn configuration_is_validated_strictly() {
let ok = "auth: {password_login: true}\nworkspaces: []\n";
assert!(Config::parse(ok).is_ok());
for (bad, why) in [
("workspaces: []\n", "no sign-in method"),
("auth: {password_login: true, typo: 1}\n", "unknown field"),
(
"auth: {password_login: true}\nworkspaces: [{key: Bad_Key, name: A, admin_groups: [g]}]\n",
"key format",
),
(
"auth: {password_login: true}\nworkspaces: [{key: aaa, name: A, admin_groups: [g]}, {key: aaa, name: B, admin_groups: [g]}]\n",
"duplicate key",
),
(
"auth: {password_login: true}\nworkspaces: [{key: aaa, name: A, admin_groups: [g]}, {key: bbb, name: a, admin_groups: [g]}]\n",
"duplicate name",
),
(
"auth: {password_login: true}\nworkspaces: [{key: aaa, name: A, admin_groups: []}]\n",
"no admin group",
),
(
"auth: {password_login: true}\nworkspaces: [{key: aaa, name: A, admin_groups: [g, g]}]\n",
"duplicate group",
),
(
"auth: {bootstrap_admin: {usernames: []}}\n",
"empty bootstrap list",
),
(
"auth: {bootstrap_admin: {usernames: [Admin]}}\n",
"bad bootstrap name",
),
(
"oidc: {issuer: 'http://idp.example.com', client_id: x}\n",
"plain HTTP issuer",
),
(
"oidc: {issuer: 'https://idp.example.com', client_id: x, scopes: [email]}\n",
"scope without openid",
),
("{", "syntax"),
] {
assert!(Config::parse(bad).is_err(), "must reject: {why}");
}
let oidc =
Config::parse("oidc: {issuer: 'http://127.0.0.1:9000/realms/x', client_id: meguri}\n")
.unwrap();
let oidc = oidc.oidc.unwrap();
assert_eq!(oidc.groups_claim, "groups");
assert_eq!(oidc.scopes, ["openid", "profile", "email"]);
assert_eq!(
oidc.groups_sources,
[GroupSource::IdToken, GroupSource::Userinfo]
);
}
#[test]
fn the_shipped_example_configuration_is_valid() {
let config = Config::parse(include_str!("../deploy/instance-config.example.yaml")).unwrap();
assert!(config.oidc.is_some() && !config.workspaces.is_empty());
assert!(config.bootstrap_usernames().is_empty());
}
#[test]
fn the_helm_chart_default_instance_configuration_is_valid() {
let values: serde_yaml_ng::Value =
serde_yaml_ng::from_str(include_str!("../helm/meguri/values.yaml")).unwrap();
let rendered = serde_yaml_ng::to_string(&values["instanceConfig"]).unwrap();
let config = Config::parse(&rendered).unwrap();
assert!(config.oidc.is_some() && !config.workspaces.is_empty());
}
#[test]
fn known_groups_are_unique_and_sorted_and_the_form_flags_follow_the_configuration() {
let c = config();
assert_eq!(
c.known_groups(),
groups(&[
"/everyone",
"/meguri/admins",
"/payments/leads",
"/payments/team",
"/search/leads",
"/search/team"
])
);
assert!(c.password_form_available());
let sso_only =
Config::parse("oidc: {issuer: 'https://idp.example.com', client_id: x}\n").unwrap();
assert!(!sso_only.password_form_available());
let bootstrap = Config::parse(
"oidc: {issuer: 'https://idp.example.com', client_id: x}\nauth: {bootstrap_admin: {usernames: [admin]}}\n",
)
.unwrap();
assert!(bootstrap.password_form_available() && !bootstrap.password_login_enabled());
}
}
+395
View File
@@ -0,0 +1,395 @@
//! Instance administration: read-only workspace overview, password-user management and the
//! deliberate deletion of workspaces that configuration disabled.
use crate::{
access::{Config, valid_username},
domain::validate_name,
error::{Error, Result, ensure},
identity::{apply_access, new_user_row, normalize_email},
models::*,
service,
store::{self, Store},
};
use cot::db::{Model, query};
use serde::Deserialize;
use serde_json::{Value, json};
use std::collections::{BTreeMap, BTreeSet};
use uuid::Uuid;
pub async fn require_instance_admin(db: &Store, actor: Uuid) -> Result<()> {
if db.is_instance_admin(actor).await? {
Ok(())
} else {
Err(Error::Forbidden)
}
}
async fn sso_user_ids(db: &Store) -> Result<BTreeSet<String>> {
Ok(ExternalIdentityRow::objects()
.all(&db.db)
.await?
.into_iter()
.map(|i| i.user_id)
.collect())
}
/// Everything the instance administrator may see. Workspaces and group mappings are read-only
/// here because configuration owns them.
pub async fn overview(db: &Store, config: &Config, actor: Uuid) -> Result<Value> {
require_instance_admin(db, actor).await?;
let mut members: BTreeMap<String, usize> = BTreeMap::new();
for member in WorkspaceMemberRow::objects().all(&db.db).await? {
*members.entry(member.workspace_id).or_default() += 1;
}
let mut jobs: BTreeMap<String, SyncJobRow> = BTreeMap::new();
for job in SyncJobRow::objects().all(&db.db).await? {
jobs.insert(job.workspace_id.clone(), job);
}
let mut workspaces = vec![];
for row in WorkspaceRow::objects().all(&db.db).await? {
if row.demo_deleted_at.is_some() {
continue;
}
let name = serde_json::from_str::<crate::domain::WorkspaceDocument>(&row.document)?.name;
let configured = row
.key
.as_ref()
.and_then(|key| config.workspaces.iter().find(|w| &w.key == key));
let job = jobs.get(&row.id);
workspaces.push(json!({
"id":row.id,
"key":row.key,
"name":name,
"created_at":row.created_at,
"disabled_at":row.disabled_at,
"in_config":configured.is_some(),
"admin_groups":configured.map(|w| w.admin_groups.clone()).unwrap_or_default(),
"member_groups":configured.map(|w| w.member_groups.clone()).unwrap_or_default(),
"member_count":members.get(&row.id).copied().unwrap_or(0),
"sync_state":job.map(|j| j.state.clone()),
"last_success":job.and_then(|j| j.last_success),
"confirm":row.key.clone().unwrap_or_else(|| name.clone()),
}));
}
workspaces.sort_by(|a, b| {
(a["disabled_at"].is_null(), a["name"].as_str())
.cmp(&(b["disabled_at"].is_null(), b["name"].as_str()))
});
let sso = sso_user_ids(db).await?;
let mut users = vec![];
let mut rows = UserRow::objects().all(&db.db).await?;
rows.sort_by(|a, b| a.username.cmp(&b.username));
for row in rows {
if row.demo_deleted_at.is_some() {
continue;
}
users.push(json!({
"id":row.id,
"username":row.username,
"display_name":row.display_name,
"email":row.email,
"sso":sso.contains(&row.id),
"bootstrap":row.bootstrap,
"instance_admin":row.instance_admin,
"groups":crate::identity::stored_groups(&row)?,
"created_at":row.created_at,
}));
}
Ok(json!({
"config":{
"password_login":config.password_login_enabled(),
"bootstrap_usernames":config.bootstrap_usernames(),
"oidc_issuer":config.oidc.as_ref().map(|o| o.issuer.clone()),
"instance_admin_groups":config.instance_admin_groups,
},
"workspaces":workspaces,
"users":users,
"known_groups":config.known_groups(),
}))
}
fn checked_groups(config: &Config, mut groups: Vec<String>) -> Result<Vec<String>> {
groups.sort();
groups.dedup();
let known: BTreeSet<String> = config.known_groups().into_iter().collect();
ensure(
groups.iter().all(|g| known.contains(g)),
crate::i18n::text("err_choose_groups_from_the_configured_list_5d0e71"),
)?;
Ok(groups)
}
#[derive(Debug, Deserialize)]
pub struct NewUser {
pub username: String,
pub display_name: String,
pub password: String,
#[serde(default)]
pub email: Option<String>,
#[serde(default)]
pub groups: Vec<String>,
/// Scheduling country (ISO 3166-1 alpha-2); defaults to CY like every new account.
#[serde(default)]
pub country: Option<String>,
}
/// Creates a password account. `actor` is None only for trusted startup seeding; the request
/// handler always goes through [`create_user`].
pub async fn create_password_user(
db: &Store,
config: &Config,
actor: Option<Uuid>,
input: NewUser,
) -> Result<Value> {
let username = input.username.trim().to_ascii_lowercase();
ensure(
valid_username(&username),
crate::i18n::text("err_username_must_use_3_64_letters_numbers_dots_unde_2c6b6f"),
)?;
validate_name(&input.display_name)?;
let email = match input
.email
.as_deref()
.map(str::trim)
.filter(|e| !e.is_empty())
{
None => None,
Some(email) => Some(normalize_email(email).ok_or_else(|| {
Error::Validation(crate::i18n::text("err_enter_a_valid_email_address_1577ea").into())
})?),
};
let groups = checked_groups(config, input.groups)?;
let hash = service::hash_password(input.password).await?;
let mut row = new_user_row(username.clone(), input.display_name, Some(hash));
row.email = email;
if let Some(country) = input
.country
.as_deref()
.map(str::trim)
.filter(|c| !c.is_empty())
{
let country = country.to_ascii_uppercase();
ensure(
crate::domain::valid_country(&country),
crate::i18n::text("err_choose_a_valid_country_code_e79c72"),
)?;
row.country = country;
}
match row.clone().insert(&db.db).await {
Ok(_) => {}
Err(cot::db::DatabaseError::UniqueViolation) => {
return Err(Error::Conflict(
crate::i18n::text("err_this_username_is_already_registered_5ea7ea").into(),
));
}
Err(e) => return Err(e.into()),
}
let id = store::uuid(&row.id)?;
apply_access(db, config, id, Some(groups.clone())).await?;
if let Err(error) = crate::identity::link_contacts(db, id).await {
tracing::warn!(user = %id, %error, "could not add the new account to its workspaces");
}
db.instance_event(
actor,
"user.created",
json!({"user_id":id,"username":username,"groups":groups}),
)
.await?;
let created = query!(UserRow,$id==row.id.clone())
.get(&db.db)
.await?
.ok_or(Error::NotFound)?;
Ok(json!({"user":store::user(created)?}))
}
pub async fn create_user(
db: &Store,
config: &Config,
actor: Uuid,
input: NewUser,
) -> Result<Value> {
require_instance_admin(db, actor).await?;
create_password_user(db, config, Some(actor), input).await
}
/// Password accounts only: SSO groups come from the provider and bootstrap accounts from
/// configuration. Administrators cannot edit their own groups, so the last one cannot vanish.
async fn editable_target(db: &Store, actor: Uuid, target: Uuid) -> Result<UserRow> {
ensure(
actor != target,
crate::i18n::text("err_you_cannot_change_your_own_access_9b44c0"),
)?;
let id = target.to_string();
let row = query!(UserRow,$id==id)
.get(&db.db)
.await?
.filter(|r| r.demo_deleted_at.is_none())
.ok_or(Error::NotFound)?;
ensure(
!row.bootstrap && !sso_user_ids(db).await?.contains(&row.id),
crate::i18n::text("err_this_account_is_managed_by_sso_or_configuration_a3c8e2"),
)?;
Ok(row)
}
pub async fn set_user_groups(
db: &Store,
config: &Config,
actor: Uuid,
target: Uuid,
groups: Vec<String>,
) -> Result<Value> {
require_instance_admin(db, actor).await?;
let before = editable_target(db, actor, target).await?;
let groups = checked_groups(config, groups)?;
apply_access(db, config, target, Some(groups.clone())).await?;
if let Err(error) = crate::identity::link_contacts(db, target).await {
tracing::warn!(user = %target, %error, "could not add the account to its workspaces");
}
db.instance_event(
Some(actor),
"user.groups_changed",
json!({"user_id":target,"username":before.username,"before":crate::identity::stored_groups(&before)?,"after":groups}),
)
.await?;
let id = target.to_string();
let row = query!(UserRow,$id==id)
.get(&db.db)
.await?
.ok_or(Error::NotFound)?;
Ok(json!({"user":store::user(row)?}))
}
pub async fn reset_password(db: &Store, actor: Uuid, target: Uuid, password: String) -> Result<()> {
require_instance_admin(db, actor).await?;
let row = editable_target(db, actor, target).await?;
let hash = service::hash_password(password).await?;
db.db
.raw_with(
"UPDATE meguri__users SET password_hash=$2 WHERE id=$1",
&[&row.id, &hash],
)
.await?;
db.db
.raw_with("DELETE FROM meguri__sessions WHERE user_id=$1", &[&row.id])
.await?;
db.instance_event(
Some(actor),
"user.password_reset",
json!({"user_id":target,"username":row.username}),
)
.await
}
const DELETE_WORKSPACE: &str = r#"
WITH target AS MATERIALIZED (
SELECT id FROM meguri__workspaces w WHERE w.id=$1 AND w.disabled_at IS NOT NULL AND w.demo_deleted_at IS NULL
AND NOT EXISTS(SELECT 1 FROM meguri__sync_jobs j WHERE j.workspace_id=w.id AND j.lease_until>now())
), members AS (DELETE FROM meguri__workspace_members WHERE workspace_id IN(SELECT id FROM target)),
history AS (DELETE FROM meguri__workspace_history WHERE workspace_id IN(SELECT id FROM target)),
audit AS (DELETE FROM meguri__audit_events WHERE workspace_id IN(SELECT id FROM target)),
jobs AS (DELETE FROM meguri__sync_jobs WHERE workspace_id IN(SELECT id FROM target)),
objects AS (DELETE FROM meguri__provider_objects WHERE workspace_id IN(SELECT id FROM target)),
pins AS (DELETE FROM meguri__demo_pins WHERE kind='workspace' AND id IN(SELECT id FROM target))
DELETE FROM meguri__workspaces WHERE id IN(SELECT id FROM target)
"#;
/// Permanently removes a workspace that is disabled and absent from configuration. The caller
/// must type its key (or name when it has none). Remote provider resources are not touched.
pub async fn delete_workspace(
db: &Store,
config: &Config,
actor: Uuid,
workspace: Uuid,
confirm: &str,
) -> Result<()> {
require_instance_admin(db, actor).await?;
let id = workspace.to_string();
let row = query!(WorkspaceRow,$id==id)
.get(&db.db)
.await?
.filter(|r| r.demo_deleted_at.is_none())
.ok_or(Error::NotFound)?;
let name = serde_json::from_str::<crate::domain::WorkspaceDocument>(&row.document)?.name;
ensure(
row.disabled_at.is_some()
&& !row
.key
.as_ref()
.is_some_and(|key| config.workspaces.iter().any(|w| &w.key == key)),
crate::i18n::text("err_only_disabled_workspaces_can_be_deleted_8f27d1"),
)?;
let expected = row.key.clone().unwrap_or_else(|| name.clone());
ensure(
confirm == expected,
crate::i18n::text("err_type_the_workspace_key_to_confirm_deletion_4be9a0"),
)?;
let result = db.db.raw_with(DELETE_WORKSPACE, &[&row.id]).await?;
ensure(
result.rows_affected().0 > 0,
crate::i18n::text("err_the_workspace_is_publishing_try_again_shortly_2a7d94"),
)?;
db.instance_event(
Some(actor),
"workspace.deleted",
json!({"id":row.id,"key":row.key,"name":name}),
)
.await
}
const EVENT_LIMIT: usize = 300;
/// The newest instance events, newest first, optionally filtered by a case-insensitive text that
/// is matched against the action, the actor and the details. A support reference from a failed
/// sign-in (the `ref` shown on the login screen) is found this way.
pub async fn events(db: &Store, actor: Uuid, search: &str) -> Result<Value> {
require_instance_admin(db, actor).await?;
let needle = search.trim().to_lowercase();
ensure(
needle.len() <= 200,
crate::i18n::text("err_the_search_text_is_too_long_7c3d58"),
)?;
let mut rows = InstanceAuditRow::objects().all(&db.db).await?;
rows.sort_by_key(|row| std::cmp::Reverse(row.id.unwrap()));
let mut names: BTreeMap<String, String> = BTreeMap::new();
let mut events = vec![];
let mut more = false;
for row in rows {
let actor_name = match &row.actor_id {
None => None,
Some(id) => {
if !names.contains_key(id) {
let key = id.clone();
let name = query!(UserRow,$id==key)
.get(&db.db)
.await?
.map_or_else(|| "unknown".to_owned(), |u| u.username);
names.insert(id.clone(), name);
}
names.get(id).cloned()
}
};
if !needle.is_empty()
&& ![
row.action.as_str(),
actor_name.as_deref().unwrap_or(""),
row.details.as_str(),
]
.iter()
.any(|text| text.to_lowercase().contains(&needle))
{
continue;
}
if events.len() == EVENT_LIMIT {
more = true;
break;
}
events.push(json!({
"id":row.id.unwrap(),
"action":row.action,
"actor":actor_name,
"details":serde_json::from_str::<Value>(&row.details).unwrap_or(Value::Null),
"created_at":row.created_at,
}));
}
Ok(json!({"events":events,"more":more}))
}
+21
View File
@@ -8,6 +8,9 @@ pub type Names = BTreeMap<String, String>;
pub fn remember(names: &mut Names, doc: &WorkspaceDocument) { pub fn remember(names: &mut Names, doc: &WorkspaceDocument) {
for person in &doc.people { for person in &doc.people {
names.insert(person.id.to_string(), person.display_name.clone()); names.insert(person.id.to_string(), person.display_name.clone());
for alias in &person.aliases {
names.insert(alias.to_string(), person.display_name.clone());
}
if let Some(id) = person.user_id { if let Some(id) = person.user_id {
names.insert(id.to_string(), person.display_name.clone()); names.insert(id.to_string(), person.display_name.clone());
} }
@@ -87,4 +90,22 @@ mod tests {
assert_eq!(resolved["unknown_id"], "missing"); assert_eq!(resolved["unknown_id"], "missing");
assert_eq!(raw["from_user"], id); assert_eq!(raw["from_user"], id);
} }
#[test]
fn merged_contact_ids_keep_resolving_to_the_person() {
let (old, new) = (uuid::Uuid::new_v4(), uuid::Uuid::new_v4());
let mut doc = WorkspaceDocument::new("Team".into());
doc.people.push(crate::domain::Person {
id: new,
user_id: Some(new),
display_name: "Dana".into(),
email: Some("dana@example.com".into()),
country: None,
aliases: vec![old],
});
let mut names = Names::new();
remember(&mut names, &doc);
assert_eq!(names[&old.to_string()], "Dana");
assert_eq!(names[&new.to_string()], "Dana");
}
} }
+24
View File
@@ -0,0 +1,24 @@
# Demonstration instance. It is described exactly like a production configuration; only the
# bootstrap administrator and the password sign-in switch differ.
auth:
password_login: true
bootstrap_admin:
usernames: [admin]
instance_admin_groups: [demo-instance-admins]
workspaces:
- key: acme-engineering
name: Acme Engineering
admin_groups: [acme-leads]
member_groups: [acme-staff]
- key: customer-support
name: Customer Support
admin_groups: [support-leads]
member_groups: [support-staff]
- key: regional-platform
name: Regional Platform
admin_groups: [regional-leads]
member_groups: [regional-staff]
- key: commerce-operations
name: Commerce Operations
admin_groups: [commerce-leads]
member_groups: [commerce-staff]
+21 -18
View File
@@ -14,21 +14,24 @@ CY,2026-10-28,Ohi Day
CY,2026-12-24,Christmas Eve CY,2026-12-24,Christmas Eve
CY,2026-12-25,Christmas Day CY,2026-12-25,Christmas Day
CY,2026-12-26,Boxing Day CY,2026-12-26,Boxing Day
JP,2026-01-01,New Year's Day GB,2026-01-01,New Year's Day
JP,2026-01-12,Coming of Age Day GB,2026-04-03,Good Friday
JP,2026-02-11,National Foundation Day GB,2026-04-06,Easter Monday
JP,2026-02-23,Emperor's Birthday GB,2026-05-04,Early May bank holiday
JP,2026-03-20,Vernal Equinox Day GB,2026-05-25,Spring bank holiday
JP,2026-04-29,Showa Day GB,2026-08-31,Summer bank holiday
JP,2026-05-03,Constitution Memorial Day GB,2026-12-25,Christmas Day
JP,2026-05-04,Greenery Day GB,2026-12-28,Boxing Day (substitute day)
JP,2026-05-05,Children's Day AM,2026-01-01,New Year's Day
JP,2026-05-06,Substitute Holiday AM,2026-01-02,New Year holiday
JP,2026-07-20,Marine Day AM,2026-01-06,Christmas Day
JP,2026-08-11,Mountain Day AM,2026-01-28,Army Day
JP,2026-09-21,Respect for the Aged Day AM,2026-03-08,Women's Day
JP,2026-09-22,Citizens' Holiday AM,2026-04-07,Motherhood and Beauty Day
JP,2026-09-23,Autumnal Equinox Day AM,2026-04-24,Genocide Remembrance Day
JP,2026-10-12,Sports Day AM,2026-05-01,Labour Day
JP,2026-11-03,Culture Day AM,2026-05-09,Victory and Peace Day
JP,2026-11-23,Labour Thanksgiving Day AM,2026-05-28,Republic Day
AM,2026-07-05,Constitution Day
AM,2026-09-21,Independence Day
AM,2026-12-31,New Year's Eve
1 country date name
14 CY 2026-12-24 Christmas Eve
15 CY 2026-12-25 Christmas Day
16 CY 2026-12-26 Boxing Day
17 JP GB 2026-01-01 New Year's Day
18 JP GB 2026-01-12 2026-04-03 Coming of Age Day Good Friday
19 JP GB 2026-02-11 2026-04-06 National Foundation Day Easter Monday
20 JP GB 2026-02-23 2026-05-04 Emperor's Birthday Early May bank holiday
21 JP GB 2026-03-20 2026-05-25 Vernal Equinox Day Spring bank holiday
22 JP GB 2026-04-29 2026-08-31 Showa Day Summer bank holiday
23 JP GB 2026-05-03 2026-12-25 Constitution Memorial Day Christmas Day
24 JP GB 2026-05-04 2026-12-28 Greenery Day Boxing Day (substitute day)
25 JP AM 2026-05-05 2026-01-01 Children's Day New Year's Day
26 JP AM 2026-05-06 2026-01-02 Substitute Holiday New Year holiday
27 JP AM 2026-07-20 2026-01-06 Marine Day Christmas Day
28 JP AM 2026-08-11 2026-01-28 Mountain Day Army Day
29 JP AM 2026-09-21 2026-03-08 Respect for the Aged Day Women's Day
30 JP AM 2026-09-22 2026-04-07 Citizens' Holiday Motherhood and Beauty Day
31 JP AM 2026-09-23 2026-04-24 Autumnal Equinox Day Genocide Remembrance Day
32 JP AM 2026-10-12 2026-05-01 Sports Day Labour Day
33 JP AM 2026-11-03 2026-05-09 Culture Day Victory and Peace Day
34 JP AM 2026-11-23 2026-05-28 Labour Thanksgiving Day Republic Day
35 AM 2026-07-05 Constitution Day
36 AM 2026-09-21 Independence Day
37 AM 2026-12-31 New Year's Eve
+183 -74
View File
@@ -4,12 +4,13 @@ use crate::{
error::Result, error::Result,
generation::{Node, Rule, Variable}, generation::{Node, Rule, Variable},
generation_service::{self, Draft}, generation_service::{self, Draft},
models::{UserRow, WorkspaceRow}, identity,
models::UserRow,
service, service,
store::{self, Store}, store::{self, Store},
}; };
use chrono::{Datelike, Duration, NaiveDate, Utc}; use chrono::{Datelike, Duration, NaiveDate, Utc};
use cot::db::{Model, query}; use cot::db::query;
use serde_json::{Value, json}; use serde_json::{Value, json};
use uuid::Uuid; use uuid::Uuid;
@@ -226,88 +227,218 @@ pub fn service_tiers(monday: NaiveDate, alice: Uuid, carol: Uuid) -> (Vec<Variab
) )
} }
pub async fn seed(db: &Store) -> Result<()> { /// Demonstration accounts: groups assigned exactly as an instance administrator would.
let mut users = vec![]; /// Scheduling countries differ on purpose, so holiday handling is visible in the examples.
for (username, display) in [ const ACCOUNTS: [(&str, &str, &str, &str, &[&str]); 6] = [
("alice", "Alice Morgan"), ("admin", "Instance Admin", "instance_admin", "CY", &[]),
("bob", "Bob Chen"), (
("carol", "Carol Davis"), "alice",
("dave", "Dave Wilson"), "Alice Morgan",
("erin", "Erin Patel"), "workspace_admin",
] { "CY",
let user = match query!(UserRow,$username==username).get(&db.db).await? { &[
Some(row) => store::user(row)?, "acme-leads",
None => { "support-leads",
service::register( "regional-leads",
db, "commerce-leads",
username.into(), ],
display.into(), ),
format!("{username}@example.com"), (
"meguri-demo-only".into(), "bob",
"Bob Chen",
"employee",
"GB",
&["acme-staff", "regional-staff"],
),
(
"carol",
"Carol Davis",
"employee",
"AM",
&["acme-staff", "support-staff", "commerce-staff"],
),
("dave", "Dave Wilson", "employee", "CY", &["acme-staff"]),
("erin", "Erin Patel", "employee", "GB", &["acme-staff"]),
];
/// `MEGURI_DEMO_SANDBOXES=n` (0-20) adds empty "Sandbox 1..n" workspaces, each with its own
/// administrator group, so automated browser checks can mutate state without touching the
/// examples. Normal demonstrations do not set it.
fn sandboxes() -> usize {
std::env::var("MEGURI_DEMO_SANDBOXES")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.map_or(0, |n| n.min(20))
}
pub fn config() -> Result<crate::access::Config> {
let mut config = crate::access::Config::parse(include_str!("demo-config.yaml"))?;
for number in 1..=sandboxes() {
config.workspaces.push(crate::access::WorkspaceConfig {
key: format!("sandbox-{number}"),
name: format!("Sandbox {number}"),
admin_groups: vec![format!("sandbox-{number}-leads")],
member_groups: vec![format!("sandbox-{number}-staff")],
});
}
config.validate()?;
Ok(config)
}
/// Accounts shown on the demonstration login screen.
pub fn accounts() -> Vec<crate::demo_runtime::DemoAccount> {
ACCOUNTS
.iter()
.map(
|(username, _, role, _, _)| crate::demo_runtime::DemoAccount {
username: (*username).into(),
role: (*role).into(),
},
) )
.collect()
}
async fn user_id(db: &Store, username: &str) -> Result<Uuid> {
let name = username.to_owned();
let row = query!(UserRow,$username==name)
.get(&db.db)
.await? .await?
.ok_or(crate::error::Error::NotFound)?;
store::uuid(&row.id)
}
pub async fn seed(db: &Store) -> Result<()> {
let notice = crate::demo_runtime::notice()
.ok_or_else(|| crate::error::Error::Internal("demo mode is not active".into()))?;
let config = config()?;
identity::sync_config(
db,
&config,
identity::SyncOptions {
passwords: identity::BootstrapPasswords::Fixed(notice.password.clone()),
now: Utc::now(),
allow_disable_all: false,
},
)
.await?;
for (username, display, _, country, groups) in ACCOUNTS {
let name = username.to_owned();
if query!(UserRow,$username==name).exists(&db.db).await? {
continue;
} }
}; crate::admin::create_password_user(
users.push(user); db,
} &config,
for user in &users { None,
db.db crate::admin::NewUser {
.raw_with( username: username.into(),
"INSERT INTO meguri__demo_pins(kind,id) VALUES('user',$1) ON CONFLICT DO NOTHING", display_name: display.into(),
&[&user.id.to_string()], password: notice.password.clone(),
email: Some(format!("{username}@example.com")),
groups: groups.iter().map(|g| (*g).to_owned()).collect(),
country: Some(country.into()),
},
) )
.await?; .await?;
} }
let alice = users[0].id; let mut users = vec![];
for (username, ..) in ACCOUNTS {
let id = user_id(db, username).await?;
db.db
.raw_with(
"INSERT INTO meguri__demo_pins(kind,id) VALUES('user',$1) ON CONFLICT DO NOTHING",
&[&id.to_string()],
)
.await?;
users.push(id);
}
// users[0] is the instance administrator; the five scheduling accounts follow.
let people: [Uuid; 5] = std::array::from_fn(|index| users[index + 1]);
let alice = people[0];
let today = Utc::now().date_naive(); let today = Utc::now().date_naive();
let monday = today - Duration::days(i64::from(today.weekday().num_days_from_monday())); let monday = today - Duration::days(i64::from(today.weekday().num_days_from_monday()));
for (index, workspace_name, timezone, start, end) in [ for (index, key, timezone, start, end) in [
( (
1, 1,
"Acme Engineering", "acme-engineering",
"Asia/Nicosia", "Asia/Nicosia",
"11:00:00", "11:00:00",
"19:00:00", "19:00:00",
), ),
( (
2, 2,
"Customer Support", "customer-support",
"Europe/London", "Europe/London",
"09:00:00", "09:00:00",
"17:00:00", "17:00:00",
), ),
( (
3, 3,
"Regional Platform", "regional-platform",
"Asia/Nicosia", "Asia/Nicosia",
"09:00:00", "09:00:00",
"18:00:00", "18:00:00",
), ),
( (
4, 4,
"Commerce Operations", "commerce-operations",
"Europe/London", "Europe/London",
"09:00:00", "09:00:00",
"17:00:00", "17:00:00",
), ),
] { ] {
let mut existing = None; let Some(workspace) = identity::find_workspace(db, key).await? else {
for row in WorkspaceRow::objects().all(&db.db).await? { continue;
if row.demo_deleted_at.is_some() { };
let claimed = db
.db
.raw_with(
"INSERT INTO meguri__demo_pins(kind,id) VALUES('workspace',$1) ON CONFLICT DO NOTHING",
&[&workspace.id.to_string()],
)
.await?
.rows_affected()
.0
> 0;
if !claimed {
continue; continue;
} }
let candidate = store::record(row)?; let result = seed_workspace(
if candidate.document.name == workspace_name db,
&& db.owner(candidate.id, alice).await.is_ok() alice,
{ workspace,
existing = Some(candidate); index,
break; (timezone, start, end),
monday,
people,
)
.await;
if let Err(error) = result {
// Release the claim so the next maintenance pass retries the example.
let id = identity::find_workspace(db, key)
.await?
.map(|w| w.id.to_string());
db.db
.raw_with(
"DELETE FROM meguri__demo_pins WHERE kind='workspace' AND id=$1",
&[&id],
)
.await?;
return Err(error);
} }
} }
if existing.is_some() { Ok(())
continue; }
}
let mut workspace = service::create_workspace(db, alice, workspace_name.into()).await?; async fn seed_workspace(
db: &Store,
alice: Uuid,
mut workspace: store::WorkspaceRecord,
index: usize,
(timezone, start, end): (&str, &str, &str),
monday: NaiveDate,
people: [Uuid; 5],
) -> Result<()> {
service::set_working_interval( service::set_working_interval(
db, db,
alice, alice,
@@ -322,22 +453,6 @@ pub async fn seed(db: &Store) -> Result<()> {
) )
.await?; .await?;
workspace = db.workspace(workspace.id).await?; workspace = db.workspace(workspace.id).await?;
let member_indices = if index == 1 {
vec![1, 2, 3, 4]
} else {
vec![if index % 2 == 1 { 1 } else { 2 }]
};
for member_index in member_indices {
workspace = service::set_member(
db,
alice,
workspace.id,
workspace.revision,
users[member_index].username.clone(),
Permission::member(),
)
.await?;
}
// Fixed, documented 2026 examples; use the normal scoped CSV import path. // Fixed, documented 2026 examples; use the normal scoped CSV import path.
let csv = include_str!("demo-holidays-2026.csv"); let csv = include_str!("demo-holidays-2026.csv");
if !crate::holidays::compare(csv, &workspace.document.holidays)? if !crate::holidays::compare(csv, &workspace.document.holidays)?
@@ -347,11 +462,12 @@ pub async fn seed(db: &Store) -> Result<()> {
workspace = workspace =
service::import_holidays(db, alice, workspace.id, workspace.revision, csv).await?; service::import_holidays(db, alice, workspace.id, workspace.revision, csv).await?;
} }
let [alice, bob, carol, ..] = people;
let (variables, rules) = match index { let (variables, rules) = match index {
1 => engineering_matrix(monday, std::array::from_fn(|index| users[index].id)), 1 => engineering_matrix(monday, people),
2 => support_queues(monday, alice, users[2].id), 2 => support_queues(monday, alice, carol),
3 => regional_platform(monday, alice, users[1].id), 3 => regional_platform(monday, alice, bob),
_ => service_tiers(monday, alice, users[2].id), _ => service_tiers(monday, alice, carol),
}; };
let mut draft = Draft { let mut draft = Draft {
shared_revision: None, shared_revision: None,
@@ -364,12 +480,5 @@ pub async fn seed(db: &Store) -> Result<()> {
let report = generation_service::preview(db, alice, workspace.id, draft.clone()).await?; let report = generation_service::preview(db, alice, workspace.id, draft.clone()).await?;
draft.review_id = report["review_id"].as_str().map(str::to_owned); draft.review_id = report["review_id"].as_str().map(str::to_owned);
generation_service::apply(db, alice, workspace.id, draft).await?; generation_service::apply(db, alice, workspace.id, draft).await?;
db.db
.raw_with(
"INSERT INTO meguri__demo_pins(kind,id) VALUES('workspace',$1) ON CONFLICT DO NOTHING",
&[&workspace.id.to_string()],
)
.await?;
}
Ok(()) Ok(())
} }
+10 -1
View File
@@ -53,9 +53,18 @@ impl Retention {
} }
} }
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct DemoAccount {
pub username: String,
pub role: String,
}
/// Everything is shown on the login screen: demonstration runs are loopback-only and
/// disposable, and every restart generates a new password.
#[derive(Debug, Clone, Serialize)]
pub struct Notice { pub struct Notice {
pub isolated: bool, pub isolated: bool,
pub retention: Retention, pub retention: Retention,
pub password: String,
pub accounts: Vec<DemoAccount>,
} }
static NOTICE: OnceLock<Notice> = OnceLock::new(); static NOTICE: OnceLock<Notice> = OnceLock::new();
pub fn notice() -> Option<&'static Notice> { pub fn notice() -> Option<&'static Notice> {
@@ -191,7 +200,7 @@ pub async fn reset(db: &Database) -> Result<()> {
LOCK TABLE public.meguri_runtime_guard IN EXCLUSIVE MODE; LOCK TABLE public.meguri_runtime_guard IN EXCLUSIVE MODE;
IF NOT EXISTS(SELECT 1 FROM public.meguri_runtime_guard WHERE id AND signature='meguri.runtime.v1' AND last_mode='demo') THEN RAISE EXCEPTION 'MEGURI_DEMO_PROTECTED'; END IF; IF NOT EXISTS(SELECT 1 FROM public.meguri_runtime_guard WHERE id AND signature='meguri.runtime.v1' AND last_mode='demo') THEN RAISE EXCEPTION 'MEGURI_DEMO_PROTECTED'; END IF;
IF EXISTS(SELECT 1 FROM pg_stat_activity WHERE datname=current_database() AND backend_type='client backend' AND pid<>pg_backend_pid() AND application_name<>current_setting('application_name')) THEN RAISE EXCEPTION 'Demo database has another active client; stop the other process before restarting'; END IF; IF EXISTS(SELECT 1 FROM pg_stat_activity WHERE datname=current_database() AND backend_type='client backend' AND pid<>pg_backend_pid() AND application_name<>current_setting('application_name')) THEN RAISE EXCEPTION 'Demo database has another active client; stop the other process before restarting'; END IF;
SELECT string_agg(format('public.%I',name),',') INTO tables FROM unnest(ARRAY['meguri__site_access','meguri__site_audit','meguri__workspace_drafts','meguri__workspaces','meguri__workspace_members','meguri__workspace_history','meguri__sessions','meguri__external_identities','meguri__team_members','meguri__tenant_members','meguri__team_history','meguri__audit_events','meguri__sync_jobs','meguri__provider_objects','meguri__teams','meguri__tenants','meguri__users','meguri__demo_pins']) name WHERE to_regclass('public.'||name) IS NOT NULL; SELECT string_agg(format('public.%I',name),',') INTO tables FROM unnest(ARRAY['meguri__workspace_drafts','meguri__workspaces','meguri__workspace_members','meguri__workspace_history','meguri__sessions','meguri__external_identities','meguri__team_members','meguri__tenant_members','meguri__team_history','meguri__audit_events','meguri__sync_jobs','meguri__provider_objects','meguri__teams','meguri__tenants','meguri__users','meguri__demo_pins','meguri__instance_audit','meguri__instance_state','meguri__oidc_flows']) name WHERE to_regclass('public.'||name) IS NOT NULL;
IF tables IS NOT NULL THEN EXECUTE 'TRUNCATE '||tables||' RESTART IDENTITY'; END IF; IF tables IS NOT NULL THEN EXECUTE 'TRUNCATE '||tables||' RESTART IDENTITY'; END IF;
UPDATE public.meguri_runtime_guard SET started_at=clock_timestamp() WHERE id; UPDATE public.meguri_runtime_guard SET started_at=clock_timestamp() WHERE id;
END $$;").await?; END $$;").await?;
+21 -3
View File
@@ -31,6 +31,14 @@ impl Permission {
pub fn member() -> Vec<Self> { pub fn member() -> Vec<Self> {
vec![Self::View, Self::RequestOverrides] vec![Self::View, Self::RequestOverrides]
} }
/// Default employee role: calendar access, schedule edits, and approved replacements.
pub fn employee_default() -> Vec<Self> {
vec![Self::View, Self::EditSchedules, Self::RequestOverrides]
}
/// Permissions an administrator may grant the employee role.
pub fn grantable_to_employees(self) -> bool {
!matches!(self, Self::ManageMembers | Self::ManageIntegration)
}
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -43,6 +51,10 @@ pub struct User {
pub username: String, pub username: String,
/// None follows the browser timezone; this never changes scheduling rules. /// None follows the browser timezone; this never changes scheduling rules.
pub timezone: Option<String>, pub timezone: Option<String>,
#[serde(default)]
pub instance_admin: bool,
#[serde(default)]
pub has_password: bool,
} }
impl std::ops::Deref for User { impl std::ops::Deref for User {
@@ -56,6 +68,7 @@ impl std::ops::Deref for User {
pub struct Member { pub struct Member {
pub user: User, pub user: User,
pub permissions: Vec<Permission>, pub permissions: Vec<Permission>,
pub is_owner: bool,
} }
/// Scheduling metadata, not an authentication principal. Registered users retain their ID. /// Scheduling metadata, not an authentication principal. Registered users retain their ID.
@@ -66,6 +79,9 @@ pub struct Person {
pub email: Option<String>, pub email: Option<String>,
pub country: Option<String>, pub country: Option<String>,
pub user_id: Option<Uuid>, pub user_id: Option<Uuid>,
/// Former contact IDs that were merged into this person when a login was linked by email.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub aliases: Vec<Uuid>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -92,6 +108,9 @@ pub struct WorkspaceDocument {
pub name: String, pub name: String,
pub timezone: String, pub timezone: String,
pub horizon_days: u16, pub horizon_days: u16,
/// Grants held by the employee role; administrators always hold every permission.
#[serde(default = "Permission::employee_default")]
pub member_permissions: Vec<Permission>,
#[serde(default)] #[serde(default)]
pub people: Vec<Person>, pub people: Vec<Person>,
#[serde(default)] #[serde(default)]
@@ -120,6 +139,7 @@ impl WorkspaceDocument {
overwrite_drift: false, overwrite_drift: false,
timezone: "Europe/Bucharest".into(), timezone: "Europe/Bucharest".into(),
horizon_days: 90, horizon_days: 90,
member_permissions: Permission::employee_default(),
people: vec![], people: vec![],
schedules: vec![], schedules: vec![],
holidays: vec![], holidays: vec![],
@@ -176,9 +196,7 @@ impl WorkspaceDocument {
)?; )?;
validate_name(&person.display_name)?; validate_name(&person.display_name)?;
ensure( ensure(
person person.user_id.is_none_or(|id| id == person.id),
.user_id
.is_none_or(|id| id == person.id && members.contains(&id)),
crate::i18n::text("err_choose_a_user_who_has_access_to_this_workspace_41cf15"), crate::i18n::text("err_choose_a_user_who_has_access_to_this_workspace_41cf15"),
)?; )?;
ensure( ensure(
+1
View File
@@ -435,6 +435,7 @@ mod tests {
display_name: format!("Person {i}"), display_name: format!("Person {i}"),
email: None, email: None,
country: Some(if i == 0 { "AM" } else { "CY" }.into()), country: Some(if i == 0 { "AM" } else { "CY" }.into()),
aliases: vec![],
}) })
.collect(); .collect();
let day = "2026-03-30".parse().unwrap(); let day = "2026-03-30".parse().unwrap();
+1
View File
@@ -665,6 +665,7 @@ fn recipient(
display_name: email.clone(), display_name: email.clone(),
email: Some(email), email: Some(email),
country: Some("CY".into()), country: Some("CY".into()),
aliases: vec![],
}); });
Ok(id) Ok(id)
} }
+997
View File
@@ -0,0 +1,997 @@
//! Instance identity: configuration sync, group-derived access, SSO accounts, bootstrap
//! accounts and contact linking. Authorization stays on the server; the browser only
//! displays what these functions decide.
use crate::{
access::{Access, Config, Role, WorkspaceConfig, bootstrap_password, resolve_access},
domain::*,
error::{Error, Result, ensure},
models::*,
service,
store::{self, Store, WorkspaceRecord},
};
use chrono::{DateTime, Utc};
use cot::db::{DatabaseError, Model, query};
use serde_json::json;
use std::collections::BTreeSet;
use uuid::Uuid;
fn config_error(message: impl Into<String>) -> Error {
Error::Validation(format!("Invalid configuration: {}", message.into()))
}
pub fn normalize_email(value: &str) -> Option<String> {
let email = value.trim().to_lowercase();
(email.len() <= 254 && email_address::EmailAddress::is_valid(&email)).then_some(email)
}
pub fn new_user_row(username: String, display_name: String, hash: Option<String>) -> UserRow {
UserRow {
language: None,
demo_deleted_at: None,
id: Uuid::new_v4().to_string(),
username,
display_name,
country: "CY".into(),
timezone: None,
password_hash: hash,
created_at: Utc::now(),
email: None,
instance_admin: false,
bootstrap: false,
groups: "[]".into(),
}
}
async fn insert_user(db: &Store, row: &UserRow) -> Result<bool> {
match row.clone().insert(&db.db).await {
Ok(_) => Ok(true),
Err(DatabaseError::UniqueViolation) => Ok(false),
Err(e) => Err(e.into()),
}
}
// ---- workspaces from configuration ---------------------------------------------------
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorkspaceChange {
Created,
Adopted,
Enabled,
Renamed,
}
#[derive(Debug, Clone)]
pub struct EnsuredWorkspace {
pub id: Uuid,
pub changes: Vec<WorkspaceChange>,
}
const CREATE_CONFIGURED_WORKSPACE: &str = r#"
WITH workspace AS (
INSERT INTO meguri__workspaces(id,key,document,revision,created_at) VALUES($1,$2,$3,1,now()) RETURNING *
), history AS (
INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at,actor_id) SELECT id||':1',id,1,document,clock_timestamp(),NULL FROM workspace
), jobs AS (
INSERT INTO meguri__sync_jobs(workspace_id,requested_revision,state,attempts,next_attempt) SELECT id,1,'draft',0,now() FROM workspace
)
INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at) SELECT id,NULL,'workspace.created',$4,now() FROM workspace
"#;
const ENABLE_WORKSPACE: &str = r#"
WITH enabled AS (
UPDATE meguri__workspaces SET disabled_at=NULL WHERE id=$1 AND disabled_at IS NOT NULL AND demo_deleted_at IS NULL RETURNING id,revision
), jobs AS (
INSERT INTO meguri__sync_jobs(workspace_id,requested_revision,state,attempts,next_attempt) SELECT id,revision,'draft',0,now() FROM enabled
ON CONFLICT(workspace_id) DO UPDATE SET requested_revision=EXCLUDED.requested_revision
)
INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at) SELECT id,NULL,'workspace.enabled','{}',now() FROM enabled
"#;
const DISABLE_WORKSPACE: &str = r#"
WITH disabled AS (
UPDATE meguri__workspaces SET disabled_at=now() WHERE id=$1 AND disabled_at IS NULL AND demo_deleted_at IS NULL RETURNING id
), jobs AS (
DELETE FROM meguri__sync_jobs WHERE workspace_id IN(SELECT id FROM disabled)
), members AS (
DELETE FROM meguri__workspace_members WHERE workspace_id IN(SELECT id FROM disabled)
)
INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at) SELECT id,NULL,'workspace.disabled','{}',now() FROM disabled
"#;
/// Active (not demo-expired) workspace rows, including disabled ones.
async fn live_rows(db: &Store) -> Result<Vec<WorkspaceRow>> {
Ok(WorkspaceRow::objects()
.all(&db.db)
.await?
.into_iter()
.filter(|r| r.demo_deleted_at.is_none())
.collect())
}
fn record_of(row: &WorkspaceRow) -> Result<WorkspaceRecord> {
Ok(WorkspaceRecord {
id: store::uuid(&row.id)?,
revision: row.revision,
document: serde_json::from_str(&row.document)?,
})
}
fn ambiguous_adoption(config: &WorkspaceConfig, matches: usize) -> Error {
config_error(format!(
"workspace {:?} matches {matches} existing workspaces named {:?}; rename or delete the extras so the key can adopt exactly one",
config.key, config.name
))
}
/// Everything that can be refused from configuration alone is refused before the first write,
/// so a rejected configuration never leaves a half-applied instance behind.
async fn preflight(db: &Store, config: &Config, allow_disable_all: bool) -> Result<()> {
let rows = live_rows(db).await?;
if config.workspaces.is_empty() && !allow_disable_all {
let active = rows.iter().filter(|r| r.disabled_at.is_none()).count();
ensure(
active == 0,
format!(
"Refusing to disable {active} existing workspaces because the configuration lists none. Fix the configuration or set MEGURI_ALLOW_DISABLE_ALL=true to disable them all."
),
)?;
}
for workspace in &config.workspaces {
if rows
.iter()
.any(|r| r.key.as_deref() == Some(workspace.key.as_str()))
{
continue;
}
let mut matches = 0;
for row in rows.iter().filter(|r| r.key.is_none()) {
if record_of(row)?.document.name == workspace.name {
matches += 1;
}
}
if matches > 1 {
return Err(ambiguous_adoption(workspace, matches));
}
}
for username in config.bootstrap_usernames() {
let name = username.clone();
if let Some(row) = query!(UserRow,$username==name).get(&db.db).await? {
let id = row.id;
if query!(ExternalIdentityRow,$user_id==id)
.exists(&db.db)
.await?
{
return Err(config_error(format!(
"bootstrap username {username:?} belongs to an SSO account; choose another name"
)));
}
}
}
Ok(())
}
/// Creates, adopts (by exact name), enables or renames the workspace declared by `config`.
/// Every step is idempotent, so replicas that run it concurrently converge.
pub async fn ensure_workspace(db: &Store, config: &WorkspaceConfig) -> Result<EnsuredWorkspace> {
for _ in 0..4 {
let rows = live_rows(db).await?;
if let Some(row) = rows
.iter()
.find(|r| r.key.as_deref() == Some(config.key.as_str()))
{
let id = store::uuid(&row.id)?;
let mut changes = vec![];
if row.disabled_at.is_some() {
let result = db.db.raw_with(ENABLE_WORKSPACE, &[&row.id]).await?;
if result.rows_affected().0 > 0 {
changes.push(WorkspaceChange::Enabled);
}
}
let mut record = record_of(row)?;
if record.document.name != config.name {
let before = record.document.name.clone();
record.document.name = config.name.clone();
match db
.system_commit(
&mut record,
None,
"workspace.renamed",
json!({"from":before,"to":config.name}),
)
.await
{
Ok(()) => changes.push(WorkspaceChange::Renamed),
Err(Error::Conflict(_)) => continue,
Err(e) => return Err(e),
}
}
return Ok(EnsuredWorkspace { id, changes });
}
let mut candidates = vec![];
for row in rows.iter().filter(|r| r.key.is_none()) {
if record_of(row)?.document.name == config.name {
candidates.push(row);
}
}
if candidates.len() > 1 {
return Err(ambiguous_adoption(config, candidates.len()));
}
if let Some(row) = candidates.first() {
let adopted = db
.db
.raw_with(
"UPDATE meguri__workspaces SET key=$2 WHERE id=$1 AND key IS NULL",
&[&row.id, &config.key],
)
.await?;
if adopted.rows_affected().0 == 0 {
continue;
}
let mut changes = vec![WorkspaceChange::Adopted];
if row.disabled_at.is_some() {
db.db.raw_with(ENABLE_WORKSPACE, &[&row.id]).await?;
changes.push(WorkspaceChange::Enabled);
}
return Ok(EnsuredWorkspace {
id: store::uuid(&row.id)?,
changes,
});
}
let id = Uuid::new_v4();
let document = serde_json::to_string(&WorkspaceDocument::new(config.name.clone()))?;
let details = json!({"key":config.key}).to_string();
match db
.db
.raw_with(
CREATE_CONFIGURED_WORKSPACE,
&[&id.to_string(), &config.key, &document, &details],
)
.await
{
Ok(_) => {
return Ok(EnsuredWorkspace {
id,
changes: vec![WorkspaceChange::Created],
});
}
Err(DatabaseError::UniqueViolation) => continue,
Err(e) => return Err(e.into()),
}
}
Err(Error::Internal(format!(
"workspace {:?} kept changing while it was being created",
config.key
)))
}
/// The enabled workspace declared with `key`, if it exists.
pub async fn find_workspace(db: &Store, key: &str) -> Result<Option<WorkspaceRecord>> {
live_rows(db)
.await?
.iter()
.find(|r| r.key.as_deref() == Some(key) && r.disabled_at.is_none())
.map(record_of)
.transpose()
}
pub async fn disable_workspace(db: &Store, id: &str) -> Result<bool> {
Ok(db
.db
.raw_with(DISABLE_WORKSPACE, &[&id])
.await?
.rows_affected()
.0
> 0)
}
// ---- group-derived access -------------------------------------------------------------
const APPLY_ACCESS: &str = r#"
WITH changed AS (
UPDATE meguri__users SET groups=$2,instance_admin=$3 WHERE id=$1 RETURNING id
), desired AS (
SELECT w.id AS workspace_id,d.is_owner FROM jsonb_to_recordset($4::jsonb) AS d(key text,is_owner boolean)
JOIN meguri__workspaces w ON w.key=d.key AND w.demo_deleted_at IS NULL AND w.disabled_at IS NULL
), removed AS (
DELETE FROM meguri__workspace_members m WHERE m.user_id=$1 AND EXISTS(SELECT 1 FROM changed)
AND NOT EXISTS(SELECT 1 FROM desired d WHERE d.workspace_id=m.workspace_id)
)
INSERT INTO meguri__workspace_members(id,workspace_id,user_id,permissions,is_owner)
SELECT d.workspace_id||':'||$1,d.workspace_id,$1,
CASE WHEN d.is_owner THEN $5 ELSE COALESCE((SELECT w.document::jsonb->'member_permissions' FROM meguri__workspaces w WHERE w.id=d.workspace_id)::text,$6) END,
d.is_owner
FROM desired d WHERE EXISTS(SELECT 1 FROM changed)
ON CONFLICT(id) DO UPDATE SET permissions=EXCLUDED.permissions,is_owner=EXCLUDED.is_owner
"#;
pub fn stored_groups(row: &UserRow) -> Result<Vec<String>> {
Ok(serde_json::from_str(&row.groups)?)
}
/// The one place that turns groups into memberships and the instance-admin flag. SSO login,
/// the administrator's password-user editor and startup reconciliation all end here.
/// `groups` replaces the stored list when given.
pub async fn apply_access(
db: &Store,
config: &Config,
user: Uuid,
groups: Option<Vec<String>>,
) -> Result<Access> {
let id = user.to_string();
let row = query!(UserRow,$id==id.clone())
.get(&db.db)
.await?
.ok_or(Error::NotFound)?;
let mut groups = match groups {
Some(groups) => groups,
None => stored_groups(&row)?,
};
groups.sort();
groups.dedup();
let access = resolve_access(config, &groups, row.bootstrap);
let desired: Vec<_> = access
.workspaces
.iter()
.map(|(key, role)| json!({"key":key,"is_owner":*role == Role::Admin}))
.collect();
db.db
.raw_with(
APPLY_ACCESS,
&[
&id,
&serde_json::to_string(&groups)?,
&access.instance_admin,
&serde_json::to_string(&desired)?,
&serde_json::to_string(&Permission::all())?,
&serde_json::to_string(&Permission::employee_default())?,
],
)
.await?;
Ok(access)
}
pub async fn reconcile_all(db: &Store, config: &Config) -> Result<usize> {
let mut count = 0;
for row in UserRow::objects().all(&db.db).await? {
if row.demo_deleted_at.is_some() {
continue;
}
let id = store::uuid(&row.id)?;
apply_access(db, config, id, None).await?;
if let Err(error) = link_contacts(db, id).await {
tracing::warn!(user = %id, %error, "could not refresh the account person in its workspaces");
}
count += 1;
}
Ok(count)
}
// ---- bootstrap accounts ---------------------------------------------------------------
pub enum BootstrapPasswords {
/// HMAC of a database-held secret and the current UTC hour: identical across replicas.
Derived,
/// One generated password for a single-process demonstration run.
Fixed(String),
}
async fn instance_secret(db: &Store) -> Result<Vec<u8>> {
let candidate = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple());
db.db
.raw_with(
"INSERT INTO meguri__instance_state(key,value) VALUES('bootstrap_secret',$1) ON CONFLICT(key) DO NOTHING",
&[&candidate],
)
.await?;
let secret = query!(InstanceStateRow,$key=="bootstrap_secret".to_owned())
.get(&db.db)
.await?
.ok_or_else(|| Error::Internal("bootstrap secret is missing".into()))?;
Ok(secret.value.into_bytes())
}
/// Creates or refreshes one bootstrap account. Returns whether the password changed.
async fn ensure_bootstrap_account(db: &Store, username: &str, password: &str) -> Result<bool> {
for _ in 0..3 {
let name = username.to_owned();
match query!(UserRow,$username==name).get(&db.db).await? {
Some(row) => {
let id = row.id.clone();
if query!(ExternalIdentityRow,$user_id==id)
.exists(&db.db)
.await?
{
return Err(config_error(format!(
"bootstrap username {username:?} belongs to an SSO account; choose another name"
)));
}
if row.bootstrap
&& let Some(hash) = row.password_hash.clone()
&& service::password_matches(hash, password.to_owned()).await?
{
return Ok(false);
}
let hash = service::hash_password(password.to_owned()).await?;
db.db
.raw_with(
"UPDATE meguri__users SET password_hash=$2,bootstrap=true,demo_deleted_at=NULL WHERE id=$1",
&[&row.id, &hash],
)
.await?;
db.db
.raw_with("DELETE FROM meguri__sessions WHERE user_id=$1", &[&row.id])
.await?;
return Ok(true);
}
None => {
let hash = service::hash_password(password.to_owned()).await?;
let mut row = new_user_row(username.to_owned(), username.to_owned(), Some(hash));
row.bootstrap = true;
if insert_user(db, &row).await? {
return Ok(true);
}
}
}
}
Err(Error::Internal(format!(
"could not create bootstrap account {username:?}"
)))
}
/// Accounts that stopped being bootstrap accounts lose their password and their sessions.
async fn release_bootstrap_accounts(db: &Store, keep: &[String]) -> Result<Vec<String>> {
let mut released = vec![];
for row in query!(UserRow,$bootstrap==true).all(&db.db).await? {
if keep.contains(&row.username) {
continue;
}
db.db
.raw_with(
"UPDATE meguri__users SET bootstrap=false,password_hash=NULL WHERE id=$1",
&[&row.id],
)
.await?;
db.db
.raw_with("DELETE FROM meguri__sessions WHERE user_id=$1", &[&row.id])
.await?;
released.push(row.username);
}
Ok(released)
}
// ---- synchronization ----------------------------------------------------------------
#[derive(Debug, Default, Clone)]
pub struct SyncReport {
pub created: Vec<String>,
pub adopted: Vec<String>,
pub enabled: Vec<String>,
pub renamed: Vec<String>,
pub disabled: Vec<String>,
pub bootstrap_rotated: Vec<String>,
pub bootstrap_released: Vec<String>,
/// Current username/password pairs; the caller decides where to show them.
pub bootstrap_credentials: Vec<(String, String)>,
}
pub struct SyncOptions {
pub passwords: BootstrapPasswords,
pub now: DateTime<Utc>,
/// Allows a configuration without workspaces to disable every existing one.
pub allow_disable_all: bool,
}
/// Makes the database match the declarative configuration: configured workspaces exist and are
/// enabled, other workspaces are disabled (never deleted), bootstrap accounts match the
/// configured list, and every user's memberships follow their stored groups.
pub async fn sync_config(db: &Store, config: &Config, options: SyncOptions) -> Result<SyncReport> {
config.validate()?;
preflight(db, config, options.allow_disable_all).await?;
let mut report = SyncReport::default();
let mut keys = BTreeSet::new();
for workspace in &config.workspaces {
keys.insert(workspace.key.clone());
let ensured = ensure_workspace(db, workspace).await?;
for change in &ensured.changes {
let (list, action) = match change {
WorkspaceChange::Created => (&mut report.created, "workspace.created"),
WorkspaceChange::Adopted => (&mut report.adopted, "workspace.adopted"),
WorkspaceChange::Enabled => (&mut report.enabled, "workspace.enabled"),
WorkspaceChange::Renamed => (&mut report.renamed, "workspace.renamed"),
};
list.push(workspace.key.clone());
db.instance_event(
None,
action,
json!({"key":workspace.key,"id":ensured.id,"name":workspace.name}),
)
.await?;
}
}
for row in live_rows(db).await? {
let configured = row.key.as_ref().is_some_and(|k| keys.contains(k));
if !configured && row.disabled_at.is_none() && disable_workspace(db, &row.id).await? {
let name = record_of(&row)?.document.name;
report
.disabled
.push(row.key.clone().unwrap_or_else(|| name.clone()));
db.instance_event(
None,
"workspace.disabled",
json!({"key":row.key,"id":row.id,"name":name}),
)
.await?;
}
}
let usernames = config.bootstrap_usernames();
report.bootstrap_released = release_bootstrap_accounts(db, usernames).await?;
if !usernames.is_empty() {
let secret = match options.passwords {
BootstrapPasswords::Derived => Some(instance_secret(db).await?),
BootstrapPasswords::Fixed(_) => None,
};
for username in usernames {
let password = match (&options.passwords, &secret) {
(BootstrapPasswords::Fixed(password), _) => password.clone(),
(BootstrapPasswords::Derived, Some(secret)) => {
bootstrap_password(secret, username, options.now)
}
_ => unreachable!("derived passwords always load the instance secret"),
};
if ensure_bootstrap_account(db, username, &password).await? {
report.bootstrap_rotated.push(username.clone());
db.instance_event(None, "bootstrap.password_set", json!({"username":username}))
.await?;
}
report
.bootstrap_credentials
.push((username.clone(), password));
}
}
reconcile_all(db, config).await?;
for (list, label) in [
(&report.created, "created"),
(&report.adopted, "adopted"),
(&report.enabled, "enabled"),
(&report.renamed, "renamed"),
(&report.disabled, "disabled"),
] {
if !list.is_empty() {
tracing::info!(workspaces = ?list, "workspaces {label} from configuration");
}
}
Ok(report)
}
// ---- SSO accounts -------------------------------------------------------------------
#[derive(Debug, Clone)]
pub struct SsoProfile {
pub issuer: String,
pub subject: String,
pub email: Option<String>,
pub username_hint: Option<String>,
pub display_name: Option<String>,
pub groups: Vec<String>,
}
#[derive(Debug)]
pub enum SsoOutcome {
/// The provider's groups map to nothing; no account is created or kept enabled.
NoAccess,
SignedIn {
user: Box<User>,
token: String,
csrf: String,
},
}
fn username_candidates(profile: &SsoProfile) -> Vec<String> {
let clean = |value: &str| -> String {
value
.trim()
.to_lowercase()
.chars()
.map(|c| {
if c.is_ascii_lowercase() || c.is_ascii_digit() || "._-".contains(c) {
c
} else {
'-'
}
})
.collect::<String>()
.trim_matches('-')
.chars()
.take(56)
.collect()
};
let mut bases: Vec<String> = [
profile.username_hint.as_deref(),
profile.email.as_deref().and_then(|e| e.split('@').next()),
]
.into_iter()
.flatten()
.map(clean)
.filter(|b| b.len() >= 3)
.collect();
if bases.is_empty() {
bases.push("user".into());
}
let suffix = &service::token_hash(&format!("{}\n{}", profile.issuer, profile.subject))[..6];
let mut candidates = vec![];
for base in &bases {
candidates.push(base.clone());
candidates.push(format!("{base}-{suffix}"));
}
candidates
}
async fn find_identity(db: &Store, profile: &SsoProfile) -> Result<Option<String>> {
let (issuer, subject) = (profile.issuer.clone(), profile.subject.clone());
Ok(
query!(ExternalIdentityRow,$issuer==issuer && $subject==subject)
.get(&db.db)
.await?
.map(|i| i.user_id),
)
}
async fn create_sso_user(db: &Store, profile: &SsoProfile) -> Result<String> {
for username in username_candidates(profile) {
let display = profile
.display_name
.as_deref()
.map(str::trim)
.filter(|n| !n.is_empty() && n.len() <= 120)
.unwrap_or(&username)
.to_owned();
let mut row = new_user_row(username, display, None);
row.email = profile.email.clone();
if !insert_user(db, &row).await? {
continue;
}
let mut identity = ExternalIdentityRow {
id: Uuid::new_v4().to_string(),
issuer: profile.issuer.clone(),
subject: profile.subject.clone(),
user_id: row.id.clone(),
};
match identity.insert(&db.db).await {
Ok(_) => return Ok(row.id),
Err(DatabaseError::UniqueViolation) => {
db.db
.raw_with("DELETE FROM meguri__users WHERE id=$1", &[&row.id])
.await?;
return find_identity(db, profile).await?.ok_or_else(|| {
Error::Internal("SSO identity disappeared during sign-in".into())
});
}
Err(e) => return Err(e.into()),
}
}
Err(Error::Internal(
"could not derive a unique username for the SSO account".into(),
))
}
/// Signs in a verified SSO identity. Groups are applied before anything else, so access that
/// was revoked in the provider disappears at this login and an unmapped user stays out.
pub async fn sso_sign_in(db: &Store, config: &Config, profile: SsoProfile) -> Result<SsoOutcome> {
let existing = find_identity(db, &profile).await?;
let user_id = match existing {
Some(id) => id,
None => {
if resolve_access(config, &profile.groups, false).is_empty() {
return Ok(SsoOutcome::NoAccess);
}
let created = create_sso_user(db, &profile).await?;
db.instance_event(
None,
"user.created",
json!({"user_id":created,"sso":true,"subject":profile.subject,"email":profile.email,"groups":profile.groups}),
)
.await?;
created
}
};
let id = store::uuid(&user_id)?;
let access = apply_access(db, config, id, Some(profile.groups.clone())).await?;
if access.is_empty() {
return Ok(SsoOutcome::NoAccess);
}
if let Some(email) = &profile.email {
db.db
.raw_with(
"UPDATE meguri__users SET email=$2 WHERE id=$1 AND email IS DISTINCT FROM $2",
&[&user_id, email],
)
.await?;
}
if let Err(error) = link_contacts(db, id).await {
tracing::warn!(user = %id, %error, "could not link the sign-in email to workspace contacts");
}
let row = query!(UserRow,$id==user_id)
.get(&db.db)
.await?
.ok_or(Error::NotFound)?;
let user = store::user(row)?;
let (token, csrf) = service::create_session(db, user.id).await?;
Ok(SsoOutcome::SignedIn {
user: Box::new(user),
token,
csrf,
})
}
// ---- contact linking ---------------------------------------------------------------
/// In every workspace the user belongs to, binds the user's email to the scheduling person
/// that carries it, so people planned by email are ready at first sign-in:
/// * a contact with the same email becomes the user (the contact's old ID is kept as an alias
/// and every reference is rewritten, preserving schedules, requests and variables);
/// * otherwise the user's own person gains the email, or is added with it.
///
/// Ambiguous or conflicting data is skipped, never guessed. Access is never granted by
/// email: only group membership creates workspace access.
pub async fn link_contacts(db: &Store, user: Uuid) -> Result<()> {
let id = user.to_string();
let row = query!(UserRow,$id==id.clone())
.get(&db.db)
.await?
.ok_or(Error::NotFound)?;
let email = row.email.as_deref().and_then(normalize_email);
for membership in query!(WorkspaceMemberRow,$user_id==id).all(&db.db).await? {
let workspace = store::uuid(&membership.workspace_id)?;
for _ in 0..3 {
let Ok(mut record) = db.workspace(workspace).await else {
break;
};
let members = db.member_ids(workspace).await?;
let changed = match &email {
Some(email) => linked_document(&record.document, &row, user, email),
None => account_person_added(&record.document, &row, user),
};
let Some((document, details)) = changed else {
break;
};
record.document = document;
if record.document.validate(&members).is_err() {
tracing::warn!(%workspace, %user, "contact linking skipped: the result would be invalid");
break;
}
match db
.system_commit(&mut record, Some(user), "person.linked", details)
.await
{
Ok(()) => break,
Err(Error::Conflict(_)) => continue,
Err(e) => return Err(e),
}
}
}
Ok(())
}
/// Accounts without an email still own a Person in every workspace they reach, so planning
/// can use them at once; nothing else can be linked without an address.
fn account_person_added(
document: &WorkspaceDocument,
user: &UserRow,
user_id: Uuid,
) -> Option<(WorkspaceDocument, serde_json::Value)> {
if document.people.iter().any(|p| p.id == user_id) {
return None;
}
let mut linked = document.clone();
linked.people.push(Person {
id: user_id,
user_id: Some(user_id),
display_name: user.display_name.clone(),
email: None,
country: Some(user.country.clone()),
aliases: vec![],
});
Some((linked, json!({"user_id":user_id,"merged":false})))
}
/// Pure transformation for [`link_contacts`]. `None` means nothing to change.
pub fn linked_document(
document: &WorkspaceDocument,
user: &UserRow,
user_id: Uuid,
email: &str,
) -> Option<(WorkspaceDocument, serde_json::Value)> {
let contacts: Vec<usize> = document
.people
.iter()
.enumerate()
.filter(|(_, p)| p.email.as_deref() == Some(email) && p.id != user_id)
.map(|(i, _)| i)
.collect();
if contacts.len() > 1 {
return None;
}
if let Some(&index) = contacts.first() {
let contact = &document.people[index];
if contact.user_id.is_some() {
return None;
}
let old = contact.id;
let mut working = document.clone();
working.people.retain(|p| p.id != user_id);
let text = serde_json::to_string(&working)
.ok()?
.replace(&old.to_string(), &user_id.to_string());
let mut linked: WorkspaceDocument = serde_json::from_str(&text).ok()?;
let person = linked.people.iter_mut().find(|p| p.id == user_id)?;
person.user_id = Some(user_id);
if !person.aliases.contains(&old) {
person.aliases.push(old);
}
return Some((
linked,
json!({"user_id":user_id,"contact_id":old,"merged":true}),
));
}
match document.people.iter().position(|p| p.id == user_id) {
Some(index) => {
let person = &document.people[index];
if person.email.as_deref() == Some(email) && person.user_id == Some(user_id) {
return None;
}
let mut linked = document.clone();
linked.people[index].email = Some(email.to_owned());
linked.people[index].user_id = Some(user_id);
Some((linked, json!({"user_id":user_id,"merged":false})))
}
None => {
let mut linked = document.clone();
linked.people.push(Person {
id: user_id,
user_id: Some(user_id),
display_name: user.display_name.clone(),
email: Some(email.to_owned()),
country: Some(user.country.clone()),
aliases: vec![],
});
Some((linked, json!({"user_id":user_id,"merged":false})))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn row() -> UserRow {
new_user_row("dana".into(), "Dana".into(), None)
}
fn contact(email: &str) -> Person {
Person {
id: Uuid::new_v4(),
user_id: None,
display_name: "Contact".into(),
email: Some(email.into()),
country: Some("AM".into()),
aliases: vec![],
}
}
#[test]
fn a_matching_contact_becomes_the_user_everywhere_without_duplicates() {
let user = Uuid::new_v4();
let existing_self = Person {
id: user,
user_id: Some(user),
display_name: "Dana".into(),
email: None,
country: Some("CY".into()),
aliases: vec![],
};
let mut doc = WorkspaceDocument::new("Team".into());
let c = contact("dana@corp.test");
let old = c.id;
doc.people = vec![existing_self, c];
doc.generation.variables = vec![];
doc.absences.push(Absence {
id: Uuid::new_v4(),
user_id: old,
start: "2026-11-01".parse().unwrap(),
end: "2026-11-02".parse().unwrap(),
});
let (linked, details) = linked_document(&doc, &row(), user, "dana@corp.test").unwrap();
assert_eq!(linked.people.len(), 1, "no second entry for the same user");
let person = &linked.people[0];
assert_eq!((person.id, person.user_id), (user, Some(user)));
assert_eq!(person.aliases, vec![old]);
assert_eq!(person.display_name, "Contact");
assert_eq!(linked.absences[0].user_id, user);
assert_eq!(details["contact_id"], json!(old));
assert_eq!(
serde_json::to_string(&linked)
.unwrap()
.matches(&old.to_string())
.count(),
1,
"the old id survives only as the alias"
);
// Applying the result again is a no-op.
assert!(linked_document(&linked, &row(), user, "dana@corp.test").is_none());
}
#[test]
fn ambiguous_or_conflicting_data_is_never_guessed() {
let user = Uuid::new_v4();
let mut doc = WorkspaceDocument::new("Team".into());
// Two contacts with one email cannot validate, and must not be merged either.
doc.people = vec![contact("dana@corp.test"), contact("dana@corp.test")];
assert!(linked_document(&doc, &row(), user, "dana@corp.test").is_none());
// A contact that already belongs to another login is not stolen.
let mut taken = contact("dana@corp.test");
taken.user_id = Some(Uuid::new_v4());
doc.people = vec![taken];
assert!(linked_document(&doc, &row(), user, "dana@corp.test").is_none());
// The account owns its person: when the sign-in email changed, the person follows it.
doc.people = vec![Person {
id: user,
user_id: Some(user),
display_name: "Dana".into(),
email: Some("old@corp.test".into()),
country: None,
aliases: vec![],
}];
let (linked, _) = linked_document(&doc, &row(), user, "dana@corp.test").unwrap();
assert_eq!(linked.people[0].email.as_deref(), Some("dana@corp.test"));
}
#[test]
fn users_without_a_contact_gain_a_person_with_their_email() {
let user = Uuid::new_v4();
let doc = WorkspaceDocument::new("Team".into());
let (linked, _) = linked_document(&doc, &row(), user, "dana@corp.test").unwrap();
assert_eq!(linked.people[0].email.as_deref(), Some("dana@corp.test"));
assert_eq!(linked.people[0].user_id, Some(user));
let mut with_person = doc.clone();
with_person.people.push(Person {
id: user,
user_id: Some(user),
display_name: "Dana".into(),
email: None,
country: None,
aliases: vec![],
});
let (linked, _) = linked_document(&with_person, &row(), user, "dana@corp.test").unwrap();
assert_eq!(linked.people.len(), 1);
assert_eq!(linked.people[0].email.as_deref(), Some("dana@corp.test"));
}
#[test]
fn usernames_are_clean_unique_candidates() {
let profile = |hint: Option<&str>, email: Option<&str>| SsoProfile {
issuer: "https://idp.test".into(),
subject: "sub-1".into(),
email: email.map(str::to_owned),
username_hint: hint.map(str::to_owned),
display_name: None,
groups: vec![],
};
let candidates = username_candidates(&profile(Some("Jane Doe!"), Some("jd@corp.test")));
assert_eq!(candidates[0], "jane-doe");
assert!(
candidates[1].starts_with("jane-doe-") && candidates[1].len() == "jane-doe-".len() + 6
);
// Too-short hints fall back to the email name, and nothing at all to a neutral base.
assert_eq!(
username_candidates(&profile(Some("ab"), Some("Lena.Ivanova@corp.test")))[0],
"lena.ivanova"
);
assert_eq!(username_candidates(&profile(None, None))[0], "user");
assert!(candidates.iter().all(|c| crate::access::valid_username(c)));
let long = "x".repeat(200);
assert!(
username_candidates(&profile(Some(&long), None))
.iter()
.all(|c| crate::access::valid_username(c))
);
}
}
+4 -2
View File
@@ -1,4 +1,6 @@
pub mod absence; pub mod absence;
pub mod access;
pub mod admin;
pub mod audit; pub mod audit;
pub mod configuration; pub mod configuration;
pub mod demo; pub mod demo;
@@ -10,8 +12,10 @@ pub mod generation;
pub mod generation_service; pub mod generation_service;
pub mod holidays; pub mod holidays;
pub mod i18n; pub mod i18n;
pub mod identity;
pub mod migrations; pub mod migrations;
pub mod models; pub mod models;
pub mod oidc;
pub mod portable; pub mod portable;
mod portable_v2; mod portable_v2;
pub mod providers; pub mod providers;
@@ -19,5 +23,3 @@ pub mod service;
pub mod store; pub mod store;
pub mod web; pub mod web;
pub mod worker; pub mod worker;
pub mod site;
+64 -7
View File
@@ -3,12 +3,30 @@ use cot::{
config::{DatabaseConfig, ProjectConfig}, config::{DatabaseConfig, ProjectConfig},
}; };
use meguri::{ use meguri::{
demo_runtime, service, access::{self, Config},
demo, demo_runtime, identity, oidc, service,
store::Store, store::Store,
web::{MeguriProject, enabled}, web::{MeguriProject, enabled},
worker, worker,
}; };
fn load_config(demo: bool) -> Result<Config, Box<dyn std::error::Error>> {
if demo {
if std::env::var_os("MEGURI_CONFIG_FILE").is_some() {
tracing::warn!(
"Demo mode uses its built-in configuration; MEGURI_CONFIG_FILE is ignored"
);
}
return Ok(demo::config()?);
}
let path = std::env::var("MEGURI_CONFIG_FILE").map_err(|_| {
"Set MEGURI_CONFIG_FILE to the instance configuration file (workspaces, group mappings and sign-in); see docs/deployment.md"
})?;
let text = std::fs::read_to_string(&path)
.map_err(|e| format!("Cannot read MEGURI_CONFIG_FILE {path:?}: {e}"))?;
Ok(Config::parse(&text)?)
}
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt() tracing_subscriber::fmt()
@@ -22,6 +40,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
if !database_url.starts_with("postgresql://") { if !database_url.starts_with("postgresql://") {
return Err("DATABASE_URL must use the postgresql:// scheme".into()); return Err("DATABASE_URL must use the postgresql:// scheme".into());
} }
let demo_mode = enabled("MEGURI_SEED_DEMO");
let config = load_config(demo_mode)?;
let role = std::env::var("MEGURI_ROLE").unwrap_or_else(|_| "all".into()); let role = std::env::var("MEGURI_ROLE").unwrap_or_else(|_| "all".into());
let bind = std::env::var("MEGURI_BIND").unwrap_or_else(|_| "127.0.0.1:3000".into()); let bind = std::env::var("MEGURI_BIND").unwrap_or_else(|_| "127.0.0.1:3000".into());
let public_url = let public_url =
@@ -31,6 +51,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
if public.scheme() != "https" && !(public.scheme() == "http" && loopback) { if public.scheme() != "https" && !(public.scheme() == "http" && loopback) {
return Err("MEGURI_PUBLIC_URL must use HTTPS outside localhost".into()); return Err("MEGURI_PUBLIC_URL must use HTTPS outside localhost".into());
} }
if let Some(settings) = config.oidc.clone() {
let client_secret = std::env::var("MEGURI_OIDC_CLIENT_SECRET")
.ok()
.filter(|s| !s.is_empty())
.ok_or("Set MEGURI_OIDC_CLIENT_SECRET (a deployment secret) when oidc is configured")?;
oidc::install(oidc::Settings {
config: settings,
client_secret,
redirect_uri: format!("{}/api/oidc/callback", public_url.trim_end_matches('/')),
});
}
access::install(config.clone());
if enabled("MEGURI_SEED_DEMO") && (!loopback || !bind.starts_with("127.0.0.1:")) { if enabled("MEGURI_SEED_DEMO") && (!loopback || !bind.starts_with("127.0.0.1:")) {
return Err("Demo accounts require a loopback bind and URL".into()); return Err("Demo accounts require a loopback bind and URL".into());
} }
@@ -45,9 +77,20 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
database_url = selected; database_url = selected;
let connection = cot::db::Database::new(&database_url).await?; let connection = cot::db::Database::new(&database_url).await?;
demo_runtime::reset(&connection).await?; demo_runtime::reset(&connection).await?;
let password = access::random_password();
tracing::warn!(
"Demo accounts ({}) sign in with the password {password}; it changes on every start and is also shown on the login screen",
demo::accounts()
.iter()
.map(|a| a.username.clone())
.collect::<Vec<_>>()
.join(", ")
);
demo_runtime::install(demo_runtime::Notice { demo_runtime::install(demo_runtime::Notice {
isolated, isolated,
retention, retention,
password,
accounts: demo::accounts(),
}); });
if isolated { if isolated {
tracing::warn!( tracing::warn!(
@@ -65,18 +108,32 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
} else { } else {
Store::connect(&database_url).await? Store::connect(&database_url).await?
}; };
// Whoever owns the migrations also applies the declarative configuration. Demo mode
// synchronizes while seeding. Replicas started with MEGURI_SKIP_MIGRATIONS only read.
if !demo_mode && role != "worker" && !enabled("MEGURI_SKIP_MIGRATIONS") {
let report = identity::sync_config(
&db,
&config,
identity::SyncOptions {
passwords: identity::BootstrapPasswords::Derived,
now: chrono::Utc::now(),
allow_disable_all: enabled("MEGURI_ALLOW_DISABLE_ALL"),
},
)
.await?;
for (username, password) in &report.bootstrap_credentials {
tracing::warn!(
"Bootstrap administrator enabled: sign in as {username} with password {password} (it stays valid until a later start in another UTC hour replaces it). Remove auth.bootstrap_admin from the configuration to disable it."
);
}
}
if role == "migrate" { if role == "migrate" {
tracing::info!("Cot migrations completed"); tracing::info!("Cot migrations and configuration sync completed");
return Ok(()); return Ok(());
} }
if enabled("MEGURI_SEED_DEMO") { if enabled("MEGURI_SEED_DEMO") {
service::seed_demo(&db).await?; service::seed_demo(&db).await?;
} }
if let Ok(username) = std::env::var("MEGURI_BOOTSTRAP_ADMIN")
&& !username.trim().is_empty()
{
meguri::site::bootstrap(&db, &username).await?;
}
let demo_maintenance = demo_runtime::notice() let demo_maintenance = demo_runtime::notice()
.map(|notice| tokio::spawn(demo_runtime::run(db.clone(), notice.retention.clone()))); .map(|notice| tokio::spawn(demo_runtime::run(db.clone(), notice.retention.clone())));
if role == "worker" { if role == "worker" {
+4 -8
View File
@@ -6,10 +6,8 @@ pub mod m_0005_variables;
pub mod m_0006_demo_lifecycle; pub mod m_0006_demo_lifecycle;
pub mod m_0007_language; pub mod m_0007_language;
pub mod m_0008_workspaces; pub mod m_0008_workspaces;
pub mod m_0009_manual_publication; pub mod m_0009_access;
pub mod m_0010_site_admin; pub mod m_0010_manual_publication;
pub mod m_0010b_contact_accounts;
pub mod m_0011_account_people;
pub const MIGRATIONS: &[&cot::db::migrations::SyncDynMigration] = &[ pub const MIGRATIONS: &[&cot::db::migrations::SyncDynMigration] = &[
&m_0001_initial::Migration, &m_0001_initial::Migration,
&m_0002_constraints::Migration, &m_0002_constraints::Migration,
@@ -19,8 +17,6 @@ pub const MIGRATIONS: &[&cot::db::migrations::SyncDynMigration] = &[
&m_0006_demo_lifecycle::Migration, &m_0006_demo_lifecycle::Migration,
&m_0007_language::Migration, &m_0007_language::Migration,
&m_0008_workspaces::Migration, &m_0008_workspaces::Migration,
&m_0009_manual_publication::Migration, &m_0009_access::Migration,
&m_0010_site_admin::Migration, &m_0010_manual_publication::Migration,
&m_0010b_contact_accounts::Migration,
&m_0011_account_people::Migration,
]; ];
+27
View File
@@ -0,0 +1,27 @@
use cot::db::migrations::{MigrationContext, MigrationDependency, Operation, migration_op};
#[derive(Debug, Copy, Clone)]
pub struct Migration;
impl cot::db::migrations::Migration for Migration {
const APP_NAME: &'static str = "meguri";
const MIGRATION_NAME: &'static str = "m_0009_access";
const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration(
"meguri",
"m_0008_workspaces",
)];
const OPERATIONS: &'static [Operation] = &[Operation::custom(upgrade).build()];
}
#[migration_op]
async fn upgrade(context: MigrationContext<'_>) -> cot::db::Result<()> {
context.db.raw(r#"DO $$ BEGIN
ALTER TABLE meguri__users ALTER COLUMN password_hash DROP NOT NULL;
ALTER TABLE meguri__users ADD COLUMN email text, ADD COLUMN instance_admin boolean NOT NULL DEFAULT false, ADD COLUMN bootstrap boolean NOT NULL DEFAULT false, ADD COLUMN groups text NOT NULL DEFAULT '[]' CHECK(jsonb_typeof(groups::jsonb)='array');
ALTER TABLE meguri__workspaces ADD COLUMN key text, ADD COLUMN disabled_at timestamptz;
CREATE UNIQUE INDEX workspace_key_unique ON meguri__workspaces(key) WHERE key IS NOT NULL AND demo_deleted_at IS NULL;
CREATE UNIQUE INDEX external_identity_subject ON meguri__external_identities(issuer,subject);
CREATE INDEX external_identity_user ON meguri__external_identities(user_id);
CREATE TABLE meguri__instance_state(key text PRIMARY KEY,value text NOT NULL);
CREATE TABLE meguri__instance_audit(id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,actor_id text,action text NOT NULL,details text NOT NULL,created_at timestamptz NOT NULL);
CREATE TABLE meguri__oidc_flows(state_hash text PRIMARY KEY,nonce text NOT NULL,verifier text NOT NULL,expires_at timestamptz NOT NULL);
END $$;"#).await?;
Ok(())
}
@@ -3,11 +3,9 @@ use cot::db::migrations::{MigrationContext, MigrationDependency, Operation, migr
pub struct Migration; pub struct Migration;
impl cot::db::migrations::Migration for Migration { impl cot::db::migrations::Migration for Migration {
const APP_NAME: &'static str = "meguri"; const APP_NAME: &'static str = "meguri";
const MIGRATION_NAME: &'static str = "m_0009_manual_publication"; const MIGRATION_NAME: &'static str = "m_0010_manual_publication";
const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration( const DEPENDENCIES: &'static [MigrationDependency] =
"meguri", &[MigrationDependency::migration("meguri", "m_0009_access")];
"m_0008_workspaces",
)];
const OPERATIONS: &'static [Operation] = &[Operation::custom(upgrade).build()]; const OPERATIONS: &'static [Operation] = &[Operation::custom(upgrade).build()];
} }
#[migration_op] #[migration_op]
-23
View File
@@ -1,23 +0,0 @@
use cot::db::migrations::{MigrationContext, MigrationDependency, Operation, migration_op};
#[derive(Debug, Copy, Clone)]
pub struct Migration;
impl cot::db::migrations::Migration for Migration {
const APP_NAME: &'static str = "meguri";
const MIGRATION_NAME: &'static str = "m_0010_site_admin";
const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration(
"meguri",
"m_0009_manual_publication",
)];
const OPERATIONS: &'static [Operation] = &[Operation::custom(upgrade).build()];
}
#[migration_op]
async fn upgrade(context: MigrationContext<'_>) -> cot::db::Result<()> {
context.db.raw(r#"DO $$ BEGIN
CREATE TABLE meguri__site_access(user_id text PRIMARY KEY REFERENCES meguri__users(id) ON DELETE CASCADE, administrator boolean NOT NULL DEFAULT false, disabled boolean NOT NULL DEFAULT false, revision bigint NOT NULL DEFAULT 1);
CREATE TABLE meguri__site_settings(section text PRIMARY KEY, revision bigint NOT NULL, payload text NOT NULL);
INSERT INTO meguri__site_settings VALUES('authentication',1,'{"oidc_enabled":false,"password_enabled":true,"issuer":"","client_id":"","client_secret_env":"MEGURI_OIDC_CLIENT_SECRET","button_label":"Single sign-on","allow_new_users":false}');
INSERT INTO meguri__site_settings VALUES('control',1,'{}');
CREATE TABLE meguri__site_audit(id bigserial PRIMARY KEY, actor_id text, action text NOT NULL, details text NOT NULL, created_at timestamptz NOT NULL DEFAULT now());
END $$;"#).await?;
Ok(())
}
-342
View File
@@ -1,342 +0,0 @@
// Repair pre-account-email identity duplication without rewriting applied migrations.
use crate::{
domain::*,
generation::{Binding, Node, Rule},
models::{WorkspaceDraftRow, WorkspaceMemberRow, WorkspaceRow},
};
use cot::db::migrations::{MigrationContext, MigrationDependency, Operation, migration_op};
use cot::db::{Auto, Database, DatabaseError, Model, model, query};
use serde_json::{Value, json};
use std::collections::{BTreeMap, BTreeSet};
use uuid::Uuid;
#[derive(Debug, Copy, Clone)]
pub struct Migration;
impl cot::db::migrations::Migration for Migration {
const APP_NAME: &'static str = "meguri";
const MIGRATION_NAME: &'static str = "m_0010b_contact_accounts";
const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration(
"meguri",
"m_0010_site_admin",
)];
const OPERATIONS: &'static [Operation] = &[Operation::custom(upgrade).build()];
}
#[derive(Debug)]
#[model(table_name = "cot__migrations", model_type = "internal")]
struct Applied {
#[model(primary_key)]
id: Auto<i32>,
app: String,
name: String,
}
#[derive(Debug)]
#[model(table_name = "meguri__users", model_type = "internal")]
struct Account {
#[model(primary_key)]
id: String,
username: String,
display_name: String,
country: String,
}
fn fail(error: impl std::fmt::Display) -> DatabaseError {
DatabaseError::ValueDecode(Box::new(std::io::Error::other(error.to_string())))
}
#[migration_op]
async fn upgrade(context: MigrationContext<'_>) -> cot::db::Result<()> {
// Already migrated installations retain their existing identity policy.
if query!(Applied,$app=="meguri" && $name=="m_0011_account_people")
.exists(context.db)
.await?
{
return Ok(());
}
repair(
context.db,
&std::env::var("MEGURI_ACCOUNT_EMAILS").unwrap_or_else(|_| "{}".into()),
)
.await
}
type Links = BTreeMap<Uuid, Uuid>;
fn id(value: &mut Uuid, links: &Links) {
if let Some(next) = links.get(value) {
*value = *next;
}
}
fn values(value: &mut Value, links: &Links) {
match value {
Value::String(s) => {
if let Ok(old) = Uuid::parse_str(s)
&& let Some(next) = links.get(&old)
{
*s = next.to_string();
}
}
Value::Array(a) => {
for v in a {
values(v, links);
}
}
Value::Object(o) => {
for v in o.values_mut() {
values(v, links);
}
}
_ => {}
}
}
fn node(value: &mut Node, links: &Links) {
if value.kind == "person" {
values(&mut value.value, links);
}
for child in &mut value.entries {
node(child, links);
}
}
fn binding(b: &mut Binding, links: &Links) {
values(&mut b.value, links);
for item in &mut b.items {
binding(item, links);
}
if let Some(key) = &mut b.key {
binding(key, links);
}
}
fn rules(rules: &mut [Rule], links: &Links) {
for rule in rules {
for iteration in &mut rule.iterations {
binding(&mut iteration.source, links);
}
if let Some(p) = &mut rule.when {
binding(&mut p.left, links);
binding(&mut p.right, links);
}
for block in &mut rule.blocks {
for field in ["participants", "primary", "secondary"] {
if let Some(b) = block.fields.get_mut(field) {
binding(b, links);
}
}
for step in &mut block.steps {
if step.user && !step.team_all && !step.team_random {
binding(&mut step.recipient, links);
}
}
}
}
}
fn leg(leg: &mut ReplacementLeg, links: &Links) -> cot::db::Result<()> {
id(&mut leg.from_user, links);
id(&mut leg.to_user, links);
if leg.from_user == leg.to_user {
return Err(fail(
"Contact association collapses a replacement to the same person; review it before migration",
));
}
Ok(())
}
fn remap(doc: &mut WorkspaceDocument, links: &Links) -> cot::db::Result<()> {
for schedule in &mut doc.schedules {
let (ScheduleKind::Rotation { participants, .. }
| ScheduleKind::Priority { participants, .. }) = &mut schedule.kind;
for person in participants {
id(person, links);
}
}
for absence in &mut doc.absences {
id(&mut absence.user_id, links);
}
for policy in &mut doc.escalations {
for step in &mut policy.steps {
if let Recipient::User { id: person } = &mut step.recipient {
id(person, links);
}
}
}
for request in &mut doc.requests {
for l in &mut request.legs {
leg(l, links)?;
}
if let Some(legs) = &mut request.effective_legs {
for l in legs {
leg(l, links)?;
}
}
request.required_approvers = request
.required_approvers
.iter()
.map(|v| *links.get(v).unwrap_or(v))
.collect();
// Existing decisions remain evidence of the original actors. Newly linked
// account participants must approve still-pending requests themselves.
if request.status == RequestStatus::Pending {
for l in &request.legs {
for person in [l.from_user, l.to_user] {
if links.values().any(|v| *v == person) && person != request.requested_by {
request.required_approvers.insert(person);
}
}
}
}
}
if let Integration::Jsm { users, .. } = &mut doc.integration {
for (old, new) in links {
if let Some(external) = users.remove(old) {
if users.get(new).is_some_and(|v| v != &external) {
return Err(fail(
"Conflicting explicit JSM identities for contact and account",
));
}
users.insert(*new, external);
}
}
}
for variable in &mut doc.generation.variables {
node(&mut variable.node, links);
}
rules(&mut doc.generation.rules, links);
Ok(())
}
/// Validate all links before atomically updating current documents and shared drafts.
pub async fn repair(db: &Database, mapping: &str) -> cot::db::Result<()> {
let mapping: BTreeMap<String, String> = serde_json::from_str(mapping).map_err(fail)?;
let accounts = Account::objects().all(db).await?;
for (name, email) in &mapping {
if !accounts.iter().any(|u| &u.username == name) {
return Err(fail(format!(
"Unknown username in MEGURI_ACCOUNT_EMAILS: {name}"
)));
}
crate::service::normalize_email(email).map_err(fail)?;
}
let workspaces = WorkspaceRow::objects().all(db).await?;
let memberships = WorkspaceMemberRow::objects().all(db).await?;
let drafts = WorkspaceDraftRow::objects().all(db).await?;
let mut emails = BTreeMap::new();
// Fail before any writes if the subsequent email migration would need more input.
for account in &accounts {
let email = if let Some(e) = mapping.get(&account.username) {
crate::service::normalize_email(e).map_err(fail)?
} else {
let mut found = BTreeSet::new();
for row in &workspaces {
let doc: WorkspaceDocument = serde_json::from_str(&row.document).map_err(fail)?;
for p in doc.people {
if p.user_id.map(|v| v.to_string()).as_deref() == Some(&account.id)
&& let Some(e) = p.email
{
found.insert(crate::service::normalize_email(&e).map_err(fail)?);
}
}
}
if found.len() != 1 {
return Err(fail(format!(
"Set MEGURI_ACCOUNT_EMAILS for missing or ambiguous email: {}",
account.username
)));
}
found.into_iter().next().unwrap()
};
if emails.values().any(|e| e == &email) {
return Err(fail("Duplicate account email in MEGURI_ACCOUNT_EMAILS"));
}
emails.insert(account.id.clone(), email);
}
let mut changes = vec![];
for row in workspaces {
let mut doc: WorkspaceDocument = serde_json::from_str(&row.document).map_err(fail)?;
let mut links = Links::new();
let members: BTreeSet<Uuid> = memberships
.iter()
.filter(|m| m.workspace_id == row.id)
.map(|m| Uuid::parse_str(&m.user_id).map_err(fail))
.collect::<cot::db::Result<_>>()?;
for account in &accounts {
let account_id = Uuid::parse_str(&account.id).map_err(fail)?;
if !members.contains(&account_id) {
continue;
}
let email = &emails[&account.id];
for person in &doc.people {
if person.id != account_id
&& person
.email
.as_ref()
.is_some_and(|e| e.trim().eq_ignore_ascii_case(email))
{
if person.user_id.is_some() || !mapping.contains_key(&account.username) {
return Err(fail(format!(
"Explicit MEGURI_ACCOUNT_EMAILS required to associate contact with {} in workspace {}",
account.username, row.id
)));
}
links.insert(person.id, account_id);
}
}
}
if links.is_empty() {
continue;
}
remap(&mut doc, &links)?;
doc.people.retain(|p| !links.contains_key(&p.id));
for account in &accounts {
let account_id = Uuid::parse_str(&account.id).map_err(fail)?;
if links.values().any(|v| *v == account_id) {
doc.people.retain(|p| p.id != account_id);
doc.people.push(Person {
id: account_id,
user_id: Some(account_id),
display_name: account.display_name.clone(),
email: Some(emails[&account.id].clone()),
country: Some(account.country.clone()),
});
}
}
doc.validate(&members).map_err(fail)?;
let draft = if let Some(d) = drafts.iter().find(|d| d.workspace_id == row.id) {
let mut payload: crate::generation_service::Draft =
serde_json::from_str(&d.payload).map_err(fail)?;
for v in &mut payload.variables {
node(&mut v.node, &links);
}
rules(&mut payload.rules, &links);
payload.review_id = None;
Some(json!({"revision":d.revision,"payload":payload}))
} else {
None
};
changes.push(
json!({"id":row.id,"revision":row.revision,"document":doc,"links":links,"draft":draft}),
);
}
if changes.is_empty() {
return Ok(());
}
let literal = json!(changes)
.to_string()
.replace('\\', "\\\\")
.replace('\'', "''");
let mut delimiter = "$repair$".to_owned();
while literal.contains(&delimiter) {
delimiter.insert(1, '_');
}
db.raw(&format!(r#"DO {delimiter}
DECLARE changes jsonb := E'{literal}'::jsonb; item jsonb;
BEGIN
LOCK TABLE meguri__workspaces,meguri__workspace_drafts,meguri__workspace_members IN SHARE ROW EXCLUSIVE MODE;
FOR item IN SELECT value FROM jsonb_array_elements(changes) LOOP
IF NOT EXISTS(SELECT 1 FROM meguri__workspaces WHERE id=item->>'id' AND revision=(item->>'revision')::bigint) THEN RAISE EXCEPTION 'Workspace changed during contact migration'; END IF;
IF item->'draft'='null'::jsonb AND EXISTS(SELECT 1 FROM meguri__workspace_drafts WHERE workspace_id=item->>'id') THEN RAISE EXCEPTION 'Shared draft created during contact migration'; END IF;
IF item->'draft'<>'null'::jsonb AND NOT EXISTS(SELECT 1 FROM meguri__workspace_drafts WHERE workspace_id=item->>'id' AND revision=(item->'draft'->>'revision')::bigint) THEN RAISE EXCEPTION 'Shared draft changed during contact migration'; END IF;
UPDATE meguri__workspaces SET document=(item->'document')::text,revision=revision+1 WHERE id=item->>'id';
IF item->'draft'<>'null'::jsonb THEN
UPDATE meguri__workspace_drafts SET payload=(item->'draft'->'payload')::text,revision=revision+1 WHERE workspace_id=item->>'id';
END IF;
INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at)
SELECT id||':'||revision::text,id,revision,document,clock_timestamp() FROM meguri__workspaces WHERE id=item->>'id';
INSERT INTO meguri__audit_events(workspace_id,action,details,created_at) VALUES(item->>'id','account.contacts_linked',(item->'links')::text,clock_timestamp());
UPDATE meguri__sync_jobs SET requested_revision=(item->>'revision')::bigint+1 WHERE workspace_id=item->>'id';
END LOOP;
END {delimiter};"#)).await?;
Ok(())
}
-87
View File
@@ -1,87 +0,0 @@
use cot::db::migrations::{MigrationContext, MigrationDependency, Operation, migration_op};
#[derive(Debug, Copy, Clone)]
pub struct Migration;
impl cot::db::migrations::Migration for Migration {
const APP_NAME: &'static str = "meguri";
const MIGRATION_NAME: &'static str = "m_0011_account_people";
const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration(
"meguri",
"m_0010_site_admin",
)];
const OPERATIONS: &'static [Operation] = &[Operation::custom(upgrade).build()];
}
#[migration_op]
async fn upgrade(context: MigrationContext<'_>) -> cot::db::Result<()> {
migrate(
context.db,
&std::env::var("MEGURI_ACCOUNT_EMAILS").unwrap_or_else(|_| "{}".into()),
)
.await
}
/// A single PostgreSQL statement ensures validation failures leave the old schema intact.
pub async fn migrate(db: &cot::db::Database, mapping: &str) -> cot::db::Result<()> {
// Explicit E-string escaping, never shell interpolation or unquoted JSON.
let literal = mapping.replace('\\', "\\\\").replace('\'', "''");
let mut delimiter = "$migration$".to_owned();
while literal.contains(&delimiter) {
delimiter.insert(1, '_');
}
let sql = format!(
r#"DO {delimiter}
DECLARE mapping jsonb := E'{literal}'::jsonb; missing text;
BEGIN
IF jsonb_typeof(mapping) <> 'object' OR EXISTS(SELECT 1 FROM jsonb_each(mapping) WHERE jsonb_typeof(value)<>'string') THEN
RAISE EXCEPTION 'MEGURI_ACCOUNT_EMAILS must be a JSON object mapping existing usernames to email addresses';
END IF;
IF EXISTS(SELECT 1 FROM jsonb_object_keys(mapping) k WHERE NOT EXISTS(SELECT 1 FROM meguri__users u WHERE u.username=k)) THEN
RAISE EXCEPTION 'MEGURI_ACCOUNT_EMAILS contains an unknown username';
END IF;
ALTER TABLE meguri__users ADD COLUMN email text;
UPDATE meguri__users u SET email=lower(trim(COALESCE(mapping->>u.username,(
SELECT CASE WHEN count(DISTINCT lower(trim(p->>'email')))=1 THEN min(lower(trim(p->>'email'))) END
FROM meguri__workspaces w CROSS JOIN LATERAL jsonb_array_elements(COALESCE(w.document::jsonb->'people','[]')) p
WHERE p->>'user_id'=u.id AND nullif(trim(p->>'email'),'') IS NOT NULL
))));
SELECT string_agg(username,', ' ORDER BY username) INTO missing FROM meguri__users
WHERE email IS NULL OR length(email)>254 OR email !~ '^[^[:space:]@]+@[^[:space:]@]+$';
IF missing IS NOT NULL THEN
RAISE EXCEPTION 'Missing, ambiguous or invalid account email. Set MEGURI_ACCOUNT_EMAILS for: %', missing;
END IF;
IF EXISTS(SELECT email FROM meguri__users GROUP BY email HAVING count(*)>1) THEN
RAISE EXCEPTION 'Duplicate account email. Resolve MEGURI_ACCOUNT_EMAILS before migration';
END IF;
ALTER TABLE meguri__users ALTER COLUMN email SET NOT NULL;
ALTER TABLE meguri__users ADD CONSTRAINT account_email_valid CHECK(email=lower(trim(email)) AND length(email)<=254 AND email ~ '^[^[:space:]@]+@[^[:space:]@]+$');
ALTER TABLE meguri__users ADD CONSTRAINT account_email_unique UNIQUE(email);
-- Current documents acquire canonical member snapshots; historical and authorized
-- publication snapshots retain the identity data that was actually used then.
WITH snapshots AS (
SELECT w.id, COALESCE((
SELECT jsonb_agg(p) FROM jsonb_array_elements(COALESCE(w.document::jsonb->'people','[]')) p
WHERE NOT EXISTS(SELECT 1 FROM meguri__workspace_members m WHERE m.workspace_id=w.id AND m.user_id=p->>'id')
),'[]'::jsonb) || COALESCE((
SELECT jsonb_agg(jsonb_build_object('id',u.id,'user_id',u.id,'display_name',u.display_name,'email',u.email,'country',u.country))
FROM meguri__workspace_members m JOIN meguri__users u ON u.id=m.user_id WHERE m.workspace_id=w.id
),'[]'::jsonb) AS people FROM meguri__workspaces w
), changed AS (
UPDATE meguri__workspaces w SET document=jsonb_set(w.document::jsonb,'{{people}}',s.people)::text, revision=w.revision+1
FROM snapshots s WHERE w.id=s.id RETURNING w.*
), history AS (
INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at)
SELECT id||':'||revision::text,id,revision,document,clock_timestamp() FROM changed
), jobs AS (
UPDATE meguri__sync_jobs j SET requested_revision=c.revision FROM changed c WHERE j.workspace_id=c.id
)
INSERT INTO meguri__audit_events(workspace_id,action,details,created_at)
SELECT id,'account.people_migrated','{{}}',clock_timestamp() FROM changed;
IF EXISTS(SELECT 1 FROM meguri__workspaces w CROSS JOIN LATERAL jsonb_array_elements(w.document::jsonb->'people') p GROUP BY w.id,p->>'email' HAVING count(*)>1) THEN
RAISE EXCEPTION 'Account email conflicts with an existing planning contact. Resolve the contact identity before migration; no identities were merged';
END IF;
END {delimiter};"#
);
db.raw(&sql).await?;
Ok(())
}
+30 -22
View File
@@ -11,13 +11,17 @@ pub struct UserRow {
pub id: String, pub id: String,
#[model(unique)] #[model(unique)]
pub username: String, pub username: String,
#[model(unique)]
pub email: String,
pub display_name: String, pub display_name: String,
pub country: String, pub country: String,
pub timezone: Option<String>, pub timezone: Option<String>,
pub password_hash: String, /// None for accounts that sign in only through SSO or whose bootstrap access was removed.
pub password_hash: Option<String>,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub email: Option<String>,
pub instance_admin: bool,
pub bootstrap: bool,
/// JSON array of group names that map to workspace roles through the instance configuration.
pub groups: String,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -49,6 +53,9 @@ pub struct WorkspaceRow {
pub document: String, pub document: String,
pub revision: i64, pub revision: i64,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
/// Stable identifier from the instance configuration; None for legacy workspaces.
pub key: Option<String>,
pub disabled_at: Option<DateTime<Utc>>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -118,6 +125,24 @@ pub struct ProviderObjectRow {
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
} }
#[derive(Debug, Clone)]
#[model(table_name = "oidc_flows")]
pub struct OidcFlowRow {
#[model(primary_key)]
pub state_hash: String,
pub nonce: String,
pub verifier: String,
pub expires_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
#[model(table_name = "instance_state")]
pub struct InstanceStateRow {
#[model(primary_key)]
pub key: String,
pub value: String,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
#[model(table_name = "workspace_drafts")] #[model(table_name = "workspace_drafts")]
pub struct WorkspaceDraftRow { pub struct WorkspaceDraftRow {
@@ -128,25 +153,8 @@ pub struct WorkspaceDraftRow {
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
#[model(table_name = "site_access")] #[model(table_name = "instance_audit")]
pub struct SiteAccessRow { pub struct InstanceAuditRow {
#[model(primary_key)]
pub user_id: String,
pub administrator: bool,
pub disabled: bool,
pub revision: i64,
}
#[derive(Debug, Clone)]
#[model(table_name = "site_settings")]
pub struct SiteSettingsRow {
#[model(primary_key)]
pub section: String,
pub revision: i64,
pub payload: String,
}
#[derive(Debug, Clone)]
#[model(table_name = "site_audit")]
pub struct SiteAuditRow {
#[model(primary_key)] #[model(primary_key)]
pub id: Auto<i64>, pub id: Auto<i64>,
pub actor_id: Option<String>, pub actor_id: Option<String>,
+348
View File
@@ -0,0 +1,348 @@
//! OpenID Connect authorization-code flow with PKCE for a confidential client. Tokens are
//! verified here (signature, issuer, audience, expiry, nonce); the rest of the application only
//! sees an already trusted profile.
use crate::{
access::{GroupSource, OidcConfig, claim_groups},
error::{Error, Result},
identity::{SsoProfile, normalize_email},
models::OidcFlowRow,
service,
store::Store,
};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{Duration, Utc};
use cot::db::{Model, query};
use jsonwebtoken::{Algorithm, DecodingKey, Validation, jwk::JwkSet};
use serde::Deserialize;
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::{
sync::OnceLock,
time::{Duration as StdDuration, Instant},
};
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct Settings {
pub config: OidcConfig,
pub client_secret: String,
pub redirect_uri: String,
}
static SETTINGS: OnceLock<Settings> = OnceLock::new();
pub fn install(settings: Settings) {
let _ = SETTINGS.set(settings);
}
pub fn settings() -> Option<&'static Settings> {
SETTINGS.get()
}
/// How a sign-in attempt failed, in terms the browser can show.
#[derive(Debug)]
pub enum Failure {
/// The identity provider could not be reached or answered unusably.
Unavailable(String),
/// The callback or a token did not verify.
Invalid(String),
}
impl From<Failure> for Error {
fn from(failure: Failure) -> Self {
match failure {
Failure::Unavailable(m) | Failure::Invalid(m) => Error::Provider(m),
}
}
}
type Outcome<T> = std::result::Result<T, Failure>;
#[derive(Debug, Clone, Deserialize)]
struct Discovery {
issuer: String,
authorization_endpoint: String,
token_endpoint: String,
jwks_uri: String,
#[serde(default)]
userinfo_endpoint: Option<String>,
}
#[derive(Default)]
struct Cache {
discovery: Option<(Instant, Discovery)>,
jwks: Option<(Instant, JwkSet)>,
}
static CACHE: tokio::sync::Mutex<Cache> = tokio::sync::Mutex::const_new(Cache {
discovery: None,
jwks: None,
});
const DISCOVERY_TTL: StdDuration = StdDuration::from_secs(600);
const JWKS_MIN_REFRESH: StdDuration = StdDuration::from_secs(15);
fn client() -> &'static reqwest::Client {
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
CLIENT.get_or_init(|| {
reqwest::Client::builder()
.timeout(StdDuration::from_secs(10))
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("HTTP client")
})
}
fn unavailable(what: &str, error: impl std::fmt::Display) -> Failure {
Failure::Unavailable(format!("{what}: {error}"))
}
async fn get_json<T: serde::de::DeserializeOwned>(
what: &str,
request: reqwest::RequestBuilder,
) -> Outcome<T> {
let response = request.send().await.map_err(|e| unavailable(what, e))?;
if !response.status().is_success() {
return Err(Failure::Unavailable(format!(
"{what}: HTTP {}",
response.status()
)));
}
response.json().await.map_err(|e| unavailable(what, e))
}
async fn discovery(settings: &Settings) -> Outcome<Discovery> {
let mut cache = CACHE.lock().await;
if let Some((at, found)) = &cache.discovery
&& at.elapsed() < DISCOVERY_TTL
{
return Ok(found.clone());
}
let issuer = settings.config.issuer.trim_end_matches('/');
let found: Discovery = get_json(
"OpenID discovery",
client().get(format!("{issuer}/.well-known/openid-configuration")),
)
.await?;
if found.issuer.trim_end_matches('/') != issuer {
return Err(Failure::Invalid(
"discovery document issuer differs from oidc.issuer".into(),
));
}
cache.discovery = Some((Instant::now(), found.clone()));
Ok(found)
}
async fn key_for(settings: &Settings, kid: Option<&str>) -> Outcome<DecodingKey> {
let found = discovery(settings).await?;
let mut cache = CACHE.lock().await;
for attempt in 0..2 {
let fresh = cache
.jwks
.as_ref()
.is_some_and(|(at, _)| at.elapsed() < JWKS_MIN_REFRESH);
let hit = cache.jwks.as_ref().and_then(|(_, set)| {
set.keys.iter().find(|k| match kid {
Some(kid) => k.common.key_id.as_deref() == Some(kid),
None => set.keys.len() == 1,
})
});
if let Some(jwk) = hit {
return DecodingKey::from_jwk(jwk).map_err(|e| Failure::Invalid(e.to_string()));
}
if attempt == 1 || fresh {
break;
}
let set: JwkSet = get_json("JWKS", client().get(&found.jwks_uri)).await?;
cache.jwks = Some((Instant::now(), set));
}
Err(Failure::Invalid("no signing key matches the token".into()))
}
fn random_token() -> String {
format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple())
}
/// Stores the one-time flow state and returns the provider URL plus the state that the
/// browser must present back through a cookie.
pub async fn begin(db: &Store) -> Result<(String, String)> {
let settings = settings().ok_or(Error::NotFound)?;
let found = discovery(settings).await?;
let (state, nonce, verifier) = (random_token(), random_token(), random_token());
db.db
.raw("DELETE FROM meguri__oidc_flows WHERE expires_at<now()")
.await?;
OidcFlowRow {
state_hash: service::token_hash(&state),
nonce: nonce.clone(),
verifier: verifier.clone(),
expires_at: Utc::now() + Duration::minutes(10),
}
.insert(&db.db)
.await?;
let mut url = reqwest::Url::parse(&found.authorization_endpoint)
.map_err(|e| Failure::Invalid(e.to_string()))?;
url.query_pairs_mut()
.append_pair("response_type", "code")
.append_pair("client_id", &settings.config.client_id)
.append_pair("redirect_uri", &settings.redirect_uri)
.append_pair("scope", &settings.config.scopes.join(" "))
.append_pair("state", &state)
.append_pair("nonce", &nonce)
.append_pair(
"code_challenge",
&URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())),
)
.append_pair("code_challenge_method", "S256");
Ok((url.to_string(), state))
}
#[derive(Deserialize)]
struct Tokens {
id_token: String,
access_token: Option<String>,
}
/// Verifies the callback and returns the profile of the authenticated person. The browser's
/// state cookie must equal the returned state, and the flow row can be used exactly once.
pub async fn complete(
db: &Store,
cookie_state: Option<&str>,
state: &str,
code: &str,
) -> std::result::Result<SsoProfile, Failure> {
let settings = settings().ok_or_else(|| Failure::Invalid("OIDC is not configured".into()))?;
if cookie_state != Some(state) || state.len() != 64 {
return Err(Failure::Invalid(
"state cookie is missing or differs from the callback state".into(),
));
}
let hash = service::token_hash(state);
let flow = query!(OidcFlowRow,$state_hash==hash.clone())
.get(&db.db)
.await
.map_err(|e| Failure::Unavailable(e.to_string()))?
.filter(|f| f.expires_at > Utc::now())
.ok_or_else(|| Failure::Invalid("unknown or expired login attempt".into()))?;
let used = db
.db
.raw_with(
"DELETE FROM meguri__oidc_flows WHERE state_hash=$1",
&[&hash],
)
.await
.map_err(|e| Failure::Unavailable(e.to_string()))?;
if used.rows_affected().0 != 1 {
return Err(Failure::Invalid("login attempt was already used".into()));
}
let found = discovery(settings).await?;
let response = client()
.post(&found.token_endpoint)
.basic_auth(&settings.config.client_id, Some(&settings.client_secret))
.form(&[
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", settings.redirect_uri.as_str()),
("code_verifier", flow.verifier.as_str()),
])
.send()
.await
.map_err(|e| unavailable("token exchange", e))?;
if !response.status().is_success() {
return Err(Failure::Invalid(format!(
"token exchange: HTTP {}",
response.status()
)));
}
let tokens: Tokens = response
.json()
.await
.map_err(|e| unavailable("token response", e))?;
let claims = verify_id_token(settings, &tokens.id_token, &flow.nonce).await?;
profile(settings, &found, &claims, tokens.access_token.as_deref()).await
}
/// Signature (RS256 only), issuer, audience, expiry and nonce checks.
pub async fn verify_id_token(settings: &Settings, token: &str, nonce: &str) -> Outcome<Value> {
let header = jsonwebtoken::decode_header(token).map_err(|e| Failure::Invalid(e.to_string()))?;
let key = key_for(settings, header.kid.as_deref()).await?;
let mut validation = Validation::new(Algorithm::RS256);
validation.set_audience(&[&settings.config.client_id]);
validation.set_issuer(&[settings.config.issuer.trim_end_matches('/')]);
validation.set_required_spec_claims(&["exp", "iss", "aud", "sub"]);
let claims = match jsonwebtoken::decode::<Value>(token, &key, &validation) {
Ok(data) => data.claims,
Err(first) => {
// Providers differ on a trailing slash in `iss`; accept only that single variation.
let mut alternate = validation.clone();
let issuer = settings.config.issuer.trim_end_matches('/');
alternate.set_issuer(&[format!("{issuer}/")]);
jsonwebtoken::decode::<Value>(token, &key, &alternate)
.map_err(|_| Failure::Invalid(first.to_string()))?
.claims
}
};
if claims.get("nonce").and_then(Value::as_str) != Some(nonce) {
return Err(Failure::Invalid("nonce mismatch".into()));
}
if let Some(audiences) = claims.get("aud").and_then(Value::as_array)
&& audiences.len() > 1
&& claims.get("azp").and_then(Value::as_str) != Some(settings.config.client_id.as_str())
{
return Err(Failure::Invalid("authorized party mismatch".into()));
}
Ok(claims)
}
async fn userinfo(found: &Discovery, access_token: Option<&str>) -> Option<Value> {
let (endpoint, token) = (found.userinfo_endpoint.as_ref()?, access_token?);
get_json::<Value>("userinfo", client().get(endpoint).bearer_auth(token))
.await
.map_err(|failure| tracing::warn!(?failure, "userinfo request failed"))
.ok()
}
async fn profile(
settings: &Settings,
found: &Discovery,
claims: &Value,
access_token: Option<&str>,
) -> Outcome<SsoProfile> {
let path = &settings.config.groups_claim;
let mut info: Option<Value> = None;
let mut groups = None;
for source in &settings.config.groups_sources {
match source {
GroupSource::IdToken => groups = claim_groups(claims, path),
GroupSource::Userinfo => {
if info.is_none() {
info = Some(userinfo(found, access_token).await.unwrap_or(Value::Null));
}
groups = info.as_ref().and_then(|i| claim_groups(i, path));
}
}
if groups.is_some() {
break;
}
}
if groups.is_none() {
tracing::warn!(
claim = %path,
"the identity provider returned no groups claim; check the group membership mapper of the client"
);
}
let text = |name: &str| -> Option<String> {
claims
.get(name)
.or_else(|| info.as_ref().and_then(|i| i.get(name)))
.and_then(Value::as_str)
.map(str::to_owned)
};
let subject = claims
.get("sub")
.and_then(Value::as_str)
.ok_or_else(|| Failure::Invalid("token has no subject".into()))?
.to_owned();
Ok(SsoProfile {
issuer: settings.config.issuer.trim_end_matches('/').to_owned(),
subject,
email: text("email").and_then(|e| normalize_email(&e)),
username_hint: text("preferred_username"),
display_name: text("name"),
groups: groups.unwrap_or_default(),
})
}
+114 -132
View File
@@ -38,77 +38,13 @@ pub async fn hash_password(password: String) -> Result<String> {
.map_err(|e| Error::Internal(e.to_string()))? .map_err(|e| Error::Internal(e.to_string()))?
} }
pub fn normalize_email(value: &str) -> Result<String> { /// Checks a password against the stored hash. Unknown users and accounts without a
let email = value.trim().to_lowercase(); /// password still pay for one key derivation so response time does not reveal either.
ensure( pub async fn verify_password(
email.len() <= 254
&& email.split('@').count() == 2
&& !email.chars().any(char::is_whitespace)
&& email_address::EmailAddress::is_valid(&email),
crate::i18n::text("err_enter_a_valid_email_address_1577ea"),
)?;
Ok(email)
}
pub async fn register(
db: &Store, db: &Store,
username: String, username: &str,
display_name: String,
email: String,
password: String, password: String,
) -> Result<User> { ) -> Result<Option<UserRow>> {
let username = username.trim().to_ascii_lowercase();
ensure(
(3..=64).contains(&username.len())
&& username
.chars()
.all(|c| c.is_ascii_alphanumeric() || "._-".contains(c)),
crate::i18n::text("err_username_must_use_3_64_letters_numbers_dots_unde_2c6b6f"),
)?;
validate_name(&display_name)?;
let email = normalize_email(&email)?;
let hash = hash_password(password).await?;
let id = Uuid::new_v4();
let user = User {
language: None,
username,
timezone: None,
person: Person {
id,
user_id: Some(id),
display_name,
email: Some(email.clone()),
country: Some("CY".into()),
},
};
let result = UserRow {
language: None,
demo_deleted_at: None,
id: user.id.to_string(),
username: user.username.clone(),
display_name: user.display_name.clone(),
country: user.country.clone().unwrap_or_else(|| "CY".into()),
email,
timezone: None,
password_hash: hash,
created_at: Utc::now(),
}
.insert(&db.db)
.await;
if matches!(result, Err(cot::db::DatabaseError::UniqueViolation)) {
return Err(Error::Conflict(
crate::i18n::text("account_identity_taken").into(),
));
}
result?;
Ok(user)
}
pub async fn login(
db: &Store,
username: String,
password: String,
) -> Result<(User, String, String)> {
ensure( ensure(
password.len() <= 256 && username.len() <= 64, password.len() <= 256 && username.len() <= 64,
crate::i18n::text("err_invalid_credentials_e41784"), crate::i18n::text("err_invalid_credentials_e41784"),
@@ -116,13 +52,17 @@ pub async fn login(
let username = username.trim().to_ascii_lowercase(); let username = username.trim().to_ascii_lowercase();
let row = query!(UserRow,$username==username).get(&db.db).await?; let row = query!(UserRow,$username==username).get(&db.db).await?;
let row = row.filter(|r| r.demo_deleted_at.is_none()); let row = row.filter(|r| r.demo_deleted_at.is_none());
// Perform the expensive KDF for an unknown username too.
let fallback = "$argon2id$v=19$m=19456,t=2,p=1$c29tZXJhbmRvbXNhbHQ$DKjSa6guFpKhutOgiqYmtDXrWFFJEWEbCJ3JAlCSF3c"; let fallback = "$argon2id$v=19$m=19456,t=2,p=1$c29tZXJhbmRvbXNhbHQ$DKjSa6guFpKhutOgiqYmtDXrWFFJEWEbCJ3JAlCSF3c";
let hash: String = row let hash: String = row
.as_ref() .as_ref()
.map(|r| r.password_hash.clone()) .and_then(|r| r.password_hash.clone())
.unwrap_or(fallback.into()); .unwrap_or(fallback.into());
let valid = tokio::task::spawn_blocking(move || { let valid = password_matches(hash, password).await?;
Ok(row.filter(|r| valid && r.password_hash.is_some()))
}
pub async fn password_matches(hash: String, password: String) -> Result<bool> {
tokio::task::spawn_blocking(move || {
PasswordHash::new(&hash).is_ok_and(|h| { PasswordHash::new(&hash).is_ok_and(|h| {
Argon2::default() Argon2::default()
.verify_password(password.as_bytes(), &h) .verify_password(password.as_bytes(), &h)
@@ -130,48 +70,73 @@ pub async fn login(
}) })
}) })
.await .await
.map_err(|e| Error::Internal(e.to_string()))?; .map_err(|e| Error::Internal(e.to_string()))
if !valid || row.is_none() { }
return Err(Error::Unauthorized);
} pub async fn create_session(db: &Store, user: Uuid) -> Result<(String, String)> {
let row = row.unwrap();
let user = store::user(row)?;
crate::site::active(db, user.id).await?;
let token = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); let token = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple());
let csrf = Uuid::new_v4().to_string(); let csrf = Uuid::new_v4().to_string();
SessionRow { SessionRow {
token_hash: token_hash(&token), token_hash: token_hash(&token),
user_id: user.id.to_string(), user_id: user.to_string(),
csrf: csrf.clone(), csrf: csrf.clone(),
expires_at: Utc::now() + Duration::hours(12), expires_at: Utc::now() + Duration::hours(12),
} }
.insert(&db.db) .insert(&db.db)
.await?; .await?;
Ok((token, csrf))
}
/// Password sign-in. Ordinary accounts need `password_login`; bootstrap accounts always may.
/// Every refusal is the same error as a wrong password.
pub async fn login(
db: &Store,
password_login: bool,
username: String,
password: String,
) -> Result<(User, String, String)> {
let row = verify_password(db, &username, password)
.await?
.filter(|r| password_login || r.bootstrap)
.ok_or(Error::Unauthorized)?;
let user = store::user(row)?;
let (token, csrf) = create_session(db, user.id).await?;
Ok((user, token, csrf)) Ok((user, token, csrf))
} }
pub async fn create_workspace(db: &Store, actor: Uuid, name: String) -> Result<WorkspaceRecord> { /// Employees with schedule-editing rights plan schedules, but the workspace preferences and the
validate_name(&name)?; /// scheduling directory belong to workspace administrators: name, planning horizon and every
let id = Uuid::new_v4(); /// existing person stay exactly as they were. New email contacts may be added while planning.
let mut doc = WorkspaceDocument::new(name); fn ensure_employee_edit(before: &WorkspaceDocument, after: &WorkspaceDocument) -> Result<()> {
let key = actor.to_string(); let normal = |p: &Person| {
let account = query!(UserRow, $id == key) (
.get(&db.db) p.display_name.clone(),
.await? p.email
.ok_or(Error::NotFound)?; .as_deref()
doc.people.push(store::user(account)?.person); .map(|e| e.trim().to_lowercase())
db.db .filter(|e| !e.is_empty()),
.raw_with( p.country
CREATE_WORKSPACE, .as_deref()
&[ .map(|c| c.trim().to_ascii_uppercase())
&id.to_string(), .filter(|c| !c.is_empty())
&serde_json::to_string(&doc)?, .unwrap_or_else(|| "CY".into()),
&actor.to_string(), p.user_id,
&serde_json::to_string(&Permission::all())?,
],
) )
.await?; };
db.workspace(id).await let unchanged = after.name == before.name
&& after.horizon_days == before.horizon_days
&& before.people.iter().all(|old| {
after
.people
.iter()
.find(|p| p.id == old.id)
.is_some_and(|new| normal(new) == normal(old))
});
if unchanged {
Ok(())
} else {
Err(Error::Forbidden)
}
} }
pub async fn update_document( pub async fn update_document(
@@ -185,6 +150,9 @@ pub async fn update_document(
.prepare(workspace, revision, actor, Permission::EditSchedules) .prepare(workspace, revision, actor, Permission::EditSchedules)
.await?; .await?;
document.requests = record.document.requests.clone(); document.requests = record.document.requests.clone();
if db.owner(workspace, actor).await.is_err() {
ensure_employee_edit(&record.document, &document)?;
}
for request in &mut document.requests { for request in &mut document.requests {
if request.status == RequestStatus::Pending if request.status == RequestStatus::Pending
&& request && request
@@ -224,6 +192,12 @@ pub async fn update_document(
!query!(UserRow,$id==id).exists(&db.db).await?, !query!(UserRow,$id==id).exists(&db.db).await?,
crate::i18n::text("err_select_the_workspace_user_from_the_directory_725423"), crate::i18n::text("err_select_the_workspace_user_from_the_directory_725423"),
)?; )?;
} else if let Some(id) = person.user_id {
// A new person may only link to an account that currently has access here.
ensure(
members.contains(&id),
crate::i18n::text("err_choose_a_user_who_has_access_to_this_workspace_41cf15"),
)?;
} }
} }
store::account_people(&mut document, &db.directory(workspace).await?)?; store::account_people(&mut document, &db.directory(workspace).await?)?;
@@ -403,36 +377,36 @@ pub async fn update_integration(
Ok(record) Ok(record)
} }
pub async fn set_member( /// Replaces the permission set of the employee role. Administrators always hold everything;
/// changing the role takes effect for every current and future employee in this workspace.
pub async fn set_member_permissions(
db: &Store, db: &Store,
actor: Uuid, actor: Uuid,
workspace: Uuid, workspace: Uuid,
revision: i64, revision: i64,
username: String, mut permissions: Vec<Permission>,
permissions: Vec<Permission>,
) -> Result<WorkspaceRecord> { ) -> Result<WorkspaceRecord> {
permissions.sort();
permissions.dedup();
ensure( ensure(
permissions.contains(&Permission::View), permissions.contains(&Permission::View),
crate::i18n::text("err_every_member_must_have_calendar_access_ca104e"), crate::i18n::text("err_every_member_must_have_calendar_access_ca104e"),
)?; )?;
ensure(
permissions.iter().all(|p| p.grantable_to_employees()),
crate::i18n::text("err_employees_cannot_manage_members_or_integrations_3c21aa"),
)?;
let mut record = db let mut record = db
.prepare(workspace, revision, actor, Permission::ManageMembers) .prepare(workspace, revision, actor, Permission::ManageMembers)
.await?; .await?;
let username = username.trim().to_ascii_lowercase(); record.document.member_permissions = permissions.clone();
let user = query!(UserRow,$username==username)
.get(&db.db)
.await?
.ok_or(Error::Validation(
crate::i18n::text("err_ask_this_person_to_create_an_account_first_897eb2").into(),
))?;
let user = store::uuid(&user.id)?;
db.commit( db.commit(
&mut record, &mut record,
actor, actor,
Permission::ManageMembers, Permission::ManageMembers,
"member.permissions_changed", "member.role_permissions_changed",
json!({"user_id":user,"permissions":permissions}), json!({"permissions":permissions}),
Some((user, permissions)), Some(permissions),
) )
.await?; .await?;
Ok(record) Ok(record)
@@ -512,6 +486,11 @@ async fn prepare_absence(
!query!(UserRow,$id==key).exists(&db.db).await?, !query!(UserRow,$id==key).exists(&db.db).await?,
crate::i18n::text("err_select_an_existing_workspace_user_from_the_direc_a09a03"), crate::i18n::text("err_select_an_existing_workspace_user_from_the_direc_a09a03"),
)?; )?;
} else if let Some(id) = person.user_id {
ensure(
participants.contains(&id),
crate::i18n::text("err_choose_a_user_who_has_access_to_this_workspace_41cf15"),
)?;
} }
record.document.people.push(person); record.document.people.push(person);
} }
@@ -971,6 +950,21 @@ pub async fn seed_demo(db: &Store) -> Result<()> {
pub async fn list_workspaces(db: &Store, user: Uuid) -> Result<Value> { pub async fn list_workspaces(db: &Store, user: Uuid) -> Result<Value> {
let actor = user.to_string(); let actor = user.to_string();
let mut workspaces = vec![]; let mut workspaces = vec![];
if db.is_instance_admin(user).await? {
// Instance administrators see every enabled workspace without belonging to it.
for row in WorkspaceRow::objects().all(&db.db).await? {
let key = row.key.clone();
match store::record(row) {
Ok(record) => workspaces.push(
json!({"id":record.id,"key":key,"name":record.document.name,"is_owner":true}),
),
Err(Error::NotFound) => {}
Err(e) => return Err(e),
}
}
workspaces.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
return Ok(json!({"workspaces":workspaces}));
}
for membership in query!(WorkspaceMemberRow,$user_id==actor) for membership in query!(WorkspaceMemberRow,$user_id==actor)
.all(&db.db) .all(&db.db)
.await? .await?
@@ -980,31 +974,19 @@ pub async fn list_workspaces(db: &Store, user: Uuid) -> Result<Value> {
.get(&db.db) .get(&db.db)
.await? .await?
.ok_or(Error::NotFound)?; .ok_or(Error::NotFound)?;
if row.demo_deleted_at.is_some() { let key = row.key.clone();
let Ok(record) = store::record(row) else {
continue; continue;
} };
let record = store::record(row)?;
if db.require(record.id, user, Permission::View).await.is_ok() { if db.require(record.id, user, Permission::View).await.is_ok() {
workspaces.push( workspaces.push(
json!({"id":record.id,"name":record.document.name,"is_owner":membership.is_owner}), json!({"id":record.id,"key":key,"name":record.document.name,"is_owner":membership.is_owner}),
); );
} }
} }
workspaces.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
Ok(json!({"workspaces":workspaces})) Ok(json!({"workspaces":workspaces}))
} }
const CREATE_WORKSPACE: &str = r#"
WITH workspace AS (
INSERT INTO meguri__workspaces(id,document,revision,created_at) VALUES($1,$2,1,now()) RETURNING *
), member AS (
INSERT INTO meguri__workspace_members(id,workspace_id,user_id,permissions,is_owner) SELECT id||':'||$3,id,$3,$4,true FROM workspace
), history AS (
INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at,actor_id) SELECT id||':1',id,1,document,clock_timestamp(),$3 FROM workspace
), jobs AS (
INSERT INTO meguri__sync_jobs(workspace_id,requested_revision,state,attempts,next_attempt) SELECT id,1,'draft',0,now() FROM workspace
)
INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at) SELECT id,$3,'workspace.created','{}',now() FROM workspace
"#;
const SAVE_PROFILE: &str = r#" const SAVE_PROFILE: &str = r#"
WITH previous AS MATERIALIZED (SELECT * FROM meguri__users WHERE id=$1 FOR NO KEY UPDATE), changed AS ( WITH previous AS MATERIALIZED (SELECT * FROM meguri__users WHERE id=$1 FOR NO KEY UPDATE), changed AS (
UPDATE meguri__users SET display_name=$2,country=$3,timezone=$4,password_hash=$5,language=$6 WHERE id IN(SELECT id FROM previous) RETURNING * UPDATE meguri__users SET display_name=$2,country=$3,timezone=$4,password_hash=$5,language=$6 WHERE id IN(SELECT id FROM previous) RETURNING *
-282
View File
@@ -1,282 +0,0 @@
//! Explicit installation administration; never an ambient workspace grant.
use crate::{
domain::*,
error::{Error, Result, ensure},
models::*,
store::{Store, user},
};
use cot::db::{Model, query};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AuthenticationSettings {
pub oidc_enabled: bool,
pub password_enabled: bool,
pub issuer: String,
pub client_id: String,
pub client_secret_env: String,
pub button_label: String,
pub allow_new_users: bool,
}
pub fn invalid() -> Error {
Error::Validation(crate::i18n::text("site_invalid").into())
}
pub async fn settings(db: &Store) -> Result<(i64, AuthenticationSettings)> {
let row = query!(SiteSettingsRow,$section=="authentication")
.get(&db.db)
.await?
.ok_or(Error::NotFound)?;
Ok((row.revision, serde_json::from_str(&row.payload)?))
}
pub async fn access(db: &Store, id: Uuid) -> Result<SiteAccessRow> {
let id = id.to_string();
Ok(query!(SiteAccessRow,$user_id==id.clone())
.get(&db.db)
.await?
.unwrap_or(SiteAccessRow {
user_id: id,
administrator: false,
disabled: false,
revision: 0,
}))
}
pub async fn active(db: &Store, id: Uuid) -> Result<()> {
if access(db, id).await?.disabled {
Err(Error::Unauthorized)
} else {
Ok(())
}
}
pub async fn require(db: &Store, id: Uuid) -> Result<()> {
let a = access(db, id).await?;
if a.administrator && !a.disabled {
Ok(())
} else {
Err(Error::Forbidden)
}
}
pub async fn bootstrap(db: &Store, username: &str) -> Result<()> {
let username = username.trim().to_ascii_lowercase();
let row = query!(UserRow,$username==username)
.get(&db.db)
.await?
.filter(|r| r.demo_deleted_at.is_none())
.ok_or_else(invalid)?;
db.db.raw_with(r#"WITH changed AS (INSERT INTO meguri__site_access(user_id,administrator) VALUES($1,true) ON CONFLICT(user_id) DO UPDATE SET administrator=true,disabled=false,revision=meguri__site_access.revision+1 WHERE NOT meguri__site_access.administrator OR meguri__site_access.disabled RETURNING user_id), version AS(UPDATE meguri__site_settings SET revision=revision+1 WHERE section='control' AND EXISTS(SELECT 1 FROM changed)) INSERT INTO meguri__site_audit(actor_id,action,details) SELECT NULL,'site.bootstrap',json_build_object('user_id',user_id)::text FROM changed"#,&[&row.id]).await?;
Ok(())
}
pub async fn overview(db: &Store, actor: Uuid) -> Result<Value> {
require(db, actor).await?;
let (revision, authentication) = settings(db).await?;
let control = query!(SiteSettingsRow,$section=="control")
.get(&db.db)
.await?
.ok_or(Error::NotFound)?;
let access_rows: std::collections::BTreeMap<_, _> = SiteAccessRow::objects()
.all(&db.db)
.await?
.into_iter()
.map(|a| (a.user_id.clone(), a))
.collect();
let member_rows = WorkspaceMemberRow::objects().all(&db.db).await?;
let mut users = vec![];
for row in UserRow::objects().all(&db.db).await? {
if row.demo_deleted_at.is_none() {
let u = user(row)?;
let a = access_rows
.get(&u.id.to_string())
.cloned()
.unwrap_or(SiteAccessRow {
user_id: u.id.to_string(),
administrator: false,
disabled: false,
revision: 0,
});
users.push(json!({"user":u,"access":a_value(&a)}));
}
}
users.sort_by_key(|v| {
v["user"]["username"]
.as_str()
.unwrap_or_default()
.to_owned()
});
let mut workspaces = vec![];
for w in WorkspaceRow::objects().all(&db.db).await? {
if w.demo_deleted_at.is_none() {
let d: WorkspaceDocument = serde_json::from_str(&w.document)?;
let id = w.id.clone();
let members: Vec<_> = member_rows
.iter()
.filter(|m| m.workspace_id == id)
.collect();
workspaces.push(json!({"id":w.id,"name":d.name,"revision":w.revision,"owners":members.iter().filter(|m|m.is_owner).map(|m|m.user_id.clone()).collect::<Vec<_>>(),"members":members.len()}));
}
}
workspaces.sort_by_key(|v| v["name"].as_str().unwrap_or_default().to_owned());
let audit = SiteAuditRow::objects().all(&db.db).await?;
let mut audit:Vec<_>=audit.into_iter().map(|a|json!({"id":a.id,"actor_id":a.actor_id,"action":a.action,"details":serde_json::from_str::<Value>(&a.details).unwrap_or(Value::Null),"created_at":a.created_at})).collect();
audit.sort_by(|a, b| b["created_at"].as_str().cmp(&a["created_at"].as_str()));
audit.truncate(100);
Ok(
json!({"revision":control.revision,"users":users,"workspaces":workspaces,"audit":audit,"settings":{"authentication":{"revision":revision,"value":authentication}},"authentication_applied":false}),
)
}
fn a_value(a: &SiteAccessRow) -> Value {
json!({"administrator":a.administrator,"disabled":a.disabled,"revision":a.revision})
}
// Every mutation checks the same control revision. Concurrent site writes cannot
// both succeed from a stale administrator/last-admin snapshot.
const GATE: &str = r#"WITH gate AS (UPDATE meguri__site_settings SET revision=revision+1 WHERE section='control' AND revision=$2 AND EXISTS(SELECT 1 FROM meguri__site_access WHERE user_id=$1 AND administrator AND NOT disabled) RETURNING revision)"#;
pub async fn mutate(db: &Store, actor: Uuid, action: &str, body: Value) -> Result<()> {
require(db, actor).await?;
let rev = body["revision"].as_i64().ok_or_else(invalid)?;
let actor = actor.to_string();
let id = body["id"].as_str().unwrap_or_default().to_owned();
let (sql, details) = match action {
"user" => {
let target = query!(UserRow,$id==id.clone())
.get(&db.db)
.await?
.filter(|u| u.demo_deleted_at.is_none())
.ok_or(Error::NotFound)?;
let name = body["display_name"].as_str().ok_or_else(invalid)?;
validate_name(name)?;
let admin = body["administrator"].as_bool().ok_or_else(invalid)?;
let disabled = body["disabled"].as_bool().ok_or_else(invalid)?;
if !admin || disabled {
let active_ids: std::collections::BTreeSet<_> = UserRow::objects()
.all(&db.db)
.await?
.into_iter()
.filter(|u| u.demo_deleted_at.is_none())
.map(|u| u.id)
.collect();
let active_admins = SiteAccessRow::objects()
.all(&db.db)
.await?
.into_iter()
.filter(|a| a.administrator && !a.disabled && active_ids.contains(&a.user_id))
.collect::<Vec<_>>();
ensure(
!active_admins.iter().any(|a| a.user_id == target.id)
|| active_admins.len() > 1,
crate::i18n::text("site_last_admin"),
)?;
}
(
r#", changed AS (INSERT INTO meguri__site_access(user_id,administrator,disabled) SELECT $3,($4::jsonb->>'administrator')::boolean,($4::jsonb->>'disabled')::boolean FROM gate ON CONFLICT(user_id) DO UPDATE SET administrator=EXCLUDED.administrator,disabled=EXCLUDED.disabled,revision=meguri__site_access.revision+1 RETURNING user_id), profile AS(UPDATE meguri__users SET display_name=$4::jsonb->>'display_name' WHERE id IN(SELECT user_id FROM changed) RETURNING *), snapshots AS(
UPDATE meguri__workspaces w SET revision=w.revision+1,document=jsonb_set(w.document::jsonb,'{people}',
(SELECT jsonb_agg(CASE WHEN p->>'user_id'=u.id THEN p||jsonb_build_object('display_name',u.display_name) ELSE p END)
FROM jsonb_array_elements(COALESCE(w.document::jsonb->'people','[]')) p))::text
FROM profile u WHERE EXISTS(SELECT 1 FROM jsonb_array_elements(COALESCE(w.document::jsonb->'people','[]')) p WHERE p->>'user_id'=u.id AND p->>'display_name' IS DISTINCT FROM u.display_name) RETURNING w.*
), profile_history AS(INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at,actor_id) SELECT id||':'||revision::text,id,revision,document,clock_timestamp(),$1 FROM snapshots),
profile_jobs AS(UPDATE meguri__sync_jobs j SET requested_revision=s.revision FROM snapshots s WHERE j.workspace_id=s.id),
profile_audit AS(INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at) SELECT id,$1,'person.profile_changed',$4,clock_timestamp() FROM snapshots), sessions AS(DELETE FROM meguri__sessions WHERE user_id IN(SELECT user_id FROM changed) AND ($4::jsonb->>'disabled')::boolean)"#,
json!({"display_name":name,"administrator":admin,"disabled":disabled}),
)
}
"workspace" => {
let workspace = db
.workspace(Uuid::parse_str(&id).map_err(|_| invalid())?)
.await?;
ensure(
body["workspace_revision"].as_i64() == Some(workspace.revision),
crate::i18n::text("site_stale"),
)?;
let name = body["name"].as_str().ok_or_else(invalid)?;
validate_name(name)?;
let owner = body["owner_id"].as_str().ok_or_else(invalid)?.to_owned();
let owner_row = query!(UserRow,$id==owner.clone())
.get(&db.db)
.await?
.filter(|u| u.demo_deleted_at.is_none())
.ok_or_else(invalid)?;
active(db, Uuid::parse_str(&owner_row.id).map_err(|_| invalid())?).await?;
let mut document = workspace.document.clone();
crate::store::account_people(&mut document, &[user(owner_row)?])?;
let mut members = db.member_ids(workspace.id).await?;
members.insert(Uuid::parse_str(&owner).map_err(|_| invalid())?);
document.validate(&members)?;
document.name = name.into();
// Add an owner explicitly, retaining existing owners and workspace grants.
(
r#", workspace AS(UPDATE meguri__workspaces SET document=($4::jsonb->'document')::text,revision=revision+1 WHERE id=$3 AND revision=($4::jsonb->>'workspace_revision')::bigint AND EXISTS(SELECT 1 FROM gate) RETURNING *), jobs AS(INSERT INTO meguri__sync_jobs(workspace_id,requested_revision,state,attempts,next_attempt) SELECT id,revision,'draft',0,now() FROM workspace ON CONFLICT(workspace_id) DO UPDATE SET requested_revision=EXCLUDED.requested_revision), changed AS(INSERT INTO meguri__workspace_members(id,workspace_id,user_id,permissions,is_owner) SELECT $4::jsonb->>'member_id',$3,$4::jsonb->>'owner_id',$4::jsonb->>'permissions',true FROM workspace ON CONFLICT(workspace_id,user_id) DO UPDATE SET is_owner=true RETURNING user_id), history AS(INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at,actor_id) SELECT $4::jsonb->>'history_id',id,revision,document,now(),$1 FROM workspace), audit AS(INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at) SELECT id,$1,'site.workspace_updated',$4,now() FROM workspace)"#,
json!({"document":document,"name":name,"owner_id":owner,"workspace_revision":workspace.revision,"member_id":format!("{id}:{owner}"),"history_id":format!("{id}:{}",workspace.revision+1),"permissions":serde_json::to_string(&Permission::all())?}),
)
}
"authentication" => {
let cfg: AuthenticationSettings =
serde_json::from_value(body["value"].clone()).map_err(|_| invalid())?;
ensure(
cfg.button_label.chars().count() <= 80
&& cfg.client_id.len() <= 500
&& cfg.issuer.len() <= 2048,
crate::i18n::text("site_invalid"),
)?;
if cfg.oidc_enabled {
ensure(
!cfg.client_id.trim().is_empty() && !cfg.button_label.trim().is_empty(),
crate::i18n::text("site_invalid"),
)?;
}
if !cfg.issuer.is_empty() || cfg.oidc_enabled {
let url = reqwest::Url::parse(&cfg.issuer).map_err(|_| invalid())?;
ensure(
url.scheme() == "https"
&& url.host_str().is_some()
&& url.username().is_empty()
&& url.password().is_none()
&& url.query().is_none()
&& url.fragment().is_none(),
crate::i18n::text("site_invalid"),
)?;
}
ensure(
cfg.password_enabled || cfg.oidc_enabled,
crate::i18n::text("site_auth_method"),
)?;
ensure(
cfg.client_secret_env.starts_with("MEGURI_OIDC_")
&& cfg
.client_secret_env
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_'),
crate::i18n::text("site_invalid"),
)?;
let (oldrev, _) = settings(db).await?;
ensure(
body["section_revision"].as_i64() == Some(oldrev),
crate::i18n::text("site_stale"),
)?;
(
r#", changed AS(UPDATE meguri__site_settings SET payload=($4::jsonb->'value')::text,revision=revision+1 WHERE section='authentication' AND EXISTS(SELECT 1 FROM gate) RETURNING section)"#,
json!({"value":cfg}),
)
}
_ => return Err(Error::NotFound),
};
let sql = format!(
"{GATE}{sql} INSERT INTO meguri__site_audit(actor_id,action,details) SELECT $1,$5,jsonb_build_object('target',$3,'change',$4::jsonb)::text FROM changed"
);
let result = db
.db
.raw_with(
&sql,
&[
&actor,
&rev,
&id,
&details.to_string(),
&format!("site.{action}"),
],
)
.await?;
if result.rows_affected().0 == 0 {
return Err(Error::Conflict(crate::i18n::text("site_stale").into()));
}
Ok(())
}
+118 -29
View File
@@ -34,11 +34,14 @@ pub fn user(row: UserRow) -> Result<User> {
id: uuid(&row.id)?, id: uuid(&row.id)?,
user_id: Some(uuid(&row.id)?), user_id: Some(uuid(&row.id)?),
display_name: row.display_name, display_name: row.display_name,
email: Some(row.email), email: row.email,
country: Some(row.country), country: Some(row.country),
aliases: vec![],
}, },
username: row.username, username: row.username,
timezone: row.timezone, timezone: row.timezone,
instance_admin: row.instance_admin,
has_password: row.password_hash.is_some(),
}) })
} }
/// Workspace copies of account people are immutable identity snapshots. /// Workspace copies of account people are immutable identity snapshots.
@@ -53,7 +56,10 @@ pub fn account_people(document: &mut WorkspaceDocument, users: &[User]) -> Resul
.is_none_or(|e| Some(e) == user.email.as_ref()), .is_none_or(|e| Some(e) == user.email.as_ref()),
crate::i18n::text("account_person_authoritative"), crate::i18n::text("account_person_authoritative"),
)?; )?;
// The account owns the profile; merged contact IDs stay as aliases for history.
let aliases = std::mem::take(&mut person.aliases);
*person = user.person.clone(); *person = user.person.clone();
person.aliases = aliases;
} else { } else {
document.people.push(user.person.clone()); document.people.push(user.person.clone());
} }
@@ -62,7 +68,7 @@ pub fn account_people(document: &mut WorkspaceDocument, users: &[User]) -> Resul
} }
pub fn record(row: WorkspaceRow) -> Result<WorkspaceRecord> { pub fn record(row: WorkspaceRow) -> Result<WorkspaceRecord> {
if row.demo_deleted_at.is_some() { if row.demo_deleted_at.is_some() || row.disabled_at.is_some() {
return Err(Error::NotFound); return Err(Error::NotFound);
} }
Ok(WorkspaceRecord { Ok(WorkspaceRecord {
@@ -90,8 +96,24 @@ impl Store {
.ok_or(Error::NotFound)?, .ok_or(Error::NotFound)?,
) )
} }
pub async fn is_instance_admin(&self, user: Uuid) -> Result<bool> {
let id = user.to_string();
Ok(query!(UserRow,$id==id && $instance_admin==true)
.exists(&self.db)
.await?)
}
pub async fn workspace_key(&self, workspace: Uuid) -> Result<Option<String>> {
let id = workspace.to_string();
Ok(query!(WorkspaceRow,$id==id)
.get(&self.db)
.await?
.and_then(|row| row.key))
}
pub async fn permissions(&self, workspace: Uuid, actor: Uuid) -> Result<Vec<Permission>> { pub async fn permissions(&self, workspace: Uuid, actor: Uuid) -> Result<Vec<Permission>> {
self.workspace(workspace).await?; self.workspace(workspace).await?;
if self.is_instance_admin(actor).await? {
return Ok(Permission::all());
}
let workspace = workspace.to_string(); let workspace = workspace.to_string();
let actor = actor.to_string(); let actor = actor.to_string();
let row = query!(WorkspaceMemberRow,$workspace_id==workspace && $user_id==actor) let row = query!(WorkspaceMemberRow,$workspace_id==workspace && $user_id==actor)
@@ -119,6 +141,9 @@ impl Store {
} }
pub async fn owner(&self, workspace: Uuid, actor: Uuid) -> Result<()> { pub async fn owner(&self, workspace: Uuid, actor: Uuid) -> Result<()> {
self.workspace(workspace).await?; self.workspace(workspace).await?;
if self.is_instance_admin(actor).await? {
return Ok(());
}
let workspace = workspace.to_string(); let workspace = workspace.to_string();
let actor = actor.to_string(); let actor = actor.to_string();
if query!(WorkspaceMemberRow,$workspace_id==workspace && $user_id==actor && $is_owner==true) if query!(WorkspaceMemberRow,$workspace_id==workspace && $user_id==actor && $is_owner==true)
@@ -149,6 +174,7 @@ impl Store {
} else { } else {
serde_json::from_str(&m.permissions)? serde_json::from_str(&m.permissions)?
}, },
is_owner: m.is_owner,
}); });
} }
members.sort_by(|a, b| a.user.display_name.cmp(&b.user.display_name)); members.sort_by(|a, b| a.user.display_name.cmp(&b.user.display_name));
@@ -190,8 +216,8 @@ impl Store {
Ok(record) Ok(record)
} }
/// Cot 0.7 has no transaction API. A single parameterized CTE commits the CAS, /// Cot 0.7 has no transaction API. A single parameterized CTE commits the CAS,
/// membership (if any), history, audit, and durable job as one PostgreSQL statement. /// employee-role grants (if changed), history, audit, and durable job as one
#[allow(clippy::too_many_arguments)] /// PostgreSQL statement. Instance administrators pass the permission check.
pub async fn commit( pub async fn commit(
&self, &self,
record: &mut WorkspaceRecord, record: &mut WorkspaceRecord,
@@ -199,20 +225,9 @@ impl Store {
permission: Permission, permission: Permission,
action: &str, action: &str,
details: Value, details: Value,
member: Option<(Uuid, Vec<Permission>)>, role_permissions: Option<Vec<Permission>>,
) -> Result<()> { ) -> Result<()> {
let mut users = self.directory(record.id).await?; let users = self.directory(record.id).await?;
if let Some((id, _)) = &member {
let key = id.to_string();
if !users.iter().any(|u| u.id == *id) {
users.push(user(
query!(UserRow, $id == key)
.get(&self.db)
.await?
.ok_or(Error::NotFound)?,
)?);
}
}
account_people(&mut record.document, &users)?; account_people(&mut record.document, &users)?;
record record
.document .document
@@ -222,8 +237,9 @@ impl Store {
let document = serde_json::to_string(&record.document)?; let document = serde_json::to_string(&record.document)?;
let details = details.to_string(); let details = details.to_string();
let required = serde_json::to_string(&vec![permission])?; let required = serde_json::to_string(&vec![permission])?;
let member_id = member.as_ref().map(|(id, _)| id.to_string()); let role_permissions = role_permissions
let grants = serde_json::to_string(&member.map(|(_, g)| g).unwrap_or_default())?; .map(|grants| serde_json::to_string(&grants))
.transpose()?;
let result = self let result = self
.db .db
.raw_with( .raw_with(
@@ -236,8 +252,7 @@ impl Store {
&action, &action,
&details, &details,
&required, &required,
&member_id, &role_permissions,
&grants,
], ],
) )
.await?; .await?;
@@ -250,6 +265,65 @@ impl Store {
record.revision += 1; record.revision += 1;
Ok(()) Ok(())
} }
/// Revision-checked change made by the system (configuration sync or identity linking)
/// rather than by a person with a workspace grant. Never expose this to request input.
pub async fn system_commit(
&self,
record: &mut WorkspaceRecord,
actor: Option<Uuid>,
action: &str,
details: Value,
) -> Result<()> {
let workspace = record.id.to_string();
let actor = actor.map(|a| a.to_string());
let document = serde_json::to_string(&record.document)?;
let details = details.to_string();
let result = self
.db
.raw_with(
SYSTEM_COMMIT_WORKSPACE,
&[
&workspace,
&record.revision,
&document,
&actor,
&action,
&details,
],
)
.await?;
if result.rows_affected().0 == 0 {
return Err(Error::Conflict(
crate::i18n::text("err_this_team_changed_refresh_before_saving_7f93ad").into(),
));
}
record.revision += 1;
Ok(())
}
pub async fn instance_event(
&self,
actor: Option<Uuid>,
action: &str,
details: Value,
) -> Result<()> {
let actor = actor.map(|a| a.to_string());
let details = details.to_string();
self.db
.raw_with(
"INSERT INTO meguri__instance_audit(actor_id,action,details,created_at) VALUES($1,$2,$3,now())",
&[&actor, &action, &details],
)
.await?;
if action == "login.failed" {
// Anonymous callers can create these rows, so only the newest few thousand are kept.
self.db
.raw(
"DELETE FROM meguri__instance_audit WHERE action='login.failed' AND id<=COALESCE((SELECT id FROM meguri__instance_audit WHERE action='login.failed' ORDER BY id DESC OFFSET 2000 LIMIT 1),0)",
)
.await?;
}
Ok(())
}
pub async fn audit(&self, workspace: Uuid) -> Result<Vec<Value>> { pub async fn audit(&self, workspace: Uuid) -> Result<Vec<Value>> {
let mut names = crate::audit::Names::new(); let mut names = crate::audit::Names::new();
for person in self.directory(workspace).await? { for person in self.directory(workspace).await? {
@@ -361,7 +435,7 @@ impl Store {
)?; )?;
let payload = serde_json::to_string(&draft)?; let payload = serde_json::to_string(&draft)?;
let changed = self.db.raw_with(r#" let changed = self.db.raw_with(r#"
WITH allowed AS (SELECT id FROM meguri__workspaces WHERE id=$1 AND revision=$5 AND demo_deleted_at IS NULL AND EXISTS(SELECT 1 FROM meguri__workspace_members WHERE workspace_id=$1 AND user_id=$4 AND (is_owner OR permissions::jsonb @> '["edit_schedules"]'::jsonb))) WITH allowed AS (SELECT id FROM meguri__workspaces WHERE id=$1 AND revision=$5 AND demo_deleted_at IS NULL AND disabled_at IS NULL AND (EXISTS(SELECT 1 FROM meguri__users WHERE id=$4 AND instance_admin) OR EXISTS(SELECT 1 FROM meguri__workspace_members WHERE workspace_id=$1 AND user_id=$4 AND (is_owner OR permissions::jsonb @> '["edit_schedules"]'::jsonb))))
, saved AS (INSERT INTO meguri__workspace_drafts(workspace_id,revision,payload) SELECT id,1,$3 FROM allowed WHERE $2=0 OR EXISTS(SELECT 1 FROM meguri__workspace_drafts WHERE workspace_id=$1) , saved AS (INSERT INTO meguri__workspace_drafts(workspace_id,revision,payload) SELECT id,1,$3 FROM allowed WHERE $2=0 OR EXISTS(SELECT 1 FROM meguri__workspace_drafts WHERE workspace_id=$1)
ON CONFLICT(workspace_id) DO UPDATE SET revision=meguri__workspace_drafts.revision+1,payload=EXCLUDED.payload WHERE meguri__workspace_drafts.revision=$2 RETURNING workspace_id,revision) ON CONFLICT(workspace_id) DO UPDATE SET revision=meguri__workspace_drafts.revision+1,payload=EXCLUDED.payload WHERE meguri__workspace_drafts.revision=$2 RETURNING workspace_id,revision)
INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at) SELECT workspace_id,$4,'generation.draft_saved',jsonb_build_object('draft_revision',revision)::text,now() FROM saved INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at) SELECT workspace_id,$4,'generation.draft_saved',jsonb_build_object('draft_revision',revision)::text,now() FROM saved
@@ -463,7 +537,7 @@ INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at
self.prepare(workspace, revision, actor, Permission::ManageIntegration) self.prepare(workspace, revision, actor, Permission::ManageIntegration)
.await?; .await?;
let result = self.db.raw_with(r#" let result = self.db.raw_with(r#"
WITH selected AS (SELECT w.* FROM meguri__workspaces w WHERE id=$1 AND revision=$3 AND demo_deleted_at IS NULL AND EXISTS(SELECT 1 FROM meguri__workspace_members WHERE workspace_id=$1 AND user_id=$2 AND (is_owner OR permissions::jsonb @> '["manage_integration"]'::jsonb)) FOR NO KEY UPDATE), WITH selected AS (SELECT w.* FROM meguri__workspaces w WHERE id=$1 AND revision=$3 AND demo_deleted_at IS NULL AND disabled_at IS NULL AND (EXISTS(SELECT 1 FROM meguri__users WHERE id=$2 AND instance_admin) OR EXISTS(SELECT 1 FROM meguri__workspace_members WHERE workspace_id=$1 AND user_id=$2 AND (is_owner OR permissions::jsonb @> '["manage_integration"]'::jsonb))) FOR NO KEY UPDATE),
queued AS (UPDATE meguri__sync_jobs j SET authorized_revision=w.revision,authorized_document=w.document,state='pending',attempts=0,next_attempt=now(),last_error=NULL FROM selected w WHERE j.workspace_id=w.id AND (lease_until IS NULL OR lease_until<now()) RETURNING j.workspace_id) queued AS (UPDATE meguri__sync_jobs j SET authorized_revision=w.revision,authorized_document=w.document,state='pending',attempts=0,next_attempt=now(),last_error=NULL FROM selected w WHERE j.workspace_id=w.id AND (lease_until IS NULL OR lease_until<now()) RETURNING j.workspace_id)
INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at) SELECT workspace_id,$2,'publication.requested',jsonb_build_object('revision',$3::bigint)::text,now() FROM queued INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at) SELECT workspace_id,$2,'publication.requested',jsonb_build_object('revision',$3::bigint)::text,now() FROM queued
"#, &[&workspace.to_string(), &actor.to_string(), &revision]).await?; "#, &[&workspace.to_string(), &actor.to_string(), &revision]).await?;
@@ -479,13 +553,12 @@ INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at
const COMMIT_WORKSPACE: &str = r#" const COMMIT_WORKSPACE: &str = r#"
WITH updated AS ( WITH updated AS (
UPDATE meguri__workspaces SET document=$3,revision=revision+1 UPDATE meguri__workspaces SET document=$3,revision=revision+1
WHERE id=$1 AND revision=$2 AND demo_deleted_at IS NULL WHERE id=$1 AND revision=$2 AND demo_deleted_at IS NULL AND disabled_at IS NULL
AND ($5 <> 'generation.applied' OR ($6::jsonb->>'shared_revision') IS NULL OR EXISTS(SELECT 1 FROM meguri__workspace_drafts WHERE workspace_id=$1 AND revision=($6::jsonb->>'shared_revision')::bigint)) AND ($5 <> 'generation.applied' OR ($6::jsonb->>'shared_revision') IS NULL OR EXISTS(SELECT 1 FROM meguri__workspace_drafts WHERE workspace_id=$1 AND revision=($6::jsonb->>'shared_revision')::bigint))
AND EXISTS(SELECT 1 FROM meguri__workspace_members WHERE workspace_id=$1 AND user_id=$4 AND (is_owner OR permissions::jsonb @> $7::jsonb)) RETURNING * AND (EXISTS(SELECT 1 FROM meguri__users WHERE id=$4 AND instance_admin)
), member AS ( OR EXISTS(SELECT 1 FROM meguri__workspace_members WHERE workspace_id=$1 AND user_id=$4 AND (is_owner OR permissions::jsonb @> $7::jsonb))) RETURNING *
INSERT INTO meguri__workspace_members(id,workspace_id,user_id,permissions,is_owner) ), roles AS (
SELECT $1||':'||$8,$1,$8,$9,false FROM updated WHERE $8::text IS NOT NULL UPDATE meguri__workspace_members SET permissions=$8 WHERE $8::text IS NOT NULL AND NOT is_owner AND workspace_id IN(SELECT id FROM updated)
ON CONFLICT(id) DO UPDATE SET permissions=EXCLUDED.permissions
), history AS ( ), history AS (
INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at,actor_id) INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at,actor_id)
SELECT id||':'||revision::text,id,revision,document,clock_timestamp(),$4 FROM updated RETURNING effective_at SELECT id||':'||revision::text,id,revision,document,clock_timestamp(),$4 FROM updated RETURNING effective_at
@@ -498,6 +571,22 @@ INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at
SELECT id,$4,$5,$6,(SELECT effective_at FROM history) FROM updated SELECT id,$4,$5,$6,(SELECT effective_at FROM history) FROM updated
"#; "#;
const SYSTEM_COMMIT_WORKSPACE: &str = r#"
WITH updated AS (
UPDATE meguri__workspaces SET document=$3,revision=revision+1
WHERE id=$1 AND revision=$2 AND demo_deleted_at IS NULL AND disabled_at IS NULL RETURNING *
), history AS (
INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at,actor_id)
SELECT id||':'||revision::text,id,revision,document,clock_timestamp(),$4::text FROM updated RETURNING effective_at
), jobs AS (
INSERT INTO meguri__sync_jobs(workspace_id,requested_revision,state,attempts,next_attempt)
SELECT id,revision,'draft',0,now() FROM updated
ON CONFLICT(workspace_id) DO UPDATE SET requested_revision=EXCLUDED.requested_revision
)
INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at)
SELECT id,$4::text,$5,$6,(SELECT effective_at FROM history) FROM updated
"#;
/// Compare desired configuration with the last verified publication, never provider GETs. /// Compare desired configuration with the last verified publication, never provider GETs.
pub fn publication_changes( pub fn publication_changes(
document: &WorkspaceDocument, document: &WorkspaceDocument,
+258 -74
View File
@@ -38,7 +38,6 @@ impl App for MeguriApp {
Route::with_handler("/", index), Route::with_handler("/", index),
Route::with_handler("/assets/app.css", css), Route::with_handler("/assets/app.css", css),
Route::with_handler("/assets/app.js", js), Route::with_handler("/assets/app.js", js),
Route::with_handler("/assets/site.js", site_js),
Route::with_handler("/assets/generation.js", generation_js), Route::with_handler("/assets/generation.js", generation_js),
Route::with_handler("/assets/generation-map.js", generation_map_js), Route::with_handler("/assets/generation-map.js", generation_map_js),
Route::with_handler("/assets/cytoscape.js", cytoscape_js), Route::with_handler("/assets/cytoscape.js", cytoscape_js),
@@ -48,12 +47,17 @@ impl App for MeguriApp {
Route::with_handler("/health/ready", ready), Route::with_handler("/health/ready", ready),
]; ];
for path in [ for path in [
"/api/site",
"/api/site/{action}",
"/api/session", "/api/session",
"/api/register",
"/api/login", "/api/login",
"/api/logout", "/api/logout",
"/api/oidc/login",
"/api/oidc/callback",
"/api/admin/overview",
"/api/admin/events",
"/api/admin/users",
"/api/admin/users/{user}/groups",
"/api/admin/users/{user}/password",
"/api/admin/workspaces/{workspace}",
"/api/account", "/api/account",
"/api/workspaces", "/api/workspaces",
"/api/workspaces/{workspace}", "/api/workspaces/{workspace}",
@@ -70,8 +74,8 @@ impl App for MeguriApp {
"/api/workspaces/{workspace}/generation-import", "/api/workspaces/{workspace}/generation-import",
"/api/workspaces/{workspace}/generation-preview", "/api/workspaces/{workspace}/generation-preview",
"/api/workspaces/{workspace}/generation", "/api/workspaces/{workspace}/generation",
"/api/workspaces/{workspace}/member-permissions",
"/api/workspaces/{workspace}/generation-draft", "/api/workspaces/{workspace}/generation-draft",
"/api/workspaces/{workspace}/members",
"/api/workspaces/{workspace}/requests", "/api/workspaces/{workspace}/requests",
"/api/workspaces/{workspace}/request-preview", "/api/workspaces/{workspace}/request-preview",
"/api/workspaces/{workspace}/requests/{request}", "/api/workspaces/{workspace}/requests/{request}",
@@ -115,14 +119,8 @@ fn secured(response: Response) -> cot::Result<Response> {
.with_header("Cache-Control", "no-store") .with_header("Cache-Control", "no-store")
.into_response() .into_response()
} }
async fn site_js() -> cot::Result<Response> {
Html::new(include_str!("../static/site.js"))
.with_header("Content-Type", "application/javascript; charset=utf-8")
.into_response()
}
async fn index() -> cot::Result<Response> { async fn index() -> cot::Result<Response> {
let html = include_str!("../static/index.html") let html = include_str!("../static/index.html")
.replace("<!-- SITE_ADMIN -->", include_str!("../static/site.html"))
.replace( .replace(
"<!-- GENERATION_MAP -->", "<!-- GENERATION_MAP -->",
include_str!("../static/generation-map.html"), include_str!("../static/generation-map.html"),
@@ -243,7 +241,6 @@ async fn authenticate(db: &Store, request: &Request) -> Result<Identity> {
if user.demo_deleted_at.is_some() { if user.demo_deleted_at.is_some() {
return Err(Error::Unauthorized); return Err(Error::Unauthorized);
} }
crate::site::active(db, store::uuid(&user.id)?).await?;
Ok(Identity { Ok(Identity {
user: store::user(user)?, user: store::user(user)?,
csrf: session.csrf, csrf: session.csrf,
@@ -285,19 +282,28 @@ async fn dispatch(request: Request) -> Result<Response> {
crate::i18n::text("err_send_a_json_request_34adc9"), crate::i18n::text("err_send_a_json_request_34adc9"),
)?; )?;
} }
let config = crate::access::current()?;
let identity = authenticate(&db, &request).await; let identity = authenticate(&db, &request).await;
if path == "/api/session" && method == Method::GET { if path == "/api/session" && method == Method::GET {
let auth =
json!({"oidc":config.oidc.is_some(),"password":config.password_form_available()});
return match identity { return match identity {
Ok(i) => response( Ok(i) => response(
json!({"site_admin":crate::site::require(&db,i.user.id).await.is_ok(),"user":i.user,"csrf":i.csrf,"allow_signup":enabled("MEGURI_ALLOW_SIGNUP"),"demo":crate::demo_runtime::active(),"demo_lifecycle":crate::demo_runtime::notice()}), json!({"version":env!("CARGO_PKG_VERSION"),"user":i.user,"csrf":i.csrf,"auth":auth,"demo":crate::demo_runtime::active(),"demo_lifecycle":crate::demo_runtime::notice()}),
), ),
Err(Error::Unauthorized) => response( Err(Error::Unauthorized) => response(
json!({"user":null,"allow_signup":enabled("MEGURI_ALLOW_SIGNUP"),"demo":crate::demo_runtime::active(),"demo_lifecycle":crate::demo_runtime::notice()}), json!({"version":env!("CARGO_PKG_VERSION"),"user":null,"auth":auth,"demo":crate::demo_runtime::active(),"demo_lifecycle":crate::demo_runtime::notice()}),
), ),
Err(e) => Err(e), Err(e) => Err(e),
}; };
} }
let anonymous = path == "/api/login" || path == "/api/register"; if path == "/api/oidc/login" && method == Method::GET {
return oidc_login(&db).await;
}
if path == "/api/oidc/callback" && method == Method::GET {
return oidc_callback(&db, config, &request, &query_string).await;
}
let anonymous = path == "/api/login";
if mutation && !anonymous { if mutation && !anonymous {
let i = identity.as_ref().map_err(|_| Error::Unauthorized)?; let i = identity.as_ref().map_err(|_| Error::Unauthorized)?;
if request if request
@@ -328,55 +334,24 @@ async fn dispatch(request: Request) -> Result<Response> {
struct Credentials { struct Credentials {
username: String, username: String,
password: String, password: String,
#[serde(default)]
display_name: String,
#[serde(default)]
email: String,
} }
let credentials: Credentials = decode(body)?; let credentials: Credentials = decode(body)?;
check_login_rate(&credentials.username)?; check_login_rate(&credentials.username)?;
if path == "/api/register" { let (user, token, csrf) = service::login(
if !enabled("MEGURI_ALLOW_SIGNUP") {
return Err(Error::Forbidden);
}
service::register(
&db, &db,
config.password_login_enabled(),
credentials.username.clone(), credentials.username.clone(),
credentials.display_name, credentials.password,
credentials.email,
credentials.password.clone(),
) )
.await?; .await?;
} forget_login_attempts(&credentials.username);
let (user, token, csrf) = return response(json!({"user":user,"csrf":csrf}))?
service::login(&db, credentials.username, credentials.password).await?; .with_header("Set-Cookie", session_cookie(&token))
let secure = if origin().starts_with("https://") {
"; Secure"
} else {
""
};
return response(json!({"site_admin":crate::site::require(&db,user.id).await.is_ok(),"user":user,"csrf":csrf}))?
.with_header(
"Set-Cookie",
format!(
"meguri_session={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=43200{secure}"
),
)
.into_response() .into_response()
.map_err(|e| Error::Internal(e.to_string())); .map_err(|e| Error::Internal(e.to_string()));
} }
let identity = identity?; let identity = identity?;
let actor = identity.user.id; let actor = identity.user.id;
if path == "/api/site" && method == Method::GET {
return response(crate::site::overview(&db, actor).await?);
}
if let Some(action) = path.strip_prefix("/api/site/") {
if method == Method::PUT {
crate::site::mutate(&db, actor, action, body).await?;
return response(json!({"ok":true}));
}
return Err(Error::NotFound);
}
if path == "/api/logout" && method == Method::POST { if path == "/api/logout" && method == Method::POST {
let token = identity.token_hash; let token = identity.token_hash;
query!(SessionRow,$token_hash==token).delete(&db.db).await?; query!(SessionRow,$token_hash==token).delete(&db.db).await?;
@@ -400,14 +375,18 @@ async fn dispatch(request: Request) -> Result<Response> {
.await? .await?
.ok_or(Error::NotFound)?; .ok_or(Error::NotFound)?;
if let Some(password) = body["password"].as_str().filter(|p| !p.is_empty()) { if let Some(password) = body["password"].as_str().filter(|p| !p.is_empty()) {
ensure(
user.password_hash.is_some(),
crate::i18n::text("err_this_account_signs_in_with_sso_and_has_no_password_6d1f3b"),
)?;
let current = body["current_password"] let current = body["current_password"]
.as_str() .as_str()
.unwrap_or_default() .unwrap_or_default()
.to_owned(); .to_owned();
let (_, temporary, _) = service::login(&db, user.username.clone(), current).await?; service::verify_password(&db, &user.username, current)
let hash = service::token_hash(&temporary); .await?
query!(SessionRow,$token_hash==hash).delete(&db.db).await?; .ok_or(Error::Unauthorized)?;
user.password_hash = service::hash_password(password.to_owned()).await?; user.password_hash = Some(service::hash_password(password.to_owned()).await?);
} }
user.display_name = name.into(); user.display_name = name.into();
if let Some(language) = body.get("language") { if let Some(language) = body.get("language") {
@@ -445,10 +424,8 @@ async fn dispatch(request: Request) -> Result<Response> {
return response(json!({"user":store::user(user)?})); return response(json!({"user":store::user(user)?}));
} }
let parts: Vec<_> = path.trim_matches('/').split('/').collect(); let parts: Vec<_> = path.trim_matches('/').split('/').collect();
if parts == ["api", "workspaces"] && method == Method::POST { if parts.len() >= 3 && parts[1] == "admin" {
return response(serde_json::to_value( return admin_route(&db, config, actor, &parts, &method, &query_string, body).await;
service::create_workspace(&db, actor, text(&body, "name")?).await?,
)?);
} }
if parts.len() < 3 || parts[1] != "workspaces" { if parts.len() < 3 || parts[1] != "workspaces" {
return Err(Error::NotFound); return Err(Error::NotFound);
@@ -502,8 +479,16 @@ async fn dispatch(request: Request) -> Result<Response> {
} else { } else {
json!({"total":0,"issues":[]}) json!({"total":0,"issues":[]})
}; };
let access = if grants.contains(&Permission::ManageMembers) {
db.workspace_key(workspace)
.await?
.and_then(|key| config.workspaces.iter().find(|w| w.key == key))
.map(|w| json!({"admin_groups":w.admin_groups,"member_groups":w.member_groups}))
} else {
None
};
return response( return response(
json!({"generation_draft":db.shared_draft(workspace).await?,"coverage":coverage,"workspace":record,"is_owner":db.owner(workspace,actor).await.is_ok(),"permissions":grants,"members":db.members(workspace).await?,"directory":db.directory(workspace).await?,"sync":db.sync_status(workspace).await?,"audit":audit}), json!({"generation_draft":db.shared_draft(workspace).await?,"coverage":coverage,"workspace":record,"is_owner":db.owner(workspace,actor).await.is_ok(),"instance_admin":identity.user.instance_admin,"access":access,"permissions":grants,"members":db.members(workspace).await?,"directory":db.directory(workspace).await?,"sync":db.sync_status(workspace).await?,"audit":audit}),
); );
} }
let action = parts.get(3).copied().unwrap_or_default(); let action = parts.get(3).copied().unwrap_or_default();
@@ -675,14 +660,13 @@ async fn dispatch(request: Request) -> Result<Response> {
.await?, .await?,
)?); )?);
} }
if action == "members" && method == Method::PUT { if action == "member-permissions" && method == Method::PUT {
return response(serde_json::to_value( return response(serde_json::to_value(
service::set_member( service::set_member_permissions(
&db, &db,
actor, actor,
workspace, workspace,
revision(&body)?, revision(&body)?,
text(&body, "username")?,
decode(body["permissions"].clone())?, decode(body["permissions"].clone())?,
) )
.await?, .await?,
@@ -770,6 +754,201 @@ async fn dispatch(request: Request) -> Result<Response> {
Err(Error::NotFound) Err(Error::NotFound)
} }
fn session_cookie(token: &str) -> String {
let secure = if origin().starts_with("https://") {
"; Secure"
} else {
""
};
format!("meguri_session={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=43200{secure}")
}
fn flow_cookie(value: &str, max_age: u32) -> String {
let secure = if origin().starts_with("https://") {
"; Secure"
} else {
""
};
format!(
"meguri_oidc={value}; Path=/api/oidc; HttpOnly; SameSite=Lax; Max-Age={max_age}{secure}"
)
}
fn redirect(location: &str) -> Result<Response> {
cot::response::Redirect::new(location.to_owned())
.into_response()
.map_err(|e| Error::Internal(e.to_string()))
}
/// Every refused SSO sign-in gets a support reference that is shown to the person, logged, and
/// stored as an instance event so an administrator can look it up on the instance page.
async fn login_failure(
db: &Store,
code: &str,
detail: impl std::fmt::Display,
context: Value,
) -> Result<Response> {
let reference = Uuid::new_v4().simple().to_string()[..8].to_owned();
let detail = detail.to_string();
tracing::warn!(%reference, %code, %detail, "SSO sign-in was not completed");
let mut details = json!({"reference":reference,"code":code,"detail":detail});
if let (Some(extra), Some(target)) = (context.as_object(), details.as_object_mut()) {
target.extend(extra.clone());
}
if let Err(error) = db.instance_event(None, "login.failed", details).await {
tracing::error!(%error, "could not record the failed sign-in");
}
redirect(&format!("/?login_error={code}&ref={reference}"))?
.with_header("Set-Cookie", flow_cookie("", 0))
.into_response()
.map_err(|e| Error::Internal(e.to_string()))
}
async fn oidc_login(db: &Store) -> Result<Response> {
if crate::oidc::settings().is_none() {
return Err(Error::NotFound);
}
match crate::oidc::begin(db).await {
Ok((url, state)) => redirect(&url)?
.with_header("Set-Cookie", flow_cookie(&state, 600))
.into_response()
.map_err(|e| Error::Internal(e.to_string())),
Err(error) => login_failure(db, "unavailable", error, json!({})).await,
}
}
async fn oidc_callback(
db: &Store,
config: &crate::access::Config,
request: &Request,
query_string: &str,
) -> Result<Response> {
use crate::{identity, oidc};
if oidc::settings().is_none() {
return Err(Error::NotFound);
}
let url = reqwest::Url::parse(&format!("http://localhost/?{query_string}"))
.map_err(|_| Error::NotFound)?;
let params: std::collections::BTreeMap<_, _> = url.query_pairs().into_owned().collect();
if let Some(error) = params.get("error") {
return login_failure(
db,
"failed",
format!("provider returned {error}"),
json!({}),
)
.await;
}
let (Some(state), Some(code)) = (params.get("state"), params.get("code")) else {
return login_failure(db, "failed", "callback without state or code", json!({})).await;
};
let cookie_state = request
.headers()
.get("cookie")
.and_then(|h| h.to_str().ok())
.unwrap_or_default()
.split(';')
.map(str::trim)
.find_map(|c| c.strip_prefix("meguri_oidc="))
.map(str::to_owned);
let profile = match oidc::complete(db, cookie_state.as_deref(), state, code).await {
Ok(profile) => profile,
Err(oidc::Failure::Unavailable(detail)) => {
return login_failure(db, "unavailable", detail, json!({})).await;
}
Err(oidc::Failure::Invalid(detail)) => {
return login_failure(db, "failed", detail, json!({})).await;
}
};
match identity::sso_sign_in(db, config, profile.clone()).await {
Ok(identity::SsoOutcome::SignedIn { token, .. }) => redirect("/")?
.with_header("Set-Cookie", session_cookie(&token))
.with_header("Set-Cookie", flow_cookie("", 0))
.into_response()
.map_err(|e| Error::Internal(e.to_string())),
Ok(identity::SsoOutcome::NoAccess) => {
login_failure(
db,
"no_access",
"no workspace or instance role matches the groups of this account",
json!({
"subject":profile.subject,
"username":profile.username_hint,
"email":profile.email,
"groups":profile.groups,
"issuer":profile.issuer,
}),
)
.await
}
Err(error) => {
login_failure(
db,
"failed",
error,
json!({"subject":profile.subject,"email":profile.email}),
)
.await
}
}
}
async fn admin_route(
db: &Store,
config: &crate::access::Config,
actor: Uuid,
parts: &[&str],
method: &Method,
query_string: &str,
body: Value,
) -> Result<Response> {
use crate::admin;
match (parts, method) {
(["api", "admin", "overview"], &Method::GET) => {
response(admin::overview(db, config, actor).await?)
}
(["api", "admin", "events"], &Method::GET) => {
let url = reqwest::Url::parse(&format!("http://localhost/?{query_string}"))
.map_err(|_| Error::NotFound)?;
let search = url
.query_pairs()
.find(|(key, _)| key == "search")
.map(|(_, value)| value.into_owned())
.unwrap_or_default();
response(admin::events(db, actor, &search).await?)
}
(["api", "admin", "users"], &Method::POST) => {
response(admin::create_user(db, config, actor, decode(body)?).await?)
}
(["api", "admin", "users", user, "groups"], &Method::PUT) => response(
admin::set_user_groups(
db,
config,
actor,
parse_id(user)?,
decode(body["groups"].clone())?,
)
.await?,
),
(["api", "admin", "users", user, "password"], &Method::POST) => {
admin::reset_password(db, actor, parse_id(user)?, text(&body, "password")?).await?;
response(json!({"ok":true}))
}
(["api", "admin", "workspaces", workspace], &Method::DELETE) => {
admin::delete_workspace(
db,
config,
actor,
parse_id(workspace)?,
&text(&body, "confirm")?,
)
.await?;
response(json!({"ok":true}))
}
_ => Err(Error::NotFound),
}
}
fn replacement_boundary( fn replacement_boundary(
doc: &WorkspaceDocument, doc: &WorkspaceDocument,
schedule: Uuid, schedule: Uuid,
@@ -814,17 +993,22 @@ fn decode<T: serde::de::DeserializeOwned>(value: Value) -> Result<T> {
}) })
} }
type Attempts = std::sync::Mutex<std::collections::HashMap<String, (std::time::Instant, u16)>>;
fn attempts() -> &'static Attempts {
static ATTEMPTS: std::sync::OnceLock<Attempts> = std::sync::OnceLock::new();
ATTEMPTS.get_or_init(Default::default)
}
/// A successful sign-in clears the counter; only failed guessing accumulates.
fn forget_login_attempts(username: &str) {
if let Ok(mut attempts) = attempts().lock() {
attempts.remove(&username.trim().to_ascii_lowercase());
}
}
fn check_login_rate(username: &str) -> Result<()> { fn check_login_rate(username: &str) -> Result<()> {
use std::{ use std::time::{Duration, Instant};
collections::HashMap, let mut attempts = attempts().lock().map_err(|_| {
sync::{Mutex, OnceLock},
time::{Duration, Instant},
};
static ATTEMPTS: OnceLock<Mutex<HashMap<String, (Instant, u16)>>> = OnceLock::new();
let mut attempts = ATTEMPTS
.get_or_init(|| Mutex::new(HashMap::new()))
.lock()
.map_err(|_| {
Error::Internal(crate::i18n::text("err_login_limiter_unavailable_4f8aae").into()) Error::Internal(crate::i18n::text("err_login_limiter_unavailable_4f8aae").into())
})?; })?;
attempts.retain(|_, (at, _)| at.elapsed() < Duration::from_secs(300)); attempts.retain(|_, (at, _)| at.elapsed() < Duration::from_secs(300));
+29 -2
View File
@@ -195,6 +195,35 @@ input[type=date]:not(.native-date),.date-field{max-width:176px;min-width:0}
.map-resource-fields{padding:0 12px 12px;border-top:1px solid var(--line)}.map-field-hint{display:block;font-size:10px;line-height:1.5;color:var(--muted);margin-bottom:6px}.map-resource-fields .map-reference>small{flex-basis:auto;margin:0;min-width:15px}.map-resource-fields .map-input-field:last-child{margin-bottom:0} .map-resource-fields{padding:0 12px 12px;border-top:1px solid var(--line)}.map-field-hint{display:block;font-size:10px;line-height:1.5;color:var(--muted);margin-bottom:6px}.map-resource-fields .map-reference>small{flex-basis:auto;margin:0;min-width:15px}.map-resource-fields .map-input-field:last-child{margin-bottom:0}
.login-form .password-login{display:block}.login-form .password-login>label:first-of-type{margin-top:22px}.login-form .sso-button{display:block;width:100%;margin-top:26px;padding:13px;text-align:center;text-decoration:none;border:1px solid var(--green);border-radius:7px;box-sizing:border-box}
.login-divider{display:flex;align-items:center;gap:12px;margin:22px 0 0;color:var(--muted);font-size:12px}.login-divider::before,.login-divider::after{content:"";flex:1;border-top:1px solid var(--line)}
.login-error{display:flex;flex-direction:column;gap:6px;margin-top:22px;padding:12px 14px;border:1px solid #e9c6b9;border-radius:7px;background:#fff3ee;color:#a04e34;font-size:13px;line-height:1.5}.login-error small{font-size:11px;user-select:all;color:#8a5f4f}
.demo-accounts{list-style:none;margin:8px 0;padding:0;display:flex;flex-direction:column;gap:2px}.demo-account{display:flex;align-items:baseline;gap:10px;padding:2px 0;font-size:11px;text-align:left;border:0;background:none}.demo-account code{min-width:56px;font-weight:700}.demo-password{display:block;margin-top:6px}.demo-password code{user-select:all}.demo-note small{display:block;margin-top:6px;color:#8a977f}
a.button{display:inline-block;text-decoration:none;border:1px solid #dbe3de;border-radius:7px;padding:9px 13px;color:inherit}a.button.primary{width:auto;margin-top:12px}
.sidebar nav .instance-link{margin-top:6px;border-top:1px solid var(--line);border-radius:0;padding-top:18px}
.warning-banner{margin:0 0 20px;padding:14px 16px;border:1px solid #e5c9a8;border-radius:8px;background:#fdf4e3;color:#7d5a1d;font-size:13px;line-height:1.5}
.config-summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px 24px;margin:12px 0}.config-summary>div{min-width:0}.config-summary dt{font-size:11px;color:var(--muted);margin-bottom:3px}.config-summary dd{margin:0;overflow-wrap:anywhere}
.admin-workspace.disabled{background:#faf7f2}.admin-workspace small{display:block;margin-top:3px;overflow-wrap:anywhere}.disabled-note{color:#a17f32}
.admin-user small{display:block;margin-top:3px;overflow-wrap:anywhere}.admin-user .badge,.admin-workspace .badge{white-space:nowrap}.admin-user>button,.admin-workspace>button{white-space:nowrap}small.inline{display:inline}
.padded{padding:16px}.danger-button{color:#a04e34;border-color:#e9c6b9}.danger-button:hover{background:#fff3ee}.danger-note{background:#fff3ee;color:#a04e34}
.role-badge{text-transform:none}.role-badge.admin{background:#e4f1e8;color:#3d7e57}
.permission-list{list-style:none;margin:12px 0;padding:0;display:flex;flex-direction:column;gap:6px}.permission-list li{color:var(--muted)}.permission-list li.on{color:var(--ink)}
.group-picker{border:1px solid var(--line);border-radius:8px;padding:10px 14px;margin:12px 0 0}.group-picker legend{font-size:12px;color:var(--muted);padding:0 6px}.group-picker .check{margin:6px 0}
@media(max-width:700px){.config-summary{grid-template-columns:1fr}.admin-user,.admin-workspace{flex-wrap:wrap}.admin-user>.grow,.admin-workspace>.grow{flex-basis:calc(100% - 60px)}}
@media(max-width:700px){.sidebar nav .instance-link{margin:0;border:0;border-radius:6px;padding:6px}.sidebar nav .instance-link>span:not(.nav-icon){position:absolute;width:1px;height:1px;clip-path:inset(50%);overflow:hidden;white-space:nowrap}}
.admin-user .badge,.admin-workspace .badge{text-transform:none}.admin-user strong{display:inline}
@media(max-width:700px){.sidebar nav .instance-link{padding:6px 2px}.sidebar nav .instance-link .nav-icon{width:16px}}
.event-search{display:flex;gap:8px;margin:12px 0}.event-search input{flex:1;min-width:0}.event-search button{white-space:nowrap}.event-search .primary{width:auto;margin:0;padding:9px 13px}
.event-list{display:flex;flex-direction:column}.event-row{padding:12px 0;border-top:1px solid var(--line);min-width:0}.event-row:first-child{border-top:0}.event-row.failed{border-left:3px solid #d99a82;padding-left:12px;background:#fff9f6}
.event-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 12px}.event-actor{font-weight:600}.event-time{color:var(--muted);margin-left:auto}
.event-action{font:12px/1.4 Consolas,monospace;background:#eef3f0;color:#3d5a4c;padding:2px 7px;border-radius:5px;overflow-wrap:anywhere}.event-action.failed{background:#fbe3da;color:#a04e34}
.event-details{display:flex;flex-wrap:wrap;gap:4px 18px;margin:6px 0 0}.event-details>div{min-width:0;max-width:100%}.event-details dt{display:inline;color:var(--muted);font-size:11px}.event-details dt::after{content:": "}.event-details dd{display:inline;margin:0;overflow-wrap:anywhere;word-break:break-word;font-size:12px}
.chips{display:inline-flex;flex-wrap:wrap;gap:4px}.chip{font:11px/1.4 Consolas,monospace;background:#eef3f0;padding:1px 6px;border-radius:10px;overflow-wrap:anywhere}
@media(max-width:700px){.event-search{flex-wrap:wrap}.event-search input{flex-basis:100%}.event-time{margin-left:0;flex-basis:100%}}
.brand .app-version{align-self:flex-end;margin-bottom:7px;font-size:11px;font-weight:500;letter-spacing:0;color:var(--muted)}
@media(max-width:950px){.sidebar .brand{flex-direction:column;gap:2px}.sidebar .brand .app-version{align-self:center;margin:0}}
@media(max-width:700px){.sidebar{gap:4px;padding:8px}.sidebar nav :is(button,a){padding:8px 4px}.sidebar .brand .app-version{margin:0;font-size:10px}}
.publication-review{border:1px solid var(--line);border-radius:9px;padding:16px;margin-bottom:22px;background:#fff}.publication-review-heading{display:flex;justify-content:space-between;align-items:center;gap:12px}.publication-sync{font-weight:650}.publication-sync.sync-needed{background:#ffd65a;color:#493500;border:1px solid #c79a14;box-shadow:0 0 0 2px #ffe99c}.publication-draft-note{font-size:12px;color:#76643d;margin:9px 0}.publication-status{display:flex;flex-wrap:wrap;gap:10px;font-size:12px;margin-bottom:12px}.publication-status .sync-error{width:100%}.publication-review .compact-preview{width:100%;margin-top:8px} .publication-review{border:1px solid var(--line);border-radius:9px;padding:16px;margin-bottom:22px;background:#fff}.publication-review-heading{display:flex;justify-content:space-between;align-items:center;gap:12px}.publication-sync{font-weight:650}.publication-sync.sync-needed{background:#ffd65a;color:#493500;border:1px solid #c79a14;box-shadow:0 0 0 2px #ffe99c}.publication-draft-note{font-size:12px;color:#76643d;margin:9px 0}.publication-status{display:flex;flex-wrap:wrap;gap:10px;font-size:12px;margin-bottom:12px}.publication-status .sync-error{width:100%}.publication-review .compact-preview{width:100%;margin-top:8px}
.publication-actions{flex-wrap:wrap;justify-content:flex-end}.publication-workflow{font-size:12px;line-height:1.5}.publication-review-heading{flex-wrap:wrap}@media(max-width:650px){.publication-actions{justify-content:flex-start}} .publication-actions{flex-wrap:wrap;justify-content:flex-end}.publication-workflow{font-size:12px;line-height:1.5}.publication-review-heading{flex-wrap:wrap}@media(max-width:650px){.publication-actions{justify-content:flex-start}}
@@ -203,7 +232,5 @@ input[type=date]:not(.native-date),.date-field{max-width:176px;min-width:0}
.config-guide{min-width:0}.config-guide>.section-line{align-items:flex-start;gap:12px}.config-guide>.section-line button{flex-shrink:0}.config-guide h3{font-size:13px;margin:18px 0 7px}.config-guide pre{font:12px/1.65 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap;overflow-wrap:anywhere;margin:0;padding:12px;background:#f4f7f5;border:1px solid var(--line);border-radius:8px}.config-guide .guide-example{background:#edf4ef}@media(max-width:600px){.config-guide>.section-line{flex-wrap:wrap}.config-guide pre{font-size:11px;padding:9px}} .config-guide{min-width:0}.config-guide>.section-line{align-items:flex-start;gap:12px}.config-guide>.section-line button{flex-shrink:0}.config-guide h3{font-size:13px;margin:18px 0 7px}.config-guide pre{font:12px/1.65 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap;overflow-wrap:anywhere;margin:0;padding:12px;background:#f4f7f5;border:1px solid var(--line);border-radius:8px}.config-guide .guide-example{background:#edf4ef}@media(max-width:600px){.config-guide>.section-line{flex-wrap:wrap}.config-guide pre{font-size:11px;padding:9px}}
.site-layout{display:grid;grid-template-columns:180px minmax(0,1fr);gap:28px;align-items:start}.site-tabs{display:flex;flex-direction:column;gap:6px}.site-tabs button{text-align:left}.site-tabs .active{background:var(--ink,#193c34);color:white}.site-body{min-width:0;max-width:1000px}.site-list{display:grid;gap:10px}.site-row{display:flex;justify-content:space-between;align-items:center;gap:16px;background:white;border:1px solid #dae4df;border-radius:12px;padding:16px}.site-editor,.site-settings-form fieldset{background:white;border:1px solid #dae4df;border-radius:12px;padding:20px;margin:20px 0}.site-settings-form{max-width:720px}.site-settings-form legend{font-weight:650;padding:0 8px}.site-settings-form label,.site-editor label{margin:12px 0}.site-settings-form .check,.site-editor .check{display:flex;align-items:center;gap:10px}.site-settings-form .check input,.site-editor .check input{width:auto}.site-audit-row{border-bottom:1px solid #dae4df;padding:14px 0;display:flex;gap:12px;flex-wrap:wrap}.site-audit-row pre{width:100%;white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px}.site-admin .page-heading{align-items:start}.site-row strong{overflow-wrap:anywhere}@media(max-width:760px){.site-layout{grid-template-columns:minmax(0,1fr);gap:16px}.site-tabs{flex-direction:row;flex-wrap:wrap}.site-tabs button{font-size:12px}.site-row{padding:12px}.site-settings-form fieldset{padding:14px}.site-admin .page-heading{flex-wrap:wrap}}
@media(max-width:950px){.sidebar .site-nav-label{display:none}.sidebar .site-nav-link{flex-shrink:0;justify-content:center}}
@media(max-width:700px){.sidebar nav{min-width:0;flex-wrap:wrap}.sidebar nav :is(button,a){gap:5px;padding:6px 4px}.sidebar-bottom{flex-shrink:0}} @media(max-width:700px){.sidebar nav{min-width:0;flex-wrap:wrap}.sidebar nav :is(button,a){gap:5px;padding:6px 4px}.sidebar-bottom{flex-shrink:0}}
+37 -20
View File
@@ -18,10 +18,9 @@ document.addEventListener('alpine:init', () => {
})); }));
Alpine.data('meguri', () => ({ Alpine.data('meguri', () => ({
...generationUi(), ...generationUi(),
...siteUi(),
syncSubmitting:false,workspacePollAt:0, syncSubmitting:false,workspacePollAt:0,
user: null, csrf: '', ready: false, busy: false, error: '', toast: '', allowSignup: false, demo: false, demoLifecycle:null, user: null, csrf: '', version: '', ready: false, busy: false, error: '', toast: '', demo: false, demoLifecycle:null, auth:{oidc:false,password:true}, loginError:null, admin:null, events:[], eventsMore:false, eventSearch:'', eventsLoading:false,
loginForm: { username: '', password: '', display_name: '', email: '' }, registering: false, loginForm: { username: '', password: '' },
directoryState: { workspaces: [] }, selected: '', detail: null, calendar: { days: [], assignments: [] }, directoryState: { workspaces: [] }, selected: '', detail: null, calendar: { days: [], assignments: [] },
view: 'calendar', scheduleId: '', startDay: '', modal: '', form: {}, menu: false, view: 'calendar', scheduleId: '', startDay: '', modal: '', form: {}, menu: false,
navigationRestoring:0, navigationEpoch:0, setupOpen:false, scheduleSearch:'', schedulePicker:false, coverQuery:'', requestResult:null, requestError:'', requestLoading:false, requestSequence:0, navigationRestoring:0, navigationEpoch:0, setupOpen:false, scheduleSearch:'', schedulePicker:false, coverQuery:'', requestResult:null, requestError:'', requestLoading:false, requestSequence:0,
@@ -53,7 +52,7 @@ document.addEventListener('alpine:init', () => {
workingSummary() { const w=this.doc?.working_interval; return w ? w.weekdays.map(d=>[tr('mon_24b2a0'),tr('tue_529541'),tr('wed_23408b'),tr('thu_3593cc'),tr('fri_bbd6e3'),tr('sat_6b782d'),tr('sun_48c98c')][d-1]).join(', ')+' · '+w.start.slice(0,5)+'–'+w.end.slice(0,5)+' · '+w.timezone : ''; }, workingSummary() { const w=this.doc?.working_interval; return w ? w.weekdays.map(d=>[tr('mon_24b2a0'),tr('tue_529541'),tr('wed_23408b'),tr('thu_3593cc'),tr('fri_bbd6e3'),tr('sat_6b782d'),tr('sun_48c98c')][d-1]).join(', ')+' · '+w.start.slice(0,5)+'–'+w.end.slice(0,5)+' · '+w.timezone : ''; },
conditionText(c) { return (c.negate?tr('not_975146'):'')+this.label(c.field)+' '+this.label(c.operation)+' “'+c.value+'”'; }, conditionText(c) { return (c.negate?tr('not_975146'):'')+this.label(c.field)+' '+this.label(c.operation)+' “'+c.value+'”'; },
scheduleTime(id) { return this.doc?.schedules.find(s=>s.id===id)?.handover?.slice(0,5) || '11:00'; }, scheduleTime(id) { return this.doc?.schedules.find(s=>s.id===id)?.handover?.slice(0,5) || '11:00'; },
get nav(){return [{ id: 'calendar', name: tr('calendar_adab50'), icon: '▦' }, { id: 'requests', name: tr('requests_f7194e'), icon: '⇄' }, { id: 'configuration', name: tr('schedule_setup'), icon: '⊞' }, { id: 'activity', name: tr('activity_81c0d9'), icon: '≡' }, { id: 'settings', name: tr('team_settings_06b774'), icon: '⚙' }];}, get nav(){return [{ id: 'calendar', name: tr('calendar_adab50'), icon: '▦' }, { id: 'requests', name: tr('requests_f7194e'), icon: '⇄' }, { id: 'configuration', name: tr('schedule_setup'), icon: '⊞' }, { id: 'activity', name: tr('activity_81c0d9'), icon: '≡' }, { id: 'settings', name: tr('team_settings_06b774'), icon: '⚙' }, { id: 'instance', name: tr('access_instance_nav'), icon: '⬢' }];},
get grantOptions(){return [{ id: 'view', name: tr('view_calendar_b702b1') }, { id: 'request_overrides', name: tr('request_replacements_386c5e') }, { id: 'edit_schedules', name: tr('edit_schedules_policies_a02744') }, { id: 'force_overrides', name: tr('apply_without_confirmation_f0350d') }, { id: 'manage_members', name: tr('manage_team_access_db8b53') }, { id: 'manage_integration', name: tr('manage_provider_804901') }, { id: 'view_audit', name: tr('view_audit_history_896487') }];}, get grantOptions(){return [{ id: 'view', name: tr('view_calendar_b702b1') }, { id: 'request_overrides', name: tr('request_replacements_386c5e') }, { id: 'edit_schedules', name: tr('edit_schedules_policies_a02744') }, { id: 'force_overrides', name: tr('apply_without_confirmation_f0350d') }, { id: 'manage_members', name: tr('manage_team_access_db8b53') }, { id: 'manage_integration', name: tr('manage_provider_804901') }, { id: 'view_audit', name: tr('view_audit_history_896487') }];},
get workspace() { return this.detail?.workspace; }, get workspace() { return this.detail?.workspace; },
get doc() { return this.workspace?.document; }, get doc() { return this.workspace?.document; },
@@ -72,6 +71,16 @@ document.addEventListener('alpine:init', () => {
}, },
get needsMe() { return this.pending.filter(r=>r.required_approvers.includes(this.user?.id) && !r.approvals.includes(this.user?.id)); }, get needsMe() { return this.pending.filter(r=>r.required_approvers.includes(this.user?.id) && !r.approvals.includes(this.user?.id)); },
get myRequests() { return this.pending.filter(r=>r.requested_by===this.user?.id); }, get myRequests() { return this.pending.filter(r=>r.requested_by===this.user?.id); },
get employeeGrantOptions(){return this.grantOptions.filter(g=>!['manage_members','manage_integration'].includes(g.id));},
canEditUserGroups(u){return !u.sso && !u.bootstrap && u.id!==this.user?.id;},
async loadAdmin(){this.admin=await this.api('/api/admin/overview');await this.loadEvents();},
async loadEvents(){const query=this.eventSearch.trim(),sequence=++this.eventSequence;this.eventsLoading=true;try{const result=await this.api('/api/admin/events'+(query?'?search='+encodeURIComponent(query):''));if(sequence===this.eventSequence){this.events=result.events;this.eventsMore=!!result.more;}}finally{if(sequence===this.eventSequence)this.eventsLoading=false;}},
eventSequence:0,
isFailedLogin(event){return event.action==='login.failed';},
eventLabel(key){return MEGURI_MESSAGES['access_event_key_'+key]?tr('access_event_key_'+key):key;},
eventDetails(event){const details=event.details;if(!details || typeof details!=='object')return [];const order=['reference','code','detail','username','email','subject','issuer','groups'];const keys=[...order.filter(k=>k in details),...Object.keys(details).filter(k=>!order.includes(k))];return keys.map(key=>{const value=details[key];return Array.isArray(value)?{key,chips:value.map(String),empty:!value.length}:{key,value:typeof value==='object' && value!==null?JSON.stringify(value):String(value??'')};});},
useDemoAccount(account){this.loginForm.username=account.username;this.loginForm.password=this.demoLifecycle?.password||'';},
async copyDemoPassword(){try{await navigator.clipboard.writeText(this.demoLifecycle?.password||'');this.toast=tr('access_demo_password_copied');setTimeout(()=>this.toast='',4500);}catch(e){this.loginForm.password=this.demoLifecycle?.password||'';}},
get setupView() { return ['configuration','settings'].includes(this.view); }, get setupView() { return ['configuration','settings'].includes(this.view); },
get coverPeople() { const q=this.coverQuery.toLowerCase(); return this.people.filter(p=>p.id!==this.form.from_user && (this.members.some(m=>m.user.id===p.id) || !p.user_id || this.form.force_mode) && [p.display_name,p.email,p.username].some(v=>v?.toLowerCase().includes(q))); }, get coverPeople() { const q=this.coverQuery.toLowerCase(); return this.people.filter(p=>p.id!==this.form.from_user && (this.members.some(m=>m.user.id===p.id) || !p.user_id || this.form.force_mode) && [p.display_name,p.email,p.username].some(v=>v?.toLowerCase().includes(q))); },
get nextDuty() { const now=Date.now(); return this.calendar.assignments.filter(a=>a.schedule_id===this.scheduleId && a.user_id===this.user?.id && !a.unavailable && Date.parse(a.end)>now).sort((a,b)=>a.start.localeCompare(b.start))[0]; }, get nextDuty() { const now=Date.now(); return this.calendar.assignments.filter(a=>a.schedule_id===this.scheduleId && a.user_id===this.user?.id && !a.unavailable && Date.parse(a.end)>now).sort((a,b)=>a.start.localeCompare(b.start))[0]; },
@@ -81,10 +90,11 @@ document.addEventListener('alpine:init', () => {
get monthTitle() { return new Date((this.startDay || this.today()) + 'T12:00:00Z').toLocaleDateString(this.language, { month: 'long', year: 'numeric', timeZone: 'UTC' }); }, get monthTitle() { return new Date((this.startDay || this.today()) + 'T12:00:00Z').toLocaleDateString(this.language, { month: 'long', year: 'numeric', timeZone: 'UTC' }); },
get nowAssignment() { const now = new Date(); return this.calendar.assignments.find(a => !a.unavailable && a.schedule_id === this.scheduleId && new Date(a.start) <= now && now < new Date(a.end)); }, get nowAssignment() { const now = new Date(); return this.calendar.assignments.find(a => !a.unavailable && a.schedule_id === this.scheduleId && new Date(a.start) <= now && now < new Date(a.end)); },
can(permission) { return (this.detail?.permissions || []).includes(permission); }, can(permission) { return (this.detail?.permissions || []).includes(permission); },
person(id) { return this.people.find(p=>p.id===id)?.display_name || this.members.find(m=>m.user.id===id)?.user.display_name || tr('unknown_employee_f68692'); }, personById(id) { return this.people.find(p=>p.id===id) || this.people.find(p=>p.aliases?.includes(id)); },
person(id) { return this.personById(id)?.display_name || this.members.find(m=>m.user.id===id)?.user.display_name || tr('unknown_employee_f68692'); },
shortName(id) { return this.person(id).split(' ')[0]; }, shortName(id) { return this.person(id).split(' ')[0]; },
initials(id) { return this.person(id).split(' ').map(s => s[0]).slice(0, 2).join(''); }, initials(id) { return this.person(id).split(' ').map(s => s[0]).slice(0, 2).join(''); },
color(id) { return ['teal', 'purple', 'amber', 'blue', 'rose'][Math.max(0, this.people.findIndex(p => p.id === id)) % 5]; }, color(id) { return ['teal', 'purple', 'amber', 'blue', 'rose'][Math.max(0, this.people.findIndex(p => p.id === id || p.aliases?.includes(id))) % 5]; },
scheduleName(id) { return this.doc?.schedules.find(s => s.id === id)?.name || tr('archived_schedule_40f4da'); }, scheduleName(id) { return this.doc?.schedules.find(s => s.id === id)?.name || tr('archived_schedule_40f4da'); },
today() { return this.dayKey(new Date(), this.doc?.timezone || 'UTC'); }, today() { return this.dayKey(new Date(), this.doc?.timezone || 'UTC'); },
dayKey(date, zone = 'UTC') { const p = Object.fromEntries(new Intl.DateTimeFormat('en', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(date).map(p => [p.type, p.value])); return `${p.year}-${p.month}-${p.day}`; }, dayKey(date, zone = 'UTC') { const p = Object.fromEntries(new Intl.DateTimeFormat('en', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(date).map(p => [p.type, p.value])); return `${p.year}-${p.month}-${p.day}`; },
@@ -106,26 +116,29 @@ document.addEventListener('alpine:init', () => {
}, },
async run(task, message = '') { if (this.busy) return; this.busy = true; this.error = ''; try { await task(); if (message) { this.toast = message; setTimeout(() => this.toast = '', 4500); } } catch (e) { this.error = e.message; } finally { this.busy = false; } }, async run(task, message = '') { if (this.busy) return; this.busy = true; this.error = ''; try { await task(); if (message) { this.toast = message; setTimeout(() => this.toast = '', 4500); } } catch (e) { this.error = e.message; } finally { this.busy = false; } },
readNavigation(){const query=new URLSearchParams(location.search);const raw=query.get('view');const view=['schedules','policies','generation'].includes(raw)?'configuration':raw;return {view:view==='site'||this.nav.some(n=>n.id===view)?view:'calendar',workspace:query.get('workspace')||''};}, readNavigation(){const query=new URLSearchParams(location.search);const raw=query.get('view');const view=['schedules','policies','generation'].includes(raw)?'configuration':raw;return {view:this.nav.some(n=>n.id===view)?view:'calendar',workspace:query.get('workspace')||''};},
menuHref(view,workspace=this.selected){const url=new URL(location.href);url.searchParams.set('view',view);if(workspace)url.searchParams.set('workspace',workspace);else url.searchParams.delete('workspace');return url.pathname+url.search+url.hash;}, menuHref(view,workspace=this.selected){const url=new URL(location.href);url.searchParams.set('view',view);if(workspace)url.searchParams.set('workspace',workspace);else url.searchParams.delete('workspace');return url.pathname+url.search+url.hash;},
writeNavigation(replace=false){if(!this.ready || this.navigationRestoring)return;const href=this.menuHref(this.view);if(href!==location.pathname+location.search+location.hash)history[replace?'replaceState':'pushState']({},'',href);}, writeNavigation(replace=false){if(!this.ready || this.navigationRestoring)return;const href=this.menuHref(this.view);if(href!==location.pathname+location.search+location.hash)history[replace?'replaceState':'pushState']({},'',href);},
menuClick(event,view){if(event.button!==0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey)return;event.preventDefault();this.view=view;}, menuClick(event,view){if(event.button!==0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey)return;event.preventDefault();this.view=view;},
async changeWorkspace(){await this.run(async()=>{await this.saveSharedGeneration();this.modal='';this.scheduleId='';this.startDay='';await this.loadWorkspace();});if(this.error && this.workspace)this.selected=this.workspace.id;this.writeNavigation();}, async changeWorkspace(){await this.run(async()=>{await this.saveSharedGeneration();this.modal='';this.scheduleId='';this.startDay='';this.writeNavigation();await this.loadWorkspace();});if(this.error && this.workspace)this.selected=this.workspace.id;this.writeNavigation();},
async restoreNavigation(){const epoch=++this.navigationEpoch;this.navigationRestoring++;try{const route=this.readNavigation();await this.saveSharedGeneration();this.modal='';this.error='';this.view=route.view;if(this.user){const selected=this.directoryState.workspaces.some(t=>t.id===route.workspace)?route.workspace:this.directoryState.workspaces[0]?.id||'';if(selected!==this.selected){this.selected=selected;this.scheduleId='';this.startDay='';await this.loadWorkspace();}}else this.selected=route.workspace;await this.$nextTick();}catch(e){if(epoch===this.navigationEpoch)this.error=e.message;}finally{this.navigationRestoring--;if(!this.navigationRestoring)this.writeNavigation(true);}}, async restoreNavigation(){const epoch=++this.navigationEpoch;this.navigationRestoring++;try{const route=this.readNavigation();await this.saveSharedGeneration();this.modal='';this.error='';this.view=route.view==='instance' && this.user && !this.user.instance_admin?'calendar':route.view;if(this.user){const selected=this.directoryState.workspaces.some(t=>t.id===route.workspace)?route.workspace:this.directoryState.workspaces[0]?.id||'';if(selected!==this.selected){this.selected=selected;this.scheduleId='';this.startDay='';await this.loadWorkspace();}}else this.selected=route.workspace;await this.$nextTick();}catch(e){if(epoch===this.navigationEpoch)this.error=e.message;}finally{this.navigationRestoring--;if(!this.navigationRestoring)this.writeNavigation(true);}},
async init() { async init() {
this.$watch('user',user=>{Alpine.store('locale').language=user?.language || I18n.browser;}); this.$watch('user',user=>{Alpine.store('locale').language=user?.language || I18n.browser;});
this.$watch('form',()=>{if(this.modal==='absence')this.absenceChanged();if(this.modal==='request')this.requestChanged();if(['variable','generation-rule'].includes(this.modal))this.generationChanged();}); this.$watch('form',()=>{if(this.modal==='absence')this.absenceChanged();if(this.modal==='request')this.requestChanged();if(['variable','generation-rule'].includes(this.modal))this.generationChanged();});
this.$watch('draftPeople',()=>{if(this.modal==='absence')this.absenceChanged();}); this.$watch('draftPeople',()=>{if(this.modal==='absence')this.absenceChanged();});
this.$watch('generationDraft',()=>{if(this.generationDraft && this.can('edit_schedules'))this.generationChanged();}); this.$watch('generationDraft',()=>{if(this.generationDraft && this.can('edit_schedules'))this.generationChanged();});
this.$watch('modal',(value,old)=>{if(['variable','generation-rule'].includes(old) && !value){this.bindingEditor=null;this.rulePeopleTarget=null;this.generationChanged();}}); this.$watch('modal',(value,old)=>{if(['variable','generation-rule'].includes(old) && !value){this.bindingEditor=null;this.rulePeopleTarget=null;this.generationChanged();}});
this.$watch('view',v=>{if(v==='site'&&this.siteAdmin)this.loadSite();if(v==='configuration')this.loadGenerationDraft();if(this.setupView)this.setupOpen=true;this.writeNavigation();}); this.$watch('view',v=>{if(v==='instance'){if(this.user?.instance_admin)this.run(()=>this.loadAdmin());}if(v==='configuration')this.loadGenerationDraft();if(this.setupView)this.setupOpen=true;this.writeNavigation();});
this.consumeLoginError();
{const query=new URLSearchParams(location.search);this.eventSearch=(query.get('ref')||query.get('search')||'').slice(0,200);}
const initial=this.readNavigation();this.selected=initial.workspace;this.view=initial.view;window.addEventListener('popstate',()=>this.restoreNavigation()); const initial=this.readNavigation();this.selected=initial.workspace;this.view=initial.view;window.addEventListener('popstate',()=>this.restoreNavigation());
await this.run(async () => { const session = await this.api('/api/session'); this.user = session.user; this.siteAdmin=!!session.site_admin; this.csrf = session.csrf || ''; this.allowSignup = session.allow_signup; this.demo = session.demo;this.demoLifecycle=session.demo_lifecycle; if (this.user) await this.loadWorkspaces(); }); await this.run(async () => { const session = await this.api('/api/session'); this.version = session.version || ''; this.user = session.user; this.csrf = session.csrf || ''; this.auth = session.auth || {oidc:false,password:true}; this.demo = session.demo;this.demoLifecycle=session.demo_lifecycle; if (this.user) { if(this.view==='instance' && !this.user.instance_admin)this.view='calendar'; await this.loadWorkspaces(); if(this.view==='instance')await this.loadAdmin(); } });
if(this.view==='site'&&this.siteAdmin)await this.loadSite();if(this.setupView)this.setupOpen=true;this.ready = true;this.writeNavigation(true);this.$nextTick(()=>{const update=()=>document.documentElement.style.setProperty('--demo-offset',(this.$refs.demoBanner?.getBoundingClientRect().height||0)+'px');new ResizeObserver(update).observe(this.$refs.demoBanner);update();});if(this.view==='configuration')this.loadGenerationDraft(); if(this.setupView)this.setupOpen=true;this.ready = true;this.writeNavigation(true);this.$nextTick(()=>{const update=()=>document.documentElement.style.setProperty('--demo-offset',(this.$refs.demoBanner?.getBoundingClientRect().height||0)+'px');new ResizeObserver(update).observe(this.$refs.demoBanner);update();});if(this.view==='configuration')this.loadGenerationDraft();
setInterval(() => { if (this.user && this.workspace && !this.modal && !this.generationSaving && !this.generationSaveError && !this.busy && !this.draggedRoute && (this.publicationPending() || this.detail?.sync?.publication_busy || Date.now()-this.workspacePollAt>=20000)){this.workspacePollAt=Date.now();this.run(() => this.loadWorkspace());} }, 2000); setInterval(() => { if (this.user && this.workspace && !this.modal && !this.generationSaving && !this.generationSaveError && !this.busy && !this.draggedRoute && (this.publicationPending() || this.detail?.sync?.publication_busy || Date.now()-this.workspacePollAt>=20000)){this.workspacePollAt=Date.now();this.run(() => this.loadWorkspace());} }, 2000);
}, },
async signIn() { await this.run(async () => { const s = await this.api(this.registering ? '/api/register' : '/api/login', 'POST', this.loginForm); this.user = s.user; this.siteAdmin=!!s.site_admin; this.csrf = s.csrf; this.loginForm.password = ''; await this.loadWorkspaces();if(this.view==='site'&&this.siteAdmin)await this.loadSite(); }); }, consumeLoginError(){const url=new URL(location.href),code=url.searchParams.get('login_error');if(code===null)return;const ref=url.searchParams.get('ref')||'';this.loginError={code:['no_access','failed','unavailable'].includes(code)?code:'failed',ref:ref.slice(0,64)};url.searchParams.delete('login_error');url.searchParams.delete('ref');history.replaceState({},'',url.pathname+url.search+url.hash);},
async signOut() { await this.run(async () => { await this.saveSharedGeneration();await this.api('/api/logout', 'POST', {}); this.generationSequence++;clearTimeout(this.generationTimer);this.generationDraft=null;this.generationResult=null;this.generationWorkspace='';this.view='calendar';this.modal='';this.form={};this.user = null;this.siteAdmin=false;this.siteData=null; this.detail = null; this.menu = false; this.selected = '';this.directoryState={workspaces:[]};this.startDay=''; }); }, async signIn() { await this.run(async () => { this.loginError=null; const s = await this.api('/api/login', 'POST', this.loginForm); this.user = s.user; this.csrf = s.csrf; this.loginForm.password = ''; await this.loadWorkspaces(); if(this.view==='instance'){if(this.user.instance_admin)await this.loadAdmin();else this.view='calendar';} }); },
async signOut() { await this.run(async () => { await this.saveSharedGeneration();await this.api('/api/logout', 'POST', {}); this.generationSequence++;clearTimeout(this.generationTimer);this.generationDraft=null;this.generationResult=null;this.generationWorkspace='';this.view='calendar';this.modal='';this.form={};this.user = null; this.admin = null; this.detail = null; this.menu = false; this.selected = '';this.directoryState={workspaces:[]};this.startDay=''; }); },
async loadWorkspaces() { this.directoryState = await this.api('/api/workspaces'); if (!this.directoryState.workspaces.some(t => t.id === this.selected)) this.selected = (this.demo && ['alice','bob','carol'].includes(this.user?.username) ? this.directoryState.workspaces.find(t=>t.name===(this.user.username==='carol'?'Customer Support':'Acme Engineering'))?.id : '') || this.directoryState.workspaces[0]?.id || ''; if (this.selected) await this.loadWorkspace(); else this.detail = null; this.writeNavigation(true); }, async loadWorkspaces() { this.directoryState = await this.api('/api/workspaces'); if (!this.directoryState.workspaces.some(t => t.id === this.selected)) this.selected = (this.demo && ['alice','bob','carol'].includes(this.user?.username) ? this.directoryState.workspaces.find(t=>t.name===(this.user.username==='carol'?'Customer Support':'Acme Engineering'))?.id : '') || this.directoryState.workspaces[0]?.id || ''; if (this.selected) await this.loadWorkspace(); else this.detail = null; this.writeNavigation(true); },
async loadWorkspace() { const selected = this.directoryState.workspaces.find(t => t.id === this.selected); if (!selected) return; const detail = await this.api(`/api/workspaces/${selected.id}`);if(this.selected!==selected.id)return;this.detail=detail; if (!this.doc.schedules.some(s => s.id === this.scheduleId)) this.scheduleId = this.doc.schedules[0]?.id || ''; if (!this.startDay) this.startDay = this.monday(); await this.loadCalendar(); this.loadGenerationDraft(); }, async loadWorkspace() { const selected = this.directoryState.workspaces.find(t => t.id === this.selected); if (!selected) return; const detail = await this.api(`/api/workspaces/${selected.id}`);if(this.selected!==selected.id)return;this.detail=detail; if (!this.doc.schedules.some(s => s.id === this.scheduleId)) this.scheduleId = this.doc.schedules[0]?.id || ''; if (!this.startDay) this.startDay = this.monday(); await this.loadCalendar(); this.loadGenerationDraft(); },
async loadCalendar() { const workspace=this.workspace.id;const calendar=await this.api(this.endpoint(`/calendar?from=${this.startDay}&days=28&schedule=${this.scheduleId}`));if(this.selected===workspace)this.calendar=calendar; }, async loadCalendar() { const workspace=this.workspace.id;const calendar=await this.api(this.endpoint(`/calendar?from=${this.startDay}&days=28&schedule=${this.scheduleId}`));if(this.selected===workspace)this.calendar=calendar; },
@@ -140,8 +153,8 @@ document.addEventListener('alpine:init', () => {
if (type === 'priority') this.form = { id: crypto.randomUUID(), name: '', type: 'priority', handover:'11:00:00', participants: this.members.slice(0,2).map(m=>m.user.id), fallback: this.doc.schedules.find(s => s.type === 'rotation')?.id, ...data }; if (type === 'priority') this.form = { id: crypto.randomUUID(), name: '', type: 'priority', handover:'11:00:00', participants: this.members.slice(0,2).map(m=>m.user.id), fallback: this.doc.schedules.find(s => s.type === 'rotation')?.id, ...data };
if (type === 'absence') {this.absenceResult=null;this.absenceError='';this.absenceRevision=this.workspace.revision;this.showAllAbsence=false;this.form = { id: crypto.randomUUID(), user_id: this.user.id, start: this.today(), end: this.today(), ranges:[],force:false,reason:'',...data };this.absenceChanged();} if (type === 'absence') {this.absenceResult=null;this.absenceError='';this.absenceRevision=this.workspace.revision;this.showAllAbsence=false;this.form = { id: crypto.randomUUID(), user_id: this.user.id, start: this.today(), end: this.today(), ranges:[],force:false,reason:'',...data };this.absenceChanged();}
if (type === 'holiday') this.form = { date: this.today(), name: '', country: this.user.country || 'CY', ...data }; if (type === 'holiday') this.form = { date: this.today(), name: '', country: this.user.country || 'CY', ...data };
if (type === 'member') this.form = { username: '', permissions: ['view', 'request_overrides'], ...data }; if (type === 'member-permissions') this.form = { permissions: [...(this.doc.member_permissions || ['view','request_overrides','edit_schedules'])] };
if (type === 'workspace') this.form = { name: '' }; if (type === 'admin-user') this.form = { username: '', display_name: '', password: '', email: '', groups: [] };
if (type === 'account') this.form = { language:this.user.language || '', display_name: this.user.display_name, country: this.user.country || 'CY', timezone:this.user.timezone || '', current_password: '', password: '' }; if (type === 'account') this.form = { language:this.user.language || '', display_name: this.user.display_name, country: this.user.country || 'CY', timezone:this.user.timezone || '', current_password: '', password: '' };
if (type === 'policy') this.form = { id: crypto.randomUUID(), name: '', steps: [{ delay_minutes: 0, condition: 'if-not-acked', target: `schedule:${this.doc.schedules[0]?.id}` }], repeat: { count: 0, wait_minutes: 0, reset_recipient_states: false, close_alert_after_all: false }, ...data }; if (type === 'policy') this.form = { id: crypto.randomUUID(), name: '', steps: [{ delay_minutes: 0, condition: 'if-not-acked', target: `schedule:${this.doc.schedules[0]?.id}` }], repeat: { count: 0, wait_minutes: 0, reset_recipient_states: false, close_alert_after_all: false }, ...data };
if (type === 'policy' && data.steps) this.form.steps = data.steps.map(s => ({ ...s, target: s.recipient.id ? `${s.recipient.type}:${s.recipient.id}` : s.recipient.type })); if (type === 'policy' && data.steps) this.form.steps = data.steps.map(s => ({ ...s, target: s.recipient.id ? `${s.recipient.type}:${s.recipient.id}` : s.recipient.type }));
@@ -262,13 +275,16 @@ document.addEventListener('alpine:init', () => {
if(['variable','generation-rule'].includes(this.modal)){this.saveGenerationModal();return;} if(['variable','generation-rule'].includes(this.modal)){this.saveGenerationModal();return;}
await this.run(async () => { await this.run(async () => {
const f = copy(this.form); const kind = this.modal; const revision = this.workspace?.revision; const f = copy(this.form); const kind = this.modal; const revision = this.workspace?.revision;
if (kind === 'workspace') { const created=await this.api('/api/workspaces','POST',f);this.selected=created.id; } if (kind === 'account') { const r = await this.api('/api/account', 'PUT', f); this.user = r.user; }
else if (kind === 'account') { const r = await this.api('/api/account', 'PUT', f); this.user = r.user; }
else if(kind==='working') await this.api(this.endpoint('/working-interval'),'PUT',{revision,interval:{...f,weekdays:f.weekdays.map(Number),start:timeValue(f.start),end:timeValue(f.end)}}); else if(kind==='working') await this.api(this.endpoint('/working-interval'),'PUT',{revision,interval:{...f,weekdays:f.weekdays.map(Number),start:timeValue(f.start),end:timeValue(f.end)}});
else if(kind==='holiday-import') { if(!this.canImport)throw new Error(tr('preview_the_csv_and_fix_any_errors_before_importing_2ccc4c'));await this.api(this.endpoint('/holiday-import'),'POST',{revision:this.csvResult.revision,csv:this.csvText}); } else if(kind==='holiday-import') { if(!this.canImport)throw new Error(tr('preview_the_csv_and_fix_any_errors_before_importing_2ccc4c'));await this.api(this.endpoint('/holiday-import'),'POST',{revision:this.csvResult.revision,csv:this.csvText}); }
else if(kind==='remove-schedule') { if(!this.canRemoveSchedule)throw new Error(tr('resolve_the_listed_dependencies_before_removing_this_fa1445'));await this.api(this.endpoint('/schedules/'+f.id+'/remove'),'POST',{revision:this.removal.revision,redirect_to:f.redirect_to || null}); } else if(kind==='remove-schedule') { if(!this.canRemoveSchedule)throw new Error(tr('resolve_the_listed_dependencies_before_removing_this_fa1445'));await this.api(this.endpoint('/schedules/'+f.id+'/remove'),'POST',{revision:this.removal.revision,redirect_to:f.redirect_to || null}); }
else if(kind==='absence') {if(!this.canSaveAbsence)throw new Error(tr('wait_for_a_valid_absence_preview_before_saving_c743bf'));await this.api(this.endpoint('/absences'),'POST',this.absencePayload());} else if(kind==='absence') {if(!this.canSaveAbsence)throw new Error(tr('wait_for_a_valid_absence_preview_before_saving_c743bf'));await this.api(this.endpoint('/absences'),'POST',this.absencePayload());}
else if (kind === 'member') await this.api(this.endpoint('/members'), 'PUT', { revision, ...f }); else if (kind === 'member-permissions') await this.api(this.endpoint('/member-permissions'), 'PUT', { revision, permissions: ['view', ...f.permissions.filter(p=>p!=='view')] });
else if (kind === 'admin-user') { const body={username:f.username,display_name:f.display_name,password:f.password,groups:f.groups};if(f.email.trim())body.email=f.email.trim();await this.api('/api/admin/users','POST',body);await this.loadAdmin(); }
else if (kind === 'admin-groups') { await this.api(`/api/admin/users/${f.id}/groups`,'PUT',{groups:f.groups});await this.loadAdmin(); }
else if (kind === 'admin-password') { await this.api(`/api/admin/users/${f.id}/password`,'POST',{password:f.password});await this.loadAdmin(); }
else if (kind === 'admin-delete') { await this.api(`/api/admin/workspaces/${f.id}`,'DELETE',{confirm:f.confirm});await this.loadAdmin(); }
else if (kind === 'integration') await this.api(this.endpoint('/integration'), 'PUT', { revision, integration: f.provider === 'mock' ? { provider: 'mock' } : {...f,users:Object.fromEntries(Object.entries(f.users).filter(([id,value])=>value?.trim()).map(([id,value])=>[id,value.trim()]))} }); else if (kind === 'integration') await this.api(this.endpoint('/integration'), 'PUT', { revision, integration: f.provider === 'mock' ? { provider: 'mock' } : {...f,users:Object.fromEntries(Object.entries(f.users).filter(([id,value])=>value?.trim()).map(([id,value])=>[id,value.trim()]))} });
else if (kind === 'force') await this.api(this.endpoint(`/requests/${f.id}`), 'POST', { revision, action: 'force', reason: f.reason }); else if (kind === 'force') await this.api(this.endpoint(`/requests/${f.id}`), 'POST', { revision, action: 'force', reason: f.reason });
else { else {
@@ -293,8 +309,9 @@ document.addEventListener('alpine:init', () => {
await this.api(this.endpoint('/config'), 'PUT', { revision, document: doc }); await this.api(this.endpoint('/config'), 'PUT', { revision, document: doc });
} }
await this.loadWorkspaces(); this.modal = ''; this.draftPeople=[]; await this.loadWorkspaces(); this.modal = ''; this.draftPeople=[];
}, force ? tr('replacement_applied_publication_is_queued_89e283') : tr('changes_saved_5ffbaf')); }, force ? tr('replacement_applied_publication_is_queued_89e283') : this.savedMessage(this.modal));
}, },
savedMessage(kind){const key={'admin-user':'access_user_created','admin-groups':'access_groups_saved','admin-password':'access_password_reset_done','admin-delete':'access_workspace_deleted'}[kind];return tr(key||'changes_saved_5ffbaf');},
async decide(request, action) { await this.run(async () => { await this.api(this.endpoint(`/requests/${request.id}`), 'POST', { revision: this.workspace.revision, action }); await this.loadWorkspace(); }, tr('request_updated_c52ad2')); }, async decide(request, action) { await this.run(async () => { await this.api(this.endpoint(`/requests/${request.id}`), 'POST', { revision: this.workspace.revision, action }); await this.loadWorkspace(); }, tr('request_updated_c52ad2')); },
async removeItem(collection, id) { async removeItem(collection, id) {
if(collection==='schedules') { await this.run(async()=>{const result=await this.api(this.endpoint('/schedules/'+id+'/removal'));this.open('remove-schedule',{id,redirect_to:''});this.removal=result;});return; } if(collection==='schedules') { await this.run(async()=>{const result=await this.api(this.endpoint('/schedules/'+id+'/removal'));this.open('remove-schedule',{id,redirect_to:''});this.removal=result;});return; }
+2 -2
View File
@@ -6,7 +6,7 @@
<label><span x-text="t('map_selected')"></span><select :aria-label="t('map_selected')" :value="mapSelected" @change="mapSelect($event.target.value)"><template x-for="n in mapNodes" :key="n.id"><option :value="n.id" x-text="n.title"></option></template></select></label> <label><span x-text="t('map_selected')"></span><select :aria-label="t('map_selected')" :value="mapSelected" @change="mapSelect($event.target.value)"><template x-for="n in mapNodes" :key="n.id"><option :value="n.id" x-text="n.title"></option></template></select></label>
<template x-if="mapCurrent()"><div><h3 x-text="mapCurrent()?.title"></h3><p class="muted" x-text="mapCurrent()?.subtitle"></p><div class="actions"><button x-show="mapCurrent()?.iteration" @click="mapToggle()" x-text="mapExpanded[mapCurrent()?.rule]?t('map_collapse'):t('map_expand')"></button><button @click="mapEdit()" x-text="t('map_edit')"></button></div> <template x-if="mapCurrent()"><div><h3 x-text="mapCurrent()?.title"></h3><p class="muted" x-text="mapCurrent()?.subtitle"></p><div class="actions"><button x-show="mapCurrent()?.iteration" @click="mapToggle()" x-text="mapExpanded[mapCurrent()?.rule]?t('map_collapse'):t('map_expand')"></button><button @click="mapEdit()" x-text="t('map_edit')"></button></div>
<label x-show="mapRows().length"><span x-text="t('map_pass')"></span><select :aria-label="t('map_pass')" x-model="mapRow"><option value="all" x-text="t('map_all')"></option><template x-for="(row,index) in mapRows()" :key="index"><option :value="String(index)" x-text="mapRowLabel(row,index)"></option></template></select></label> <label x-show="mapRows().length"><span x-text="t('map_pass')"></span><select :aria-label="t('map_pass')" x-model="mapRow"><option value="all" x-text="t('map_all')"></option><template x-for="(row,index) in mapRows()" :key="index"><option :value="String(index)" x-text="mapRowLabel(row,index)"></option></template></select></label>
<details class="map-inputs" open><summary x-text="t('map_inputs')"></summary><div class="map-input-content"><template x-if="mapRow==='all'"><div class="map-source-list"><p class="muted" x-show="!mapInputSources().length" x-text="t('map_no_sources')"></p><template x-for="source in mapInputSources()" :key="source.id"><div class="map-source-card"><div class="map-source-title"><span class="badge" x-text="(source.generated?t('generated_symbol')+' ':'')+source.type"></span><button class="map-value-link" type="button" :style="{color:mapColor(source.id)}" @click="mapExplore({rule:mapCurrent()?.rule,binding:source.id.startsWith('v:')?{kind:'variable',variable:source.id.slice(2)}:source.id.startsWith('o:')?{kind:'output',output:source.id.slice(2)}:{kind:'literal',value:source.id.slice(6)},title:source.name,value:source.id.startsWith('v:')?sampleNode(mapSnapshot.payload.variables.find(v=>v.id===source.id.slice(2)).node):source.name},$event)" x-text="source.name"></button><small x-show="source.count>1" x-text="source.count"></small></div><div class="map-source-addresses"><template x-for="address in source.addresses" :key="address"><code class="map-address"><span>{ </span><template x-for="(part,partIndex) in mapAddressParts(address)" :key="partIndex"><span><span x-show="part.dot" x-text="part.text"></span><button x-show="!part.dot" type="button" :style="{color:part.color}" @click="mapExploreAddressPart(address,partIndex,$event)" x-text="part.text"></button></span></template><span> }</span></code></template></div><button class="text" x-show="source.target && source.target!==mapSelected" @click="mapSelect(source.target)" x-text="t('map_view_source')"></button></div></template><p class="muted" x-show="mapRows().length" x-text="t('map_choose_pass_values')"></p></div></template><div x-show="mapRow!=='all'"><p class="muted" x-show="!mapInputSections().length" x-text="t('map_no_inputs')"></p><template x-for="(section,sectionIndex) in mapInputSections()" :key="sectionIndex"><section class="map-input-section"><h4 x-text="section.title"></h4><template x-for="(input,inputIndex) in section.rows" :key="inputIndex"><div class="map-input-field"><div class="map-input-label"><span x-text="input.label"></span><small x-show="input.values.length>1" x-text="t('map_distinct_values',{count:input.values.length})"></small></div><template x-for="(value,valueIndex) in input.values" :key="valueIndex"><div class="map-input-value"><template x-for="(token,tokenIndex) in mapInputTokens(value)" :key="tokenIndex"><div class="map-input-token" :class="{'map-reference':token.kind}"><small x-show="token.path" x-text="token.path"></small><span class="map-reference-kind" x-show="token.kind" x-text="(token.generated?t('generated_symbol')+' ':'')+(token.kind||'')"></span><button type="button" class="map-value-link" :style="{color:mapColor(input.label+':'+token.path)}" @click="mapExplore({rule:mapCurrent()?.rule,row:Number(mapRow),binding:input.binding,title:input.label,value},$event)" x-text="token.text"></button></div></template></div></template></div></template></section></template></div></div></details> <details class="map-inputs" open><summary x-text="t('map_inputs')"></summary><div class="map-input-content"><template x-if="mapRow==='all'"><div class="map-source-list"><p class="muted" x-show="!mapInputSources().length" x-text="t('map_no_sources')"></p><template x-for="source in mapInputSources()" :key="source.id"><div class="map-source-card"><div class="map-source-title"><span class="badge" x-text="(source.generated?t('generated_symbol')+' ':'')+source.type"></span><button class="map-value-link" type="button" :style="{color:mapColor(source.id)}" @click="mapExplore({rule:mapCurrent()?.rule,binding:source.id.startsWith('v:')?{kind:'variable',variable:source.id.slice(2)}:source.id.startsWith('o:')?{kind:'output',output:source.id.slice(2)}:{kind:'literal',value:source.id.slice(6)},title:source.name,value:source.id.startsWith('v:')?sampleNode(mapSnapshot.payload.variables.find(v=>v.id===source.id.slice(2)).node):source.name},$event)" x-text="source.name"></button><small x-show="source.count>1" x-text="source.count"></small></div><div class="map-source-addresses"><template x-for="address in source.addresses" :key="address"><code class="map-address"><span><span x-text="t('symbol_brace_open')"></span> </span><template x-for="(part,partIndex) in mapAddressParts(address)" :key="partIndex"><span><span x-show="part.dot" x-text="part.text"></span><button x-show="!part.dot" type="button" :style="{color:part.color}" @click="mapExploreAddressPart(address,partIndex,$event)" x-text="part.text"></button></span></template><span> <span x-text="t('symbol_brace_close')"></span></span></code></template></div><button class="text" x-show="source.target && source.target!==mapSelected" @click="mapSelect(source.target)" x-text="t('map_view_source')"></button></div></template><p class="muted" x-show="mapRows().length" x-text="t('map_choose_pass_values')"></p></div></template><div x-show="mapRow!=='all'"><p class="muted" x-show="!mapInputSections().length" x-text="t('map_no_inputs')"></p><template x-for="(section,sectionIndex) in mapInputSections()" :key="sectionIndex"><section class="map-input-section"><h4 x-text="section.title"></h4><template x-for="(input,inputIndex) in section.rows" :key="inputIndex"><div class="map-input-field"><div class="map-input-label"><span x-text="input.label"></span><small x-show="input.values.length>1" x-text="t('map_distinct_values',{count:input.values.length})"></small></div><template x-for="(value,valueIndex) in input.values" :key="valueIndex"><div class="map-input-value"><template x-for="(token,tokenIndex) in mapInputTokens(value)" :key="tokenIndex"><div class="map-input-token" :class="{'map-reference':token.kind}"><small x-show="token.path" x-text="token.path"></small><span class="map-reference-kind" x-show="token.kind" x-text="(token.generated?t('generated_symbol')+' ':'')+(token.kind||'')"></span><button type="button" class="map-value-link" :style="{color:mapColor(input.label+':'+token.path)}" @click="mapExplore({rule:mapCurrent()?.rule,row:Number(mapRow),binding:input.binding,title:input.label,value},$event)" x-text="token.text"></button></div></template></div></template></div></template></section></template></div></div></details>
<div class="map-issues"><template x-for="(issue,index) in mapIssues()" :key="index"><p class="validation-error" x-text="l(issue.message)"></p></template></div> <div class="map-issues"><template x-for="(issue,index) in mapIssues()" :key="index"><p class="validation-error" x-text="l(issue.message)"></p></template></div>
<h4 x-text="t(mapCurrent()?.manual?'map_manual_resources':'map_generated_resources',{count:mapResources().length})"></h4><p class="muted" x-show="!mapResources().length" x-text="mapSnapshot?.report.issues.length?t('map_unverified'):t('map_empty')"></p> <h4 x-text="t(mapCurrent()?.manual?'map_manual_resources':'map_generated_resources',{count:mapResources().length})"></h4><p class="muted" x-show="!mapResources().length" x-text="mapSnapshot?.report.issues.length?t('map_unverified'):t('map_empty')"></p>
<template x-for="r in mapResources()" :key="r.id"><details class="map-resource"><summary><span class="badge" x-text="label(r.kind)"></span><strong class="map-resource-name"><template x-for="(part,index) in mapNameParts(r)" :key="index"><button type="button" :style="{color:part.color}" @click.stop.prevent="mapExplore(part.origin,$event)" x-text="part.text"></button></template></strong></summary><div class="map-resource-fields"><template x-for="(field,fieldIndex) in mapResourceFields(r)" :key="fieldIndex"><div class="map-input-field"><div class="map-input-label" x-text="field.label"></div><small class="map-field-hint" x-show="field.hint" x-text="field.hint"></small><template x-for="(token,tokenIndex) in mapInputTokens(field.value)" :key="tokenIndex"><div class="map-input-token" :class="{'map-reference':token.kind}"><small x-show="token.path" x-text="token.path"></small><span class="map-reference-kind" x-show="token.kind" x-text="(token.generated?t('generated_symbol')+' ':'')+(token.kind||'')"></span><button type="button" class="map-value-link" :style="{color:mapColor(r.block+':'+field.key+':'+token.path)}" @click="mapExploreToken(r,field,token,$event)" x-text="token.text"></button></div></template></div></template></div></details></template> <template x-for="r in mapResources()" :key="r.id"><details class="map-resource"><summary><span class="badge" x-text="label(r.kind)"></span><strong class="map-resource-name"><template x-for="(part,index) in mapNameParts(r)" :key="index"><button type="button" :style="{color:part.color}" @click.stop.prevent="mapExplore(part.origin,$event)" x-text="part.text"></button></template></strong></summary><div class="map-resource-fields"><template x-for="(field,fieldIndex) in mapResourceFields(r)" :key="fieldIndex"><div class="map-input-field"><div class="map-input-label" x-text="field.label"></div><small class="map-field-hint" x-show="field.hint" x-text="field.hint"></small><template x-for="(token,tokenIndex) in mapInputTokens(field.value)" :key="tokenIndex"><div class="map-input-token" :class="{'map-reference':token.kind}"><small x-show="token.path" x-text="token.path"></small><span class="map-reference-kind" x-show="token.kind" x-text="(token.generated?t('generated_symbol')+' ':'')+(token.kind||'')"></span><button type="button" class="map-value-link" :style="{color:mapColor(r.block+':'+field.key+':'+token.path)}" @click="mapExploreToken(r,field,token,$event)" x-text="token.text"></button></div></template></div></template></div></details></template>
@@ -16,7 +16,7 @@
<section class="map-probe" popover="manual" x-ref="mapProbe" :style="mapProbeStyle" role="region" :aria-label="t('map_explore')" @keydown.escape.stop.prevent="mapProbeClose()" @click.outside="if(mapProbe &amp;&amp; !$event.target.closest('.map-address,.map-value-link,.map-resource-name'))mapProbeClose()"> <section class="map-probe" popover="manual" x-ref="mapProbe" :style="mapProbeStyle" role="region" :aria-label="t('map_explore')" @keydown.escape.stop.prevent="mapProbeClose()" @click.outside="if(mapProbe &amp;&amp; !$event.target.closest('.map-address,.map-value-link,.map-resource-name'))mapProbeClose()">
<template x-if="mapProbe"><div><div class="section-line"><h3 x-text="t('map_explore')"></h3><button type="button" @click="mapProbeClose()" x-text="t('done_e9b450')"></button></div> <template x-if="mapProbe"><div><div class="section-line"><h3 x-text="t('map_explore')"></h3><button type="button" @click="mapProbeClose()" x-text="t('done_e9b450')"></button></div>
<button type="button" x-show="mapProbeHistory.length" @click="mapProbeBack()" x-text="t('map_explore_back')"></button> <button type="button" x-show="mapProbeHistory.length" @click="mapProbeBack()" x-text="t('map_explore_back')"></button>
<p class="muted" x-text="mapProbe.title"></p><code class="map-address"><span>{ </span><template x-for="(part,index) in mapAddressParts(mapProbeAddress())" :key="index"><span :style="part.dot?{}:{color:part.color}" x-text="part.text"></span></template><span> }</span></code> <p class="muted" x-text="mapProbe.title"></p><code class="map-address"><span><span x-text="t('symbol_brace_open')"></span> </span><template x-for="(part,index) in mapAddressParts(mapProbeAddress())" :key="index"><span :style="part.dot?{}:{color:part.color}" x-text="part.text"></span></template><span> <span x-text="t('symbol_brace_close')"></span></span></code>
<h4 x-text="t('map_source_chain')"></h4><ol class="map-probe-chain"> <h4 x-text="t('map_source_chain')"></h4><ol class="map-probe-chain">
<template x-for="(parent,index) in mapProbeParents()" :key="'parent:'+index"><li><button type="button" @click="mapProbeFollow(parent,$event)" x-text="bindingAddress(parent.binding,0,mapSnapshot.payload.rules.find(r=>r.id===mapProbe.rule)?.iterations||[])"></button></li></template> <template x-for="(parent,index) in mapProbeParents()" :key="'parent:'+index"><li><button type="button" @click="mapProbeFollow(parent,$event)" x-text="bindingAddress(parent.binding,0,mapSnapshot.payload.rules.find(r=>r.id===mapProbe.rule)?.iterations||[])"></button></li></template>
<template x-for="(stage,index) in mapProbeStages()" :key="'stage:'+index"><li><button type="button" :class="{'active':mapProbe.stage===index}" :aria-current="mapProbe.stage===index?'step':null" @click="mapProbe.stage=index" x-text="stage.path.length?stage.path[stage.path.length-1]:bindingAddress({...mapProbe.binding,path:[],prefix:'',suffix:''},0,mapSnapshot.payload.rules.find(r=>r.id===mapProbe.rule)?.iterations||[])"></button></li></template> <template x-for="(stage,index) in mapProbeStages()" :key="'stage:'+index"><li><button type="button" :class="{'active':mapProbe.stage===index}" :aria-current="mapProbe.stage===index?'step':null" @click="mapProbe.stage=index" x-text="stage.path.length?stage.path[stage.path.length-1]:bindingAddress({...mapProbe.binding,path:[],prefix:'',suffix:''},0,mapSnapshot.payload.rules.find(r=>r.id===mapProbe.rule)?.iterations||[])"></button></li></template>
+200 -51
View File
@@ -9,7 +9,6 @@
<script defer src="/assets/cytoscape.js"></script> <script defer src="/assets/cytoscape.js"></script>
<script defer src="/assets/generation-map.js"></script> <script defer src="/assets/generation-map.js"></script>
<script defer src="/assets/generation.js"></script> <script defer src="/assets/generation.js"></script>
<script defer src="/assets/site.js"></script>
<script defer src="/assets/app.js"> <script defer src="/assets/app.js">
</script> </script>
<script defer src="/assets/alpine.js"> <script defer src="/assets/alpine.js">
@@ -34,6 +33,7 @@
<main class="login" x-cloak x-show="ready && !user"> <main class="login" x-cloak x-show="ready && !user">
<div class="login-story"> <div class="login-story">
<a class="brand" href="/"><span x-text="t('symbol_4d82f1')"></span> <span><span x-text="t('meguri_d160e8')"></span></span> <a class="brand" href="/"><span x-text="t('symbol_4d82f1')"></span> <span><span x-text="t('meguri_d160e8')"></span></span>
<small class="app-version" x-show="version" x-text="t('app_version_label',{version})"></small>
</a> </a>
<div> <div>
<span class="eyebrow"><span x-text="t('on_call_in_good_hands_f1cf2d')"></span></span> <span class="eyebrow"><span x-text="t('on_call_in_good_hands_f1cf2d')"></span></span>
@@ -42,27 +42,36 @@
</div> </div>
<span class="quiet"><span x-text="t('plan_here_stay_in_sync_everywhere_70e528')"></span></span> <span class="quiet"><span x-text="t('plan_here_stay_in_sync_everywhere_70e528')"></span></span>
</div> </div>
<form class="login-form" @submit.prevent="signIn"> <div class="login-form">
<span class="eyebrow"><span x-text="t('your_team_s_home_for_on_call_66a895')"></span></span> <span class="eyebrow"><span x-text="t('your_team_s_home_for_on_call_66a895')"></span></span>
<h2 x-text="registering ? t('create_your_account_4046b5') : t('welcome_back_b80783')"></h2> <h2 x-text="t('welcome_back_b80783')"></h2>
<p class="muted"><span x-text="t('make_space_for_a_smoother_handover_44adaf')"></span></p> <p class="muted"><span x-text="t('make_space_for_a_smoother_handover_44adaf')"></span></p>
<div class="login-error" role="alert" x-cloak x-show="loginError"><span x-text="loginError ? t('access_login_error_'+loginError.code) : ''"></span><small x-show="loginError?.ref" x-text="loginError?.ref ? t('access_support_reference',{ref:loginError.ref}) : ''"></small></div>
<a class="button primary sso-button" href="/api/oidc/login" x-cloak x-show="auth.oidc"><span x-text="t('access_sso_button')"></span></a>
<div class="login-divider" x-cloak x-show="auth.oidc && auth.password"><span x-text="t('access_or_divider')"></span></div>
<form class="password-login" @submit.prevent="signIn" x-cloak x-show="auth.password">
<label><span x-text="t('username_84c290')"></span><input autocomplete="username" x-model="loginForm.username" required maxlength="64"> <label><span x-text="t('username_84c290')"></span><input autocomplete="username" x-model="loginForm.username" required maxlength="64">
</label> </label>
<label x-show="registering"><span x-text="t('display_name_c7874a')"></span><input x-model="loginForm.display_name" :required="registering"> <label><span x-text="t('password_8be3c9')"></span><input type="password" autocomplete="current-password" x-model="loginForm.password" required>
</label> </label>
<label x-show="registering"><span x-text="t('account_email')"></span><input type="email" autocomplete="email" x-model="loginForm.email" :required="registering" maxlength="254"></label> <button class="primary" :disabled="busy" x-text="t('sign_in_ada2e9')">
<label><span x-text="t('password_8be3c9')"></span><input type="password" :autocomplete="registering ? 'new-password' : 'current-password'" x-model="loginForm.password" required :minlength="registering ? 12 : 1">
</label>
<button class="primary" :disabled="busy" x-text="registering ? t('create_account_aaf374') : t('sign_in_ada2e9')">
</button> </button>
<button type="button" class="text" x-show="allowSignup" @click="registering=!registering" x-text="registering ? t('already_registered_sign_in_0df05c') : t('new_here_create_an_account_2b2c3f')">
</button>
<div class="demo-note" x-show="demo && !registering"><strong><span x-text="t('try_the_demo_38cfaf')"></span></strong><dl><div><dt><span x-text="t('alice_522b27')"></span></dt><dd><span x-text="t('lead_both_workspaces_variables_rules_ed9a19')"></span></dd></div><div><dt><span x-text="t('bob_48181a')"></span></dt><dd><span x-text="t('engineering_weekly_on_call_swaps_de49e8')"></span></dd></div><div><dt><span x-text="t('carol_28b92b')"></span></dt><dd><span x-text="t('support_daily_shifts_approvals_c9a0d8')"></span></dd></div><div><dt x-text="t('demo_extra_users')"></dt><dd x-text="t('demo_extra_users_hint')"></dd></div></dl><span><span x-text="t('password_for_all_2f1a67')"></span> <code><span x-text="t('meguri_demo_only_0c7b5d')"></span></code></span></div>
</form> </form>
<p class="info-note" x-cloak x-show="!auth.password && !auth.oidc"><span x-text="t('access_no_sign_in_method')"></span></p>
<div class="demo-note" x-cloak x-show="demo && demoLifecycle?.accounts?.length"><strong><span x-text="t('access_demo_accounts')"></span></strong>
<ul class="demo-accounts">
<template x-for="account in demoLifecycle?.accounts || []" :key="account.username">
<li><button type="button" class="text demo-account" :data-demo-account="account.username" :aria-label="t('access_demo_use_account',{username:account.username})" @click="useDemoAccount(account)"><code x-text="account.username"></code><span x-text="t('access_role_'+account.role)"></span></button></li>
</template>
</ul>
<span class="demo-password"><span x-text="t('access_demo_password')"></span> <code data-demo-password x-text="demoLifecycle?.password"></code> <button type="button" class="text" @click="copyDemoPassword()"><span x-text="t('access_demo_copy_password')"></span></button></span>
<small x-text="t('access_demo_restart_hint')"></small></div>
</div>
</main> </main>
<div class="shell" :inert="!!modal" x-cloak x-show="ready && user"> <div class="shell" :inert="!!modal" x-cloak x-show="ready && user">
<aside class="sidebar"> <aside class="sidebar">
<a class="brand" href="/"><span x-text="t('symbol_4d82f1')"></span> <span><span x-text="t('meguri_d160e8')"></span></span> <a class="brand" href="/"><span x-text="t('symbol_4d82f1')"></span> <span><span x-text="t('meguri_d160e8')"></span></span>
<small class="app-version" x-show="version" x-text="t('app_version_label',{version})"></small>
</a> </a>
<span class="eyebrow workspace-label"><span x-text="t('workspace_703988')"></span></span> <span class="eyebrow workspace-label"><span x-text="t('workspace_703988')"></span></span>
<select class="workspace-select" :aria-label="t('team_218887')" x-model="selected" :disabled="busy" @change="changeWorkspace()"> <select class="workspace-select" :aria-label="t('team_218887')" x-model="selected" :disabled="busy" @change="changeWorkspace()">
@@ -85,7 +94,7 @@
</template> </template>
<button class="setup-toggle" :aria-label="t('setup_cdd7bb')" :class="{active:setupView}" @click="setupOpen=!setupOpen" :aria-expanded="setupOpen"><span class="nav-icon"><span x-text="t('symbol_be22a3')"></span></span><span><span x-text="t('setup_cdd7bb')"></span></span><span class="grow"></span><span x-text="setupOpen?'−':'+'"></span></button> <button class="setup-toggle" :aria-label="t('setup_cdd7bb')" :class="{active:setupView}" @click="setupOpen=!setupOpen" :aria-expanded="setupOpen"><span class="nav-icon"><span x-text="t('symbol_be22a3')"></span></span><span><span x-text="t('setup_cdd7bb')"></span></span><span class="grow"></span><span x-text="setupOpen?'−':'+'"></span></button>
<div class="setup-nav" x-show="setupOpen"><template x-for="item in nav.filter(n=>['configuration','settings'].includes(n.id))" :key="item.id"><a :href="menuHref(item.id)" :aria-current="view===item.id?'page':null" :class="{active:view===item.id}" @click="menuClick($event,item.id)" x-text="item.name"></a></template></div> <div class="setup-nav" x-show="setupOpen"><template x-for="item in nav.filter(n=>['configuration','settings'].includes(n.id))" :key="item.id"><a :href="menuHref(item.id)" :aria-current="view===item.id?'page':null" :class="{active:view===item.id}" @click="menuClick($event,item.id)" x-text="item.name"></a></template></div>
<a class="site-nav-link" x-show="siteAdmin" :aria-label="t('site_title')" :href="menuHref('site')" @click="menuClick($event,'site')" :class="{active:view==='site'}"><span class="nav-icon" aria-hidden="true" x-text="t('symbol_be22a3')"></span><span class="site-nav-label" x-text="t('site_title')"></span></a> <a class="instance-link" x-cloak x-show="user?.instance_admin" :href="menuHref('instance')" :aria-current="view==='instance'?'page':null" :class="{active:view==='instance'}" @click="menuClick($event,'instance')"><span aria-hidden="true" class="nav-icon" x-text="nav.find(n=>n.id==='instance').icon"></span><span x-text="nav.find(n=>n.id==='instance').name"></span></a>
</nav> </nav>
<div class="sidebar-bottom"> <div class="sidebar-bottom">
@@ -118,22 +127,102 @@
</span> </span>
<div class="top-actions"> <div class="top-actions">
<span class="display-zone" x-text="t('your_time_31bdbf')+displayZone"></span> <span class="display-zone" x-text="t('your_time_31bdbf')+displayZone"></span>
<button class="text" @click="open('workspace')"><span x-text="t('new_workspace_b21b86')"></span></button>
</div> </div>
</header> </header>
<div class="content"> <div class="content">
<!-- SITE_ADMIN --> <template x-if="view==='instance' && user?.instance_admin">
<template x-if="!workspace && view!=='site'"> <section class="instance-page" id="instance">
<section class="empty welcome"> <div class="page-heading"><div><div class="eyebrow" x-text="t('access_instance_eyebrow')"></div><h1 x-text="t('access_instance_title')"></h1></div></div>
<span class="empty-symbol"><span x-text="t('symbol_4d82f1')"></span></span> <div class="warning-banner" role="alert" x-show="admin?.config.bootstrap_usernames.length"><span x-text="t('access_bootstrap_warning')"></span></div>
<h1><span x-text="t('your_team_s_next_chapter_starts_here_4e986d')"></span></h1> <template x-if="admin">
<p><span x-text="t('create_a_workspace_add_a_team_and_give_your_on_call__af9412')"></span></p> <div>
<button class="primary" @click="open('workspace')" x-text="t('create_a_workspace_e94c64')"> <article class="panel rule-card spaced" id="instance-config">
</button> <span class="eyebrow"><span x-text="t('access_config_title')"></span></span>
<dl class="config-summary">
<div><dt x-text="t('access_config_password_login')"></dt><dd x-text="admin.config.password_login?t('access_enabled'):t('access_disabled')"></dd></div>
<div><dt x-text="t('access_config_oidc')"></dt><dd x-text="admin.config.oidc_issuer || t('access_not_configured')"></dd></div>
<div><dt x-text="t('access_config_admin_groups')"></dt><dd x-text="admin.config.instance_admin_groups.join(', ') || t('access_none')"></dd></div>
<div><dt x-text="t('access_config_bootstrap')"></dt><dd x-text="admin.config.bootstrap_usernames.join(', ') || t('access_none')"></dd></div>
</dl>
<p class="muted"><span x-text="t('access_config_note')"></span></p>
</article>
<div class="section-line"><h2><span x-text="t('access_workspaces_title')"></span></h2></div>
<div class="panel compact" id="instance-workspaces">
<p class="muted padded" x-show="!admin.workspaces.length"><span x-text="t('access_workspaces_empty')"></span></p>
<template x-for="w in admin.workspaces" :key="w.id">
<div class="list-row admin-workspace" :class="{disabled:w.disabled_at}" :data-workspace-key="w.key">
<div class="grow">
<strong x-text="w.name"></strong>
<small x-text="t('access_workspace_key',{key:w.key || '—'})+' · '+t('access_workspace_members',{count:w.member_count})+(w.sync_state?' · '+t('access_workspace_publication',{state:w.sync_state}):'')"></small>
<small x-show="w.in_config"><span x-text="t('access_groups_admin')+': '+(w.admin_groups.join(', ') || t('access_none'))"></span></small>
<small x-show="w.in_config"><span x-text="t('access_groups_member')+': '+(w.member_groups.join(', ') || t('access_none'))"></span></small>
<small class="disabled-note" x-show="w.disabled_at" x-text="t('access_workspace_disabled')+' · '+t('access_workspace_disabled_since',{date:formatTime(w.disabled_at)})"></small>
</div>
<span class="badge" x-show="w.in_config" x-text="t('access_workspace_in_config')"></span>
<button class="danger-button" x-show="w.disabled_at" @click="open('admin-delete',{id:w.id,name:w.name,expected:w.confirm,confirm:''})"><span x-text="t('access_delete_workspace')"></span></button>
</div>
</template>
</div>
<div class="section-line"><h2><span x-text="t('access_events_title')"></span></h2></div>
<article class="panel rule-card spaced" id="instance-events">
<p class="muted"><span x-text="t('access_events_note')"></span></p>
<form class="event-search" @submit.prevent="run(()=>loadEvents())">
<input type="search" x-model="eventSearch" maxlength="200" :aria-label="t('access_events_search_label')" :placeholder="t('access_events_search_placeholder')">
<button class="primary" :disabled="busy"><span x-text="t('access_events_search')"></span></button>
<button type="button" :disabled="busy" @click="run(()=>loadEvents())"><span x-text="t('access_events_refresh')"></span></button>
</form>
<p class="muted padded" data-events-empty x-show="!events.length && !eventsLoading"><span x-text="t('access_events_empty')"></span></p>
<div class="event-list">
<template x-for="e in events" :key="e.id">
<div class="event-row" :class="{failed:isFailedLogin(e)}" :data-event-action="e.action">
<div class="event-head"><code class="event-action" :class="{failed:isFailedLogin(e)}" x-text="e.action"></code><span class="event-actor" x-text="e.actor || t('access_event_system')"></span><small class="event-time" x-text="formatTime(e.created_at)"></small></div>
<dl class="event-details">
<template x-for="d in eventDetails(e)" :key="d.key">
<div><dt x-text="eventLabel(d.key)"></dt><dd><template x-if="d.chips"><span class="chips"><template x-for="chip in d.chips" :key="chip"><code class="chip" x-text="chip"></code></template><small class="muted" x-show="d.empty" x-text="t('access_user_no_groups')"></small></span></template><template x-if="!d.chips"><span x-text="d.value"></span></template></dd></div>
</template>
</dl>
</div>
</template>
</div>
<p class="info-note" data-events-more x-show="eventsMore"><span x-text="t('access_events_more')"></span></p>
</article>
<div class="section-line"><h2><span x-text="t('access_users_title')"></span></h2>
<button class="primary" @click="open('admin-user')"><span x-text="t('access_add_user')"></span></button>
</div>
<div class="panel compact" id="instance-users">
<p class="muted padded" x-show="!admin.users.length"><span x-text="t('access_users_empty')"></span></p>
<template x-for="u in admin.users" :key="u.id">
<div class="list-row admin-user" :data-username="u.username">
<span class="avatar" x-text="u.display_name.slice(0,1)"></span>
<div class="grow">
<strong x-text="u.display_name"></strong> <small class="inline" x-show="u.id===user.id"><span x-text="t('you_8af56d')"></span></small>
<small x-text="'@'+u.username+(u.email?' · '+u.email:'')"></small>
<small x-text="t('access_user_groups')+': '+(u.groups.join(', ') || t('access_user_no_groups'))"></small>
<small class="muted" x-show="u.sso"><span x-text="t('access_groups_sso_readonly')"></span></small>
<small class="muted" x-show="u.bootstrap"><span x-text="t('access_groups_bootstrap_readonly')"></span></small>
</div>
<span class="badge" x-show="u.instance_admin" x-text="t('access_role_instance_admin')"></span>
<span class="badge" x-show="u.sso" x-text="t('access_badge_sso')"></span>
<span class="badge" x-show="u.bootstrap" x-text="t('access_badge_bootstrap')"></span>
<span class="badge" x-show="!u.sso && !u.bootstrap" x-text="t('access_badge_password')"></span>
<button x-show="canEditUserGroups(u)" @click="open('admin-groups',{id:u.id,username:u.username,groups:[...u.groups]})"><span x-text="t('access_edit_groups')"></span></button>
<button x-show="!u.sso && !u.bootstrap" @click="open('admin-password',{id:u.id,username:u.username,password:''})"><span x-text="t('access_reset_password')"></span></button>
</div>
</template>
</div>
</div>
</template>
</section> </section>
</template> </template>
<template x-if="workspace && view!=='site'"> <template x-if="view!=='instance' && !workspace">
<section class="empty welcome">
<span class="empty-symbol"><span x-text="t('symbol_4d82f1')"></span></span>
<h1><span x-text="t('access_no_workspace_title')"></span></h1>
<p x-text="user?.instance_admin?t('access_no_workspace_admin_text'):t('access_no_workspace_text')"></p>
<a class="button primary" x-show="user?.instance_admin" :href="menuHref('instance')" @click="menuClick($event,'instance')"><span x-text="t('access_open_instance_page')"></span></a>
</section>
</template>
<template x-if="view!=='instance' && workspace">
<div> <div>
<div class="page-heading"> <div class="page-heading">
<div> <div>
@@ -327,7 +416,7 @@
</p> </p>
<p x-text="t('planning_horizon_b249cc')+doc.horizon_days+t('days_e72d54')"> <p x-text="t('planning_horizon_b249cc')+doc.horizon_days+t('days_e72d54')">
</p> </p>
<button x-show="can('edit_schedules')" @click="open('preferences')"><span x-text="t('edit_preferences_4cefcb')"></span></button> <button x-show="detail?.is_owner" @click="open('preferences')"><span x-text="t('edit_preferences_4cefcb')"></span></button>
</article> </article>
<article class="panel rule-card"> <article class="panel rule-card">
<span class="eyebrow"><span x-text="t('connected_provider_f22440')"></span></span> <span class="eyebrow"><span x-text="t('connected_provider_f22440')"></span></span>
@@ -342,23 +431,43 @@
</div> </div>
<div class="section-line"> <div class="section-line">
<h2><span x-text="t('team_access_8fb357')"></span></h2> <h2><span x-text="t('team_access_8fb357')"></span></h2>
<button class="primary" x-show="can('manage_members')" @click="open('member')"><span x-text="t('add_teammate_d6bc5a')"></span></button>
</div> </div>
<div class="panel compact"> <p class="info-note" x-show="detail?.instance_admin && !members.some(m=>m.user.id===user?.id)"><span x-text="t('access_instance_admin_note')"></span></p>
<div class="panel compact" id="workspace-members">
<template x-for="m in members" :key="m.user?.id"> <template x-for="m in members" :key="m.user?.id">
<div class="list-row"> <div class="list-row" :data-username="m.user.username">
<span class="avatar" :class="color(m.user?.id)" x-text="initials(m.user?.id)"> <span class="avatar" :class="color(m.user?.id)" x-text="initials(m.user?.id)">
</span> </span>
<div class="grow"> <div class="grow">
<strong x-text="m.user.display_name"> <strong x-text="m.user.display_name">
</strong> </strong>
<small x-text="'@'+m.user.username+' · '+m.permissions.length+t('permissions_607d39')"> <small x-text="'@'+m.user.username">
</small> </small>
</div> </div>
<button x-show="can('manage_members')" @click="open('member',{username:m.user.username,permissions:m.permissions})"><span x-text="t('edit_access_e381a8')"></span></button> <span class="badge role-badge" :class="{admin:m.is_owner}" x-text="m.is_owner?t('access_role_workspace_admin'):t('access_role_employee')"></span>
</div> </div>
</template> </template>
</div> </div>
<p class="muted"><span x-text="t('access_members_hint')"></span></p>
<article class="panel rule-card spaced" id="employee-permissions">
<span class="eyebrow"><span x-text="t('access_employee_permissions_title')"></span></span>
<p class="muted"><span x-text="t('access_employee_permissions_hint')"></span></p>
<ul class="permission-list">
<template x-for="grant in employeeGrantOptions" :key="grant.id">
<li :class="{on:(doc.member_permissions||[]).includes(grant.id)}" :data-permission="grant.id"><span aria-hidden="true" x-text="(doc.member_permissions||[]).includes(grant.id)?'✓':'·'"></span> <span x-text="grant.name"></span></li>
</template>
</ul>
<p class="info-note" x-text="(doc.member_permissions||[]).includes('force_overrides')?t('access_force_enabled'):t('access_force_disabled')"></p>
<div class="actions" x-show="can('manage_members')"><button @click="open('member-permissions')"><span x-text="t('access_edit_employee_permissions')"></span></button></div>
</article>
<article class="panel rule-card spaced" id="access-groups" x-show="detail?.access">
<span class="eyebrow"><span x-text="t('access_groups_title')"></span></span>
<dl class="config-summary">
<div><dt x-text="t('access_groups_admin')"></dt><dd x-text="(detail?.access?.admin_groups||[]).join(', ') || t('access_none')"></dd></div>
<div><dt x-text="t('access_groups_member')"></dt><dd x-text="(detail?.access?.member_groups||[]).join(', ') || t('access_none')"></dd></div>
</dl>
<p class="muted"><span x-text="t('access_groups_hint')"></span></p>
</article>
<div class="section-line"> <div class="section-line">
<h2><span x-text="t('scheduling_directory_e8fdd4')"></span></h2> <h2><span x-text="t('scheduling_directory_e8fdd4')"></span></h2>
<span class="muted"><span x-text="t('country_controls_public_holidays_defaults_to_cyprus_e828a2')"></span></span> <span class="muted"><span x-text="t('country_controls_public_holidays_defaults_to_cyprus_e828a2')"></span></span>
@@ -374,7 +483,7 @@
<small x-text="(p.email || (p.username?'@'+p.username:t('no_email_configured_b309ce')))+' · '+countryName(p.country)+' · '+(p.user_id?t('workspace_user_95981a'):t('email_contact_e9e0b3'))"> <small x-text="(p.email || (p.username?'@'+p.username:t('no_email_configured_b309ce')))+' · '+countryName(p.country)+' · '+(p.user_id?t('workspace_user_95981a'):t('email_contact_e9e0b3'))">
</small> </small>
</div> </div>
<button x-show="can('edit_schedules')" @click="open('person',p)"><span x-text="t('edit_person_d98516')"></span></button> <button x-show="detail?.is_owner" @click="open('person',p)"><span x-text="t('edit_person_d98516')"></span></button>
</div> </div>
</template> </template>
</div> </div>
@@ -409,8 +518,8 @@
<form @submit.prevent="save()"> <form @submit.prevent="save()">
<div class="modal-heading"> <div class="modal-heading">
<div> <div>
<span class="eyebrow" x-show="modal!=='workspace'" x-text="['request','day','force'].includes(modal)?scheduleName(form.schedule_id || scheduleId):doc?.name || 'MEGURI'"></span> <span class="eyebrow" x-text="modal.startsWith('admin-')?t('access_instance_eyebrow'):['request','day','force'].includes(modal)?scheduleName(form.schedule_id || scheduleId):doc?.name || 'MEGURI'"></span>
<h2 id="dialog-title" x-text="modal==='request'?(form.force_mode?t('apply_without_approval_d72926'):form.from_user===user?.id?t('who_can_cover_for_you_6e894c'):t('cover_person',{name:shortName(form.from_user)})):modal==='day'?t('choose_a_shift_330348'):({'generation-transfer':t('transfer_title'),variable:t('workspace_variable_7abc57'),'generation-rule':t('generation_rule_d34ccf'),rotation:t('rotation_906fe5'),priority:t('ordered_project_coverage_cf12c3'),coverage:t('project_coverage_536388'),request:t('request_a_change_3d465f'),force:t('apply_without_confirmation_f0350d'),absence:t('planned_absence_bfcea7'),holiday:t('public_holiday_49ec03'),'holiday-import':t('import_public_holidays_8098e3'),'remove-schedule':t('remove_schedule_ef0a8a'),person:t('scheduling_person_1273c6'),working:t('workspace_working_interval_c7d250'),member:t('team_access_8fb357'),workspace:t('new_workspace_b21b86'),account:t('your_account_4ab291'),policy:t('escalation_policy_bbb0f7'),route:t('routing_rule_b00b22'),integration:t('connected_provider_9702c0'),preferences:t('team_preferences_7fcab5')})[modal]"> <h2 id="dialog-title" x-text="modal==='request'?(form.force_mode?t('apply_without_approval_d72926'):form.from_user===user?.id?t('who_can_cover_for_you_6e894c'):t('cover_person',{name:shortName(form.from_user)})):modal==='day'?t('choose_a_shift_330348'):({'generation-transfer':t('transfer_title'),variable:t('workspace_variable_7abc57'),'generation-rule':t('generation_rule_d34ccf'),rotation:t('rotation_906fe5'),priority:t('ordered_project_coverage_cf12c3'),coverage:t('project_coverage_536388'),request:t('request_a_change_3d465f'),force:t('apply_without_confirmation_f0350d'),absence:t('planned_absence_bfcea7'),holiday:t('public_holiday_49ec03'),'holiday-import':t('import_public_holidays_8098e3'),'remove-schedule':t('remove_schedule_ef0a8a'),person:t('scheduling_person_1273c6'),working:t('workspace_working_interval_c7d250'),'member-permissions':t('access_employee_permissions_title'),'admin-user':t('access_new_user_title'),'admin-groups':t('access_edit_groups_title'),'admin-password':t('access_reset_password_title'),'admin-delete':t('access_delete_workspace_title'),account:t('your_account_4ab291'),policy:t('escalation_policy_bbb0f7'),route:t('routing_rule_b00b22'),integration:t('connected_provider_9702c0'),preferences:t('team_preferences_7fcab5')})[modal]">
</h2> </h2>
</div> </div>
<button type="button" class="icon-button" @click="modal=''" :aria-label="t('close_dialog_7b2902')"><span x-text="t('symbol_67fba2')"></span></button> <button type="button" class="icon-button" @click="modal=''" :aria-label="t('close_dialog_7b2902')"><span x-text="t('symbol_67fba2')"></span></button>
@@ -474,17 +583,10 @@
</div> </div>
</div> </div>
</template><div x-show="!picker"> </template><div x-show="!picker">
<template x-if="modal==='workspace'">
<div>
<label><span x-text="t('name_709a23')"></span><input x-model="form.name" required maxlength="100">
</label>
</div>
</template>
<template x-if="modal==='account'"> <template x-if="modal==='account'">
<div> <div>
<label><span x-text="t('language')"></span><select x-model="form.language" :aria-label="t('language')"><option value="" x-text="t('language_auto')"></option><template x-for="code in I18n.supported"><option :value="code" :selected="form.language===code" x-text="t('language_'+code)"></option></template></select></label> <label><span x-text="t('language')"></span><select x-model="form.language" :aria-label="t('language')"><option value="" x-text="t('language_auto')"></option><template x-for="code in I18n.supported"><option :value="code" :selected="form.language===code" x-text="t('language_'+code)"></option></template></select></label>
<label><span x-text="t('account_email')"></span><input type="email" :value="user.email" readonly></label> <label x-show="user?.email"><span x-text="t('account_email')"></span><input type="email" :value="user?.email" readonly></label>
<label><span x-text="t('display_name_c7874a')"></span><input x-model="form.display_name" required> <label><span x-text="t('display_name_c7874a')"></span><input x-model="form.display_name" required>
</label> </label>
<label><span x-text="t('country_of_work_88453b')"></span><select :aria-label="t('country_of_work_88453b')" x-model="form.country"> <label><span x-text="t('country_of_work_88453b')"></span><select :aria-label="t('country_of_work_88453b')" x-model="form.country">
@@ -500,10 +602,13 @@
<p class="info-note" x-text="t('dates_and_timestamps_are_displayed_in_77ec74')+(form.timezone || browserZone)+t('this_never_changes_the_workspace_timezone_or_shifts_1de864')"> <p class="info-note" x-text="t('dates_and_timestamps_are_displayed_in_77ec74')+(form.timezone || browserZone)+t('this_never_changes_the_workspace_timezone_or_shifts_1de864')">
</p> </p>
<p class="muted"><span x-text="t('country_changes_update_your_holiday_calendar_in_team_1e4eb8')"></span></p> <p class="muted"><span x-text="t('country_changes_update_your_holiday_calendar_in_team_1e4eb8')"></span></p>
<template x-if="user?.has_password"><div>
<label><span x-text="t('current_password_19dff4')"></span><input type="password" autocomplete="current-password" x-model="form.current_password"> <label><span x-text="t('current_password_19dff4')"></span><input type="password" autocomplete="current-password" x-model="form.current_password">
</label> </label>
<label><span x-text="t('new_password_d850ee')"></span><input type="password" autocomplete="new-password" minlength="12" x-model="form.password"> <label><span x-text="t('new_password_d850ee')"></span><input type="password" autocomplete="new-password" minlength="12" x-model="form.password">
</label> </label>
</div></template>
<p class="muted" x-show="!user?.has_password"><span x-text="t('access_sso_password_note')"></span></p>
</div> </div>
</template> </template>
<template x-if="modal==='person'"> <template x-if="modal==='person'">
@@ -567,10 +672,10 @@
<div class="grow"> <div class="grow">
<strong x-text="person(id)"> <strong x-text="person(id)">
</strong> </strong>
<small x-text="countryName(people.find(p=>p.id===id)?.country)"> <small x-text="countryName(personById(id)?.country)">
</small> </small>
</div> </div>
<button type="button" @click="editPerson(id)" :aria-label="t('edit_participant_details_73e491')"><span x-text="t('details_dc3dec')"></span></button> <button type="button" x-show="detail?.is_owner" @click="editPerson(id)" :aria-label="t('edit_participant_details_73e491')"><span x-text="t('details_dc3dec')"></span></button>
<button type="button" @click="moveParticipant(index,-1)" :aria-label="t('move_earlier_07dd6f')"><span x-text="t('symbol_9555ff')"></span></button> <button type="button" @click="moveParticipant(index,-1)" :aria-label="t('move_earlier_07dd6f')"><span x-text="t('symbol_9555ff')"></span></button>
<button type="button" @click="moveParticipant(index,1)" :aria-label="t('move_later_bb4c84')"><span x-text="t('symbol_fc4866')"></span></button> <button type="button" @click="moveParticipant(index,1)" :aria-label="t('move_later_bb4c84')"><span x-text="t('symbol_fc4866')"></span></button>
<button type="button" @click="form.participants.splice(index,1)" :aria-label="t('remove_participant_131344')"><span x-text="t('symbol_67fba2')"></span></button> <button type="button" @click="form.participants.splice(index,1)" :aria-label="t('remove_participant_131344')"><span x-text="t('symbol_67fba2')"></span></button>
@@ -740,18 +845,62 @@
</div> </div>
</div> </div>
</template> </template>
<template x-if="modal==='member'"> <template x-if="modal==='member-permissions'">
<div> <div>
<label><span x-text="t('registered_username_e29aa9')"></span><input x-model="form.username" required> <p class="muted"><span x-text="t('access_employee_permissions_hint')"></span></p>
</label> <template x-for="grant in employeeGrantOptions" :key="grant.id">
<p class="muted"><span x-text="t('your_teammate_needs_an_account_before_you_can_add_th_80fa32')"></span></p>
<template x-for="grant in grantOptions">
<label class="check"> <label class="check">
<input type="checkbox" :value="grant.id" x-model="form.permissions" :disabled="grant.id==='view'"> <input type="checkbox" :value="grant.id" x-model="form.permissions" :disabled="grant.id==='view'" :data-permission="grant.id">
<span x-text="grant.name"> <span x-text="grant.name"></span>
</span>
</label> </label>
</template> </template>
<p class="info-note" x-text="t('access_force_hint')"></p>
</div>
</template>
<template x-if="modal==='admin-user'">
<div>
<label><span x-text="t('username_84c290')"></span><input x-model="form.username" autocomplete="off" required minlength="3" maxlength="64">
</label>
<label><span x-text="t('display_name_c7874a')"></span><input x-model="form.display_name" required>
</label>
<label><span x-text="t('access_email_optional')"></span><input type="email" x-model="form.email" autocomplete="off">
</label>
<label><span x-text="t('password_8be3c9')"></span><input type="password" x-model="form.password" autocomplete="new-password" required minlength="12" maxlength="256">
</label>
<p class="muted"><span x-text="t('access_password_hint')"></span></p>
<fieldset class="group-picker"><legend x-text="t('access_user_groups')"></legend>
<p class="muted" x-show="!admin?.known_groups.length"><span x-text="t('access_no_known_groups')"></span></p>
<template x-for="group in admin?.known_groups || []" :key="group">
<label class="check"><input type="checkbox" :value="group" x-model="form.groups"><span x-text="group"></span></label>
</template>
</fieldset>
</div>
</template>
<template x-if="modal==='admin-groups'">
<div>
<p class="muted"><span x-text="t('access_groups_picker_hint')"></span></p>
<fieldset class="group-picker"><legend x-text="'@'+form.username"></legend>
<p class="muted" x-show="!admin?.known_groups.length"><span x-text="t('access_no_known_groups')"></span></p>
<template x-for="group in admin?.known_groups || []" :key="group">
<label class="check"><input type="checkbox" :value="group" x-model="form.groups"><span x-text="group"></span></label>
</template>
</fieldset>
</div>
</template>
<template x-if="modal==='admin-password'">
<div>
<p class="muted" x-text="'@'+form.username"></p>
<label><span x-text="t('new_password_d850ee')"></span><input type="password" x-model="form.password" autocomplete="new-password" required minlength="12" maxlength="256">
</label>
<p class="muted"><span x-text="t('access_password_hint')"></span></p>
</div>
</template>
<template x-if="modal==='admin-delete'">
<div>
<p class="info-note danger-note"><span x-text="t('access_delete_warning')"></span></p>
<p><strong x-text="form.name"></strong></p>
<label><span x-text="t('access_delete_confirm_label',{confirm:form.expected})"></span><input x-model="form.confirm" autocomplete="off" required>
</label>
</div> </div>
</template> </template>
<template x-if="modal==='preferences'"> <template x-if="modal==='preferences'">
@@ -923,7 +1072,7 @@
<button type="button" @click="modal=''"><span x-text="t('cancel_77dfd2')"></span></button> <button type="button" @click="modal=''"><span x-text="t('cancel_77dfd2')"></span></button>
<button type="button" class="primary" x-show="rulePeopleTarget" @click="closeRuleEditor()" x-text="t('done_e9b450')"></button> <button type="button" class="primary" x-show="rulePeopleTarget" @click="closeRuleEditor()" x-text="t('done_e9b450')"></button>
<button x-show="!rulePeopleTarget" class="primary" :class="{'remove-confirm':modal==='remove-schedule'}" :disabled="busy || (modal==='holiday-import' && !canImport) || (modal==='absence' && !canSaveAbsence) || (modal==='remove-schedule' && !canRemoveSchedule)" x-text="busy?t('saving_56a228'):modal==='remove-schedule'?(form.redirect_to?t('remove_redirect_references_f1a241'):removal?.plan.escalation_steps.length?t('remove_schedule_affected_steps_7348af'):t('remove_schedule_ef0a8a')):modal==='holiday-import'?t('import_new_holidays_b36db6'):modal==='request'?(requestResult?.applies_immediately?t('apply_replacement_9b0845'):t('send_for_confirmation_f3df0f')):modal==='force'?t('apply_replacement_9b0845'):['variable','generation-rule'].includes(modal)?t('use_in_draft_a8f78c'):t('save_changes_179359')"> <button x-show="!rulePeopleTarget" class="primary" :class="{'remove-confirm':modal==='remove-schedule'}" :disabled="busy || (modal==='holiday-import' && !canImport) || (modal==='absence' && !canSaveAbsence) || (modal==='remove-schedule' && !canRemoveSchedule) || (modal==='admin-delete' && form.confirm!==form.expected)" x-text="busy?t('saving_56a228'):modal==='admin-user'?t('access_create_user'):modal==='admin-password'?t('access_reset_password'):modal==='admin-delete'?t('access_delete_permanently'):modal==='remove-schedule'?(form.redirect_to?t('remove_redirect_references_f1a241'):removal?.plan.escalation_steps.length?t('remove_schedule_affected_steps_7348af'):t('remove_schedule_ef0a8a')):modal==='holiday-import'?t('import_new_holidays_b36db6'):modal==='request'?(requestResult?.applies_immediately?t('apply_replacement_9b0845'):t('send_for_confirmation_f3df0f')):modal==='force'?t('apply_replacement_9b0845'):['variable','generation-rule'].includes(modal)?t('use_in_draft_a8f78c'):t('save_changes_179359')">
</button> </button>
</div> </div>
</div> </div>
File diff suppressed because it is too large. Load diff
-24
View File
@@ -1,24 +0,0 @@
<section class="site-admin" x-show="view==='site'" x-cloak>
<div class="page-heading"><div><div class="eyebrow" x-text="t('site_scope')"></div><h1 x-text="t('site_title')"></h1><p class="muted" x-text="t('site_scope_hint')"></p></div><button type="button" @click="loadSite()" :disabled="siteLoading||busy" x-text="t('site_refresh')"></button></div>
<p class="info-note" x-show="!siteAdmin" x-text="t('site_denied')"></p>
<template x-if="siteAdmin && siteData"><div class="site-layout">
<nav class="site-tabs" :aria-label="t('site_sections')"><template x-for="section in siteSections()" :key="section.id"><button type="button" :class="{active:siteSection===section.id}" :aria-current="siteSection===section.id?'page':null" @click="siteTab(section.id)" x-text="section.label"></button></template></nav>
<div class="site-body">
<label x-show="['users','workspaces'].includes(siteSection)"><span x-text="t('site_search')"></span><input x-model="siteSearch" type="search"></label>
<section x-show="siteSection==='users'"><h2 x-text="t('site_users')"></h2><div class="site-list"><template x-for="row in siteUsers()" :key="row.user.id"><article class="site-row"><div><strong x-text="row.user.display_name"></strong><div class="muted" x-text="row.user.username"></div><div class="muted" x-text="row.user.email"></div><span class="badge" x-show="row.access.administrator" x-text="t('site_administrator')"></span><span class="badge" x-show="row.access.disabled" x-text="t('site_disabled')"></span></div><button @click="editSiteUser(row)" x-text="t('site_edit')"></button></article></template></div></section>
<section x-show="siteSection==='workspaces'"><h2 x-text="t('site_workspaces')"></h2><div class="site-list"><template x-for="row in siteWorkspaces()" :key="row.id"><article class="site-row"><div><strong x-text="row.name"></strong><div class="muted"><span x-text="t('site_owners')"></span>: <span x-text="row.owners.map(id=>siteUserName(id)).join(', ')"></span></div></div><button @click="editSiteWorkspace(row)" x-text="t('site_edit')"></button></article></template></div></section>
<form class="site-editor" x-show="siteEdit" @submit.prevent="saveSiteEdit()"><h3 x-text="t('site_edit')"></h3>
<template x-if="siteEdit?.kind==='user'"><div><label><span x-text="t('display_name_c7874a')"></span><input x-model="siteEdit.display_name" required maxlength="120"></label><label class="check"><input type="checkbox" x-model="siteEdit.administrator"><span x-text="t('site_administrator')"></span></label><label class="check"><input type="checkbox" x-model="siteEdit.disabled"><span x-text="t('site_disabled')"></span></label><p class="muted" x-text="t('site_suspend_hint')"></p></div></template>
<template x-if="siteEdit?.kind==='workspace'"><div><label><span x-text="t('site_name')"></span><input x-model="siteEdit.name" required maxlength="120"></label><label><span x-text="t('site_add_owner')"></span><select x-model="siteEdit.owner_id" :aria-label="t('site_add_owner')" required><template x-for="row in siteData.users.filter(x=>!x.access.disabled)" :key="row.user.id"><option :value="row.user.id" x-text="row.user.username"></option></template></select></label><p class="muted" x-text="t('site_owner_hint')"></p></div></template>
<div class="actions"><button type="button" @click="siteEdit=null" x-text="t('site_cancel')"></button><button class="primary" :disabled="busy" x-text="t('site_save')"></button></div>
</form>
<section x-show="siteSection==='authentication'"><h2 x-text="t('site_authentication')"></h2><p class="info-note" x-text="t('site_auth_draft')"></p>
<form @submit.prevent="saveSiteAuth()" class="site-settings-form"><fieldset><legend x-text="t('site_login_methods')"></legend><label class="check"><input type="checkbox" x-model="siteAuth.password_enabled"><span x-text="t('site_password_enabled')"></span></label><label class="check"><input type="checkbox" x-model="siteAuth.oidc_enabled"><span x-text="t('site_oidc_enabled')"></span></label></fieldset>
<fieldset><legend x-text="t('site_oidc_provider')"></legend><label><span x-text="t('site_issuer')"></span><input type="url" x-model="siteAuth.issuer" :required="siteAuth.oidc_enabled" maxlength="2048"></label><p class="muted" x-text="t('site_issuer_hint')"></p><label><span x-text="t('site_client_id')"></span><input x-model="siteAuth.client_id" :required="siteAuth.oidc_enabled" maxlength="500"></label><label><span x-text="t('site_secret_env')"></span><input x-model="siteAuth.client_secret_env" required pattern="MEGURI_OIDC_[A-Z0-9_]+"></label><p class="muted" x-text="t('site_secret_hint')"></p></fieldset>
<fieldset><legend x-text="t('site_login_experience')"></legend><label><span x-text="t('site_button_label')"></span><input x-model="siteAuth.button_label" :required="siteAuth.oidc_enabled" maxlength="80"></label><label class="check"><input type="checkbox" x-model="siteAuth.allow_new_users"><span x-text="t('site_new_accounts')"></span></label><p class="muted" x-text="t('site_new_accounts_hint')"></p></fieldset><button class="primary" :disabled="busy" x-text="t('site_save_draft')"></button>
</form>
</section>
<section x-show="siteSection==='audit'"><h2 x-text="t('site_audit')"></h2><template x-for="event in siteData.audit" :key="event.id"><article class="site-audit-row"><strong x-text="t('site_action_'+event.action.split('.').at(-1))"></strong><span x-text="event.actor_id?siteUserName(event.actor_id):t('site_deployment')"></span><time x-text="formatTime(event.created_at)"></time><pre x-text="JSON.stringify(event.details,null,2)"></pre></article></template></section>
</div>
</div></template>
</section>
-14
View File
@@ -1,14 +0,0 @@
// Installation settings have independent sections and explicit save boundaries.
function siteUi(){return {
siteAdmin:false,siteData:null,siteSection:'users',siteSearch:'',siteEdit:null,siteAuth:null,siteLoading:false,
siteSections(){return ['users','workspaces','authentication','audit'].map(id=>({id,label:tr('site_'+id)}));},
siteUsers(){const q=this.siteSearch.toLowerCase();return (this.siteData?.users||[]).filter(x=>(x.user.username+' '+x.user.display_name+' '+x.user.email).toLowerCase().includes(q));},
siteWorkspaces(){const q=this.siteSearch.toLowerCase();return (this.siteData?.workspaces||[]).filter(x=>x.name.toLowerCase().includes(q));},
siteUserName(id){return this.siteData?.users.find(x=>x.user.id===id)?.user.username||id;},
async loadSite(){if(!this.siteAdmin||this.siteLoading)return;this.siteLoading=true;try{this.siteData=await this.api('/api/site');this.siteAuth=copy(this.siteData.settings.authentication.value);}catch(e){this.error=e.message;}finally{this.siteLoading=false;}},
siteTab(id){this.siteSection=id;this.siteSearch='';this.siteEdit=null;},
editSiteUser(row){this.siteEdit={kind:'user',id:row.user.id,display_name:row.user.display_name,administrator:row.access.administrator,disabled:row.access.disabled};},
editSiteWorkspace(row){this.siteEdit={kind:'workspace',id:row.id,name:row.name,workspace_revision:row.revision,owner_id:row.owners[0]||''};},
async saveSiteEdit(){const edit=copy(this.siteEdit);if(!confirm(tr('site_confirm')))return;await this.run(async()=>{await this.api('/api/site/'+edit.kind,'PUT',{...edit,revision:this.siteData.revision});this.siteEdit=null;const session=await this.api('/api/session');this.siteAdmin=!!session.site_admin;this.user=session.user;if(this.siteAdmin)await this.loadSite();else{this.siteData=null;this.view='calendar';}await this.loadWorkspaces();},tr('site_saved'));},
async saveSiteAuth(){await this.run(async()=>{await this.api('/api/site/authentication','PUT',{revision:this.siteData.revision,section_revision:this.siteData.settings.authentication.revision,value:this.siteAuth});await this.loadSite();},tr('site_saved'));}
};}
+1541
View File
File diff suppressed because it is too large. Load diff
+6 -7
View File
@@ -1,4 +1,4 @@
const {openSetup}=require('./support/browser.cjs'); const {openSetup,isolatedUser,signIn}=require('./support/browser.cjs');
const assert=require('node:assert/strict'); const assert=require('node:assert/strict');
const base=process.env.MEGURI_BROWSER_URL || 'http://127.0.0.1:3000'; const base=process.env.MEGURI_BROWSER_URL || 'http://127.0.0.1:3000';
@@ -11,14 +11,13 @@ assert(['localhost','127.0.0.1'].includes(new URL(base).hostname));
const save=async()=>{await dialog().getByRole('button',{name:'Save changes',exact:true}).click();await dialog().waitFor({state:'hidden'});}; const save=async()=>{await dialog().getByRole('button',{name:'Save changes',exact:true}).click();await dialog().waitFor({state:'hidden'});};
const fmt=iso=>iso.split('-').reverse().join('-'); const fmt=iso=>iso.split('-').reverse().join('-');
try { try {
const unique=Date.now();await page.goto(base);await page.getByRole('button',{name:'New here? Create an account'}).click(); // Sandbox 4 belongs to this suite: an administrator and two substitutes (Bob Chen, Carol Substitute).
await page.getByLabel('Username',{exact:true}).fill('absence-'+unique);await page.getByLabel('Display name',{exact:true}).fill('Absence tester');await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');await page.getByLabel('Email address',{exact:true}).fill('browser-'+Date.now()+'-'+Math.random().toString(16).slice(2)+'@example.com');await page.getByRole('button',{name:'Create account',exact:true}).click(); const tester=await isolatedUser({sandbox:4,prefix:'absence',display:'Absence tester',employees:['Bob Chen','Carol Substitute']});
await page.getByRole('button',{name:'New workspace',exact:true}).click();await dialog().getByLabel('Name',{exact:true}).fill('Absence '+unique);await save(); await signIn(page,tester.username,tester.password);
await page.getByRole('button',{name:'New workspace',exact:true}).click();await dialog().getByLabel('Name',{exact:true}).fill('Absence '+unique);await save(); await page.getByLabel('Workspace',{exact:true}).waitFor();
const dates=await page.evaluate(async()=>{ const dates=await page.evaluate(async()=>{
const app=Alpine.$data(document.body); const app=Alpine.$data(document.body);
for(const username of ['bob','carol']){await app.api(app.endpoint('/members'),'PUT',{revision:app.workspace.revision,username,permissions:['view','request_overrides']});await app.loadWorkspace();} const bob=app.directory.find(p=>p.display_name==='Bob Chen').id,doc=JSON.parse(JSON.stringify(app.doc)),start=app.addDays(app.today(),2),end=app.addDays(start,13);
const bob=app.directory.find(p=>p.username==='bob').id,doc=JSON.parse(JSON.stringify(app.doc)),start=app.addDays(app.today(),2),end=app.addDays(start,13);
const rotation=crypto.randomUUID(); const rotation=crypto.randomUUID();
doc.schedules=[{id:rotation,name:'General',handover:'11:00:00',type:'rotation',participants:[app.user.id],anchor:start,shift_days:7},{id:crypto.randomUUID(),name:'Backup',handover:'09:00:00',type:'rotation',participants:[app.user.id],anchor:'2026-09-21',shift_days:7}]; doc.schedules=[{id:rotation,name:'General',handover:'11:00:00',type:'rotation',participants:[app.user.id],anchor:start,shift_days:7},{id:crypto.randomUUID(),name:'Backup',handover:'09:00:00',type:'rotation',participants:[app.user.id],anchor:'2026-09-21',shift_days:7}];
for(let i=1;i<=9;i++)doc.schedules.push({id:crypto.randomUUID(),name:'Project '+i,handover:'11:00:00',type:'coverage',primary:app.user.id,secondary:bob,fallback:rotation}); for(let i=1;i<=9;i++)doc.schedules.push({id:crypto.randomUUID(),name:'Project '+i,handover:'11:00:00',type:'coverage',primary:app.user.id,secondary:bob,fallback:rotation});
+224
View File
@@ -0,0 +1,224 @@
const { openSetup, signIn, apiLogin, adminApi, demoPassword, launchBrowser, baseUrl } = require('./support/browser.cjs');
// Roles and access: demo login guide, instance administration, workspace-role permissions, isolation.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const base = baseUrl();
assert(['127.0.0.1', 'localhost'].includes(new URL(base).hostname), 'Browser tests require a disposable local instance');
fs.mkdirSync('test-results', { recursive: true });
(async () => {
const browser = await launchBrowser();
const errors = [];
const pages = [];
const open = async (viewport = { width: 1440, height: 1100 }) => {
const context = await browser.newContext({ locale: 'en-US', viewport });
const page = await context.newPage();
page.setDefaultTimeout(10000);
page.on('pageerror', e => errors.push(e.message));
pages.push(page);
return page;
};
const dialog = page => page.getByRole('dialog');
const save = async (page, name = 'Save changes') => {
await dialog(page).getByRole('button', { name, exact: true }).click();
await dialog(page).waitFor({ state: 'hidden' });
};
const selectWorkspace = async (page, name) => {
const id = await page.evaluate(name => Alpine.$data(document.body).directoryState.workspaces.find(w => w.name === name)?.id, name);
assert(id, 'workspace visible: ' + name);
await page.getByLabel('Workspace', { exact: true }).selectOption(id);
await page.waitForFunction(id => Alpine.$data(document.body).workspace?.id === id, id);
return id;
};
try {
const secret = await demoPassword();
const admin = await adminApi();
const overview = await admin.get('/api/admin/overview');
const commerce = overview.workspaces.find(w => w.name === 'Commerce Operations');
assert(commerce && commerce.admin_groups.length && commerce.member_groups.length, 'demo configuration defines groups per workspace');
// 1. Login screen: demo accounts with roles and the generated password; no registration; SSO only when configured.
const guest = await open();
await guest.goto(base);
await guest.locator('.demo-note').waitFor();
for (const [name, role] of [['admin', 'Instance administrator'], ['alice', 'Workspace administrator'], ['bob', 'Employee']])
assert.match(await guest.locator(`[data-demo-account=${name}]`).innerText(), new RegExp(role));
assert.equal((await guest.locator('[data-demo-password]').innerText()).trim(), secret);
assert.equal(await guest.getByRole('button', { name: /create (an )?account/i }).count(), 0, 'No self-registration');
const session = await guest.evaluate(() => fetch('/api/session').then(r => r.json()));
assert.equal(await guest.getByRole('link', { name: 'Sign in with SSO' }).isVisible(), !!session.auth.oidc);
const registration = await guest.request.post(base + '/api/register', { headers: { Origin: base }, data: { username: 'intruder1', password: 'long-enough-password', display_name: 'X' } });
assert([404, 405].includes(registration.status()), 'Registration endpoint is gone');
// The running version sits next to the brand on the login screen and on every page after sign-in.
const label = `v${session.version}`;
assert.match(session.version, /^\d+\.\d+\.\d+/);
assert.equal((await guest.locator('.login .brand .app-version').innerText()).trim(), label);
await guest.locator('[data-demo-account=alice]').click();
assert.equal(await guest.getByLabel('Username', { exact: true }).inputValue(), 'alice');
assert.equal(await guest.getByLabel('Password', { exact: true }).inputValue(), secret);
await guest.screenshot({ path: 'test-results/access-login.png', fullPage: true });
// 2. A failed single sign-on shows a localized message and a support reference, then cleans the URL.
const failed = await open();
await failed.goto(base + '/?login_error=no_access&ref=abc123');
await failed.locator('.login-error').waitFor();
assert.match(await failed.locator('.login-error').innerText(), /could not map your groups to any workspace\. Contact the service administrator\./);
assert.match(await failed.locator('.login-error').innerText(), /abc123/);
assert.equal(new URL(failed.url()).searchParams.has('login_error') || new URL(failed.url()).searchParams.has('ref'), false);
await failed.screenshot({ path: 'test-results/access-login-error.png', fullPage: true });
// 3. Instance administration.
const root = await open();
await signIn(root, 'admin');
await root.getByRole('link', { name: 'Instance', exact: true }).click();
await root.getByRole('heading', { name: 'Instance administration', exact: true }).waitFor();
assert.equal(new URL(root.url()).searchParams.get('view'), 'instance');
for (const key of overview.workspaces.map(w => w.key)) await root.locator(`#instance-workspaces [data-workspace-key="${key}"]`).waitFor();
assert.equal(await root.locator('#instance-workspaces .admin-workspace').count(), overview.workspaces.length);
assert.equal(await root.locator('#instance-workspaces').getByRole('button').count(), 0, 'Workspaces are read-only; only disabled ones can be deleted');
await root.locator('.warning-banner').waitFor();
assert.match(await root.locator('.warning-banner').innerText(), /Bootstrap access is enabled/);
assert.match(await root.locator('#instance-config').innerText(), /Password sign-in[\s\S]*Enabled/);
const adminRow = root.locator('#instance-users [data-username=admin]');
assert.match(await adminRow.innerText(), /Bootstrap/);
assert.equal(await adminRow.getByRole('button', { name: 'Edit groups' }).count(), 0, 'Bootstrap accounts are read-only');
assert.equal(await root.locator('#instance-users [data-username=alice]').getByRole('button', { name: 'Edit groups' }).count(), 1);
for (const u of overview.users.filter(u => u.sso)) assert.equal(await root.locator(`#instance-users [data-username=${u.username}]`).getByRole('button', { name: 'Edit groups' }).count(), 0, 'SSO groups are read-only');
await root.screenshot({ path: 'test-results/access-instance.png', fullPage: true });
const username = 'access-' + Date.now().toString(36);
await root.getByRole('button', { name: 'Add user', exact: true }).click();
await dialog(root).getByLabel('Username', { exact: true }).fill(username);
await dialog(root).getByLabel('Display name', { exact: true }).fill('Access tester');
await dialog(root).getByLabel('Password', { exact: true }).fill('first-test-password-1');
await dialog(root).getByLabel(commerce.member_groups[0], { exact: true }).check();
await save(root, 'Create user');
const row = root.locator(`#instance-users [data-username=${username}]`);
await row.waitFor();
assert.match(await row.innerText(), new RegExp(commerce.member_groups[0].replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')));
await row.getByRole('button', { name: 'Edit groups' }).click();
await dialog(root).getByLabel(commerce.member_groups[0], { exact: true }).uncheck();
await save(root);
await row.getByText('No groups').waitFor();
// Without a matching group the account has no workspace access: it sees the empty state and no administration.
await row.getByRole('button', { name: 'Reset password' }).click();
await dialog(root).getByLabel('New password', { exact: true }).fill('second-test-password-2');
await save(root, 'Reset password');
// The event log shows these administrator actions, can be searched, and refuses non-administrators.
const log = root.locator('#instance-events');
await log.locator('[data-event-action="user.created"]').filter({ hasText: username }).first().waitFor();
await log.locator('[data-event-action="user.password_reset"]').filter({ hasText: username }).first().waitFor();
assert.match(await log.locator('[data-event-action="user.created"]').first().innerText(), /admin/);
await log.getByPlaceholder(/sign-in reference/).fill('no-such-event-zzzz-' + Date.now());
await log.getByRole('button', { name: 'Search', exact: true }).click();
await log.locator('[data-events-empty]').waitFor();
assert.equal(await log.locator('.event-row').count(), 0);
await log.getByPlaceholder(/sign-in reference/).fill(username);
await log.getByRole('button', { name: 'Search', exact: true }).click();
await log.locator('.event-row').first().waitFor();
const matching = await log.locator('.event-row').evaluateAll(rows => rows.map(r => r.textContent));
assert(matching.length >= 3 && matching.every(text => text.includes(username)), 'Search returns only events about the user');
assert.equal(await log.locator('[data-events-empty]').isVisible(), false);
assert.equal(await log.locator('[data-events-more]').isVisible(), false);
await root.screenshot({ path: 'test-results/access-event-log.png', fullPage: true });
// A support reference in the URL prefills the search.
const byReference = await open();
await signIn(byReference, 'admin');
await byReference.goto(`${base}/?view=instance&ref=${username}`);
await byReference.locator('#instance-events [data-event-action="user.created"]').first().waitFor();
assert.equal(await byReference.locator('#instance-events input[type=search]').inputValue(), username);
assert.equal((await byReference.locator('.sidebar .brand .app-version').innerText()).trim(), label, 'Version on the instance page');
for (const view of ['calendar', 'requests', 'activity', 'settings']) {
await byReference.goto(`${base}/?view=${view}`);
await byReference.waitForFunction(() => Alpine.$data(document.body).ready);
assert.equal((await byReference.locator('.sidebar .brand .app-version').innerText()).trim(), label, `Version on ${view}`);
}
const mobile = await open({ width: 390, height: 844 });
await signIn(mobile, 'admin');
await mobile.goto(`${base}/?view=instance`);
await mobile.locator('#instance-events .event-row').first().waitFor();
assert(await mobile.evaluate(() => document.documentElement.scrollWidth <= innerWidth), 'Event log must not overflow on mobile');
assert.equal(await mobile.locator('.sidebar .brand .app-version').isVisible(), true, 'The version stays visible on the narrow rail');
await mobile.screenshot({ path: 'test-results/access-version-mobile.png' });
const forbidden = await (await apiLogin('bob')).call('GET', '/api/admin/events');
assert.equal(forbidden.status, 403);
assert.equal((await (await apiLogin('alice')).call('GET', '/api/admin/events?search=x')).status, 403);
const outsider = await open();
await signIn(outsider, username, 'second-test-password-2');
await outsider.getByRole('heading', { name: 'No workspace access yet', exact: true }).waitFor();
assert.equal(await outsider.getByRole('link', { name: 'Instance', exact: true }).isVisible(), false);
assert.equal((await (await apiLogin(username, 'second-test-password-2')).call('GET', '/api/admin/overview')).status, 403);
// Your own groups cannot be edited from the page.
assert.equal(await root.locator('#instance-users [data-username=admin]').getByRole('button', { name: 'Edit groups' }).count(), 0);
// 4. Workspace administrators edit the employee role; employees see it read-only.
const lead = await open();
await signIn(lead, 'alice');
assert.equal(await lead.getByRole('link', { name: 'Instance', exact: true }).isVisible(), false, 'Workspace administrators do not see instance administration');
assert.equal((await (await apiLogin('alice')).call('GET', '/api/admin/overview')).status, 403);
const commerceId = await selectWorkspace(lead, 'Commerce Operations');
await openSetup(lead, 'Workspace settings');
await lead.locator('#employee-permissions').waitFor();
assert.equal(await lead.locator('#employee-permissions [data-permission=force_overrides].on').count(), 0, 'Force is off by default');
assert.match(await lead.locator('#access-groups').innerText(), new RegExp(commerce.admin_groups[0].replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')));
assert.equal(await lead.locator('#workspace-members [data-username=carol] .role-badge').innerText(), 'Employee');
assert.equal(await lead.getByRole('button', { name: /add teammate/i }).count(), 0);
await lead.getByRole('button', { name: 'Edit employee permissions', exact: true }).click();
assert.equal(await dialog(lead).getByLabel('View calendar', { exact: true }).isDisabled(), true, 'Viewing is mandatory');
assert.equal(await dialog(lead).getByLabel('Manage team access').count(), 0, 'Administrative permissions are never grantable');
await dialog(lead).getByLabel('Apply without confirmation', { exact: true }).check();
await lead.screenshot({ path: 'test-results/access-permissions.png', fullPage: true });
await save(lead);
await lead.locator('#employee-permissions [data-permission=force_overrides].on').waitFor();
assert.match(await lead.locator('#employee-permissions .info-note').innerText(), /without confirmation/);
const worker = await open();
await signIn(worker, 'carol');
await selectWorkspace(worker, 'Commerce Operations');
assert.equal(await worker.evaluate(() => Alpine.$data(document.body).can('force_overrides')), true, 'The force permission reaches employees');
await openSetup(worker, 'Workspace settings');
assert.equal(await worker.getByRole('button', { name: 'Edit employee permissions', exact: true }).count(), 0, 'Employees cannot edit permissions');
assert.equal(await worker.getByRole('button', { name: 'Edit working interval' }).count(), 0);
assert.equal(await worker.locator('#access-groups').isVisible(), false, 'Group mapping is shown to administrators only');
assert.equal(await worker.getByRole('link', { name: 'Instance', exact: true }).isVisible(), false);
const carol = await apiLogin('carol');
const detail = await carol.get('/api/workspaces/' + commerceId);
const denied = await carol.call('PUT', `/api/workspaces/${commerceId}/member-permissions`, { revision: detail.workspace.revision, permissions: ['view', 'force_overrides', 'edit_schedules'] });
assert.equal(denied.status, 403);
// Employees may plan schedules, but workspace preferences and the scheduling directory are read-only.
assert.equal(await worker.getByRole('button', { name: 'Edit preferences', exact: true }).count(), 0, 'Employees cannot edit workspace preferences');
assert.equal(await worker.getByRole('button', { name: 'Edit person', exact: true }).count(), 0, 'Employees cannot edit the scheduling directory');
const rename = await carol.call('PUT', `/api/workspaces/${commerceId}/config`, { revision: detail.workspace.revision, document: { ...detail.workspace.document, name: 'Renamed by an employee' } });
assert.equal(rename.status, 403);
await worker.screenshot({ path: 'test-results/access-employee.png', fullPage: true });
// The change applies at once to everyone: turning it off removes the permission without a new login.
await lead.getByRole('button', { name: 'Edit employee permissions', exact: true }).click();
await dialog(lead).getByLabel('Apply without confirmation', { exact: true }).uncheck();
await save(lead);
await lead.locator('#employee-permissions [data-permission=force_overrides]:not(.on)').waitFor();
await worker.reload();
await worker.waitForFunction(() => { const a = Alpine.$data(document.body); return a.ready && a.workspace && !a.busy; });
await selectWorkspace(worker, 'Commerce Operations');
assert.equal(await worker.evaluate(() => Alpine.$data(document.body).can('force_overrides')), false);
// 5. Workspace isolation: a member of one workspace cannot read another, even by URL or API.
const dave = await open();
await signIn(dave, 'dave');
const visible = await dave.evaluate(() => Alpine.$data(document.body).directoryState.workspaces.map(w => w.name));
assert.deepEqual(visible, ['Acme Engineering']);
assert.equal((await (await apiLogin('dave')).call('GET', '/api/workspaces/' + commerceId)).status, 404);
await dave.goto(`${base}/?workspace=${commerceId}`);
await dave.waitForFunction(() => Alpine.$data(document.body).ready && Alpine.$data(document.body).workspace);
assert.equal(await dave.evaluate(() => Alpine.$data(document.body).workspace.document.name), 'Acme Engineering');
assert.deepEqual(errors, []);
console.log('PASS: demo login guide, no self-registration, SSO error banner, instance administration and its read-only rules, role-based employee permissions, employee/outsider/isolation checks');
} catch (error) {
console.error('Browser errors:', errors);
for (const [index, page] of pages.entries()) {
console.error('Visible alert:', await page.locator('[role=alert]').allTextContents().catch(() => ''));
await page.screenshot({ path: `test-results/access-failure-${index}.png`, fullPage: true }).catch(() => {});
}
throw error;
} finally { await browser.close(); }
})().catch(error => { console.error(error); process.exitCode = 1; });
+8 -17
View File
@@ -1,6 +1,6 @@
const assert=require('node:assert/strict'); const assert=require('node:assert/strict');
const fs=require('node:fs'); const fs=require('node:fs');
const {launchBrowser}=require('./support/browser.cjs'); const {launchBrowser,isolatedUser,signIn}=require('./support/browser.cjs');
const base=process.env.MEGURI_BROWSER_URL||'http://127.0.0.1:3000'; const base=process.env.MEGURI_BROWSER_URL||'http://127.0.0.1:3000';
assert(['localhost','127.0.0.1'].includes(new URL(base).hostname)); assert(['localhost','127.0.0.1'].includes(new URL(base).hostname));
(async()=>{ (async()=>{
@@ -8,23 +8,14 @@ assert(['localhost','127.0.0.1'].includes(new URL(base).hostname));
try { try {
const page=await browser.newPage({locale:'en-US',viewport:{width:1280,height:1000}}); const page=await browser.newPage({locale:'en-US',viewport:{width:1280,height:1000}});
const errors=[];page.on('pageerror',e=>errors.push(e.message)); const errors=[];page.on('pageerror',e=>errors.push(e.message));
const username='account-person-'+Date.now(),email=username+'@example.com'; // Accounts are created by the instance administrator (the email is entered in mixed case and stored normalized).
await page.goto(base); const email='account-person-'+Date.now()+'@example.com';
await page.getByRole('button',{name:'New here? Create an account'}).click(); const tester=await isolatedUser({sandbox:8,prefix:'account-person',display:'Account person',email:email.toUpperCase()});
await page.getByLabel('Username',{exact:true}).fill(username); await signIn(page,tester.username,tester.password);
await page.getByLabel('Display name',{exact:true}).fill('Account person');
await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');
const emailInput=page.getByLabel('Email address',{exact:true});
assert.equal(await emailInput.evaluate(e=>e.required),true);
await page.getByRole('button',{name:'Create account',exact:true}).click();
assert.equal(await emailInput.evaluate(e=>e.validity.valueMissing),true);
await emailInput.fill(email.toUpperCase());
await page.getByRole('button',{name:'Create account',exact:true}).click();
await page.getByRole('button',{name:'New workspace',exact:true}).click();
const dialog=page.getByRole('dialog'); const dialog=page.getByRole('dialog');
await dialog.getByLabel('Name',{exact:true}).fill('Account identity'); await page.waitForFunction(()=>Alpine.$data(document.body).workspace);
await dialog.getByRole('button',{name:'Save changes',exact:true}).click(); // A rotation that plans the account saves its Person (id = account id, email from the account) into the document.
await dialog.waitFor({state:'hidden'}); await page.evaluate(async()=>{const a=Alpine.$data(document.body),doc=structuredClone(Alpine.raw(a.doc)),me=a.people.find(p=>p.id===a.user.id);delete me.username;doc.people=[me];doc.schedules=[{id:crypto.randomUUID(),name:'Own rotation',type:'rotation',participants:[a.user.id],anchor:a.today(),shift_days:7,handover:'11:00:00'}];await a.api(a.endpoint('/config'),'PUT',{revision:a.workspace.revision,document:doc});await a.loadWorkspace();});
const state=await page.evaluate(()=>{const a=Alpine.$data(document.body);return {user:a.user,person:a.doc.people.find(p=>p.id===a.user.id)};}); const state=await page.evaluate(()=>{const a=Alpine.$data(document.body);return {user:a.user,person:a.doc.people.find(p=>p.id===a.user.id)};});
assert.equal(state.user.email,email); assert.equal(state.user.email,email);
assert.equal(state.user.user_id,state.user.id); assert.equal(state.user.user_id,state.user.id);
+1 -1
View File
@@ -1,7 +1,7 @@
const assert=require('node:assert/strict'); const assert=require('node:assert/strict');
(async()=>{const browser=await require('./support/browser.cjs').launchBrowser();try{ (async()=>{const browser=await require('./support/browser.cjs').launchBrowser();try{
for(const [user,names] of [['alice',['Acme Engineering','Customer Support','Regional Platform','Commerce Operations']],['bob',['Acme Engineering','Regional Platform']],['carol',['Acme Engineering','Customer Support','Commerce Operations']],['dave',['Acme Engineering']],['erin',['Acme Engineering']]]){ for(const [user,names] of [['alice',['Acme Engineering','Customer Support','Regional Platform','Commerce Operations']],['bob',['Acme Engineering','Regional Platform']],['carol',['Acme Engineering','Customer Support','Commerce Operations']],['dave',['Acme Engineering']],['erin',['Acme Engineering']]]){
const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/');await page.getByLabel('Username',{exact:true}).fill(user);await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.waitForFunction(()=>window.Alpine&&Alpine.$data(document.body).workspace); const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/');await page.getByLabel('Username',{exact:true}).fill(user);await page.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.waitForFunction(()=>window.Alpine&&Alpine.$data(document.body).workspace);
const teams=await page.evaluate(()=>Alpine.$data(document.body).directoryState.workspaces);assert.deepEqual(teams.map(t=>t.name).filter(n=>['Acme Engineering','Customer Support','Regional Platform','Commerce Operations'].includes(n)).sort(),names.sort()); const teams=await page.evaluate(()=>Alpine.$data(document.body).directoryState.workspaces);assert.deepEqual(teams.map(t=>t.name).filter(n=>['Acme Engineering','Customer Support','Regional Platform','Commerce Operations'].includes(n)).sort(),names.sort());
if(user==='alice'){const schedules=await page.evaluate(()=>Alpine.$data(document.body).doc.schedules);assert.equal(schedules.filter(s=>s.type==='priority').length,6);assert(schedules.filter(s=>s.type==='priority').every(s=>s.participants.length>=2&&s.participants.length<=3));assert.equal(schedules.find(s=>s.type==='rotation').participants.length,5);} if(user==='alice'){const schedules=await page.evaluate(()=>Alpine.$data(document.body).doc.schedules);assert.equal(schedules.filter(s=>s.type==='priority').length,6);assert(schedules.filter(s=>s.type==='priority').every(s=>s.participants.length>=2&&s.participants.length<=3));assert.equal(schedules.find(s=>s.type==='rotation').participants.length,5);}
if(user==='alice')for(const name of ['Regional Platform','Commerce Operations']){ if(user==='alice')for(const name of ['Regional Platform','Commerce Operations']){
+5 -11
View File
@@ -1,6 +1,6 @@
const assert=require('node:assert/strict'); const assert=require('node:assert/strict');
const fs=require('node:fs'); const fs=require('node:fs');
const {launchBrowser}=require('./support/browser.cjs'); const {launchBrowser,isolatedUser,signIn}=require('./support/browser.cjs');
const base=process.env.MEGURI_BROWSER_URL||'http://127.0.0.1:3000'; const base=process.env.MEGURI_BROWSER_URL||'http://127.0.0.1:3000';
assert(['localhost','127.0.0.1'].includes(new URL(base).hostname)); assert(['localhost','127.0.0.1'].includes(new URL(base).hostname));
(async()=>{ (async()=>{
@@ -8,17 +8,11 @@ assert(['localhost','127.0.0.1'].includes(new URL(base).hostname));
try { try {
const page=await browser.newPage({locale:'en-US',viewport:{width:1280,height:900}}); const page=await browser.newPage({locale:'en-US',viewport:{width:1280,height:900}});
const errors=[];page.on('pageerror',e=>errors.push(e.message)); const errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.goto(base); // Sandbox 12 belongs to this suite; workspaces cannot be created, so it starts empty.
await page.getByRole('button',{name:'New here? Create an account'}).click(); const tester=await isolatedUser({sandbox:12,prefix:'binding-path',display:'Path tester'});
await page.getByLabel('Username',{exact:true}).fill('binding-path-'+Date.now()); await signIn(page,tester.username,tester.password);
await page.getByLabel('Display name',{exact:true}).fill('Path tester'); await page.getByLabel('Workspace',{exact:true}).waitFor();
await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');
await page.getByLabel('Email address',{exact:true}).fill('browser-'+Date.now()+'-'+Math.random().toString(16).slice(2)+'@example.com');await page.getByRole('button',{name:'Create account',exact:true}).click();
await page.getByRole('button',{name:'New workspace',exact:true}).click();
const dialog=page.getByRole('dialog'); const dialog=page.getByRole('dialog');
await dialog.getByLabel('Name',{exact:true}).fill('Binding path regression');
await dialog.getByRole('button',{name:'Save changes',exact:true}).click();
await dialog.waitFor({state:'hidden'});
await page.evaluate(()=>{ await page.evaluate(()=>{
const a=Alpine.$data(document.body),root=a.newNode('dictionary'),owners=a.newNode('list',null,'owners'); const a=Alpine.$data(document.body),root=a.newNode('dictionary'),owners=a.newNode('list',null,'owners');
owners.entries=[a.newNode('text','First owner'),a.newNode('text','Second owner')];root.entries=[owners]; owners.entries=[a.newNode('text','First owner'),a.newNode('text','Second owner')];root.entries=[owners];
+1 -1
View File
@@ -1 +1 @@
const assert=require('node:assert/strict');(async()=>{const b=await require('./support/browser.cjs').launchBrowser();try{const p=await b.newPage({viewport:{width:1440,height:1000}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.goto('http://127.0.0.1:3000/?view=configuration');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).click();const dialog=p.getByRole('dialog'),popup=p.locator('.binding-popover');await dialog.getByLabel('Delay in minutes',{exact:true}).fill('7');assert.equal(await p.evaluate(()=>Alpine.$data(document.body).form.blocks.find(b=>b.kind==='escalation').steps[0].delay.value),7);await dialog.getByRole('button',{name:'Bind Delay in minutes',exact:true}).click();await popup.waitFor();assert.equal(await popup.getByLabel('Fixed number',{exact:true}).inputValue(),'7');await popup.getByLabel('Value source',{exact:true}).selectOption('lookup');await p.waitForTimeout(50);assert.equal(await popup.getByLabel('Value source',{exact:true}).count(),2);const box=await popup.boundingBox();assert(box.y>=0&&box.y+box.height<=1000);await p.screenshot({path:'test-results/inline-binding-context.png'});await popup.getByLabel('Value source',{exact:true}).first().selectOption('literal');await popup.getByLabel('Fixed number',{exact:true}).fill('10');await popup.press('Escape');await popup.waitFor({state:'detached'});assert(await dialog.isVisible());assert.equal(await dialog.getByLabel('Delay in minutes',{exact:true}).inputValue(),'10');await dialog.getByRole('button',{name:'Bind Delay in minutes',exact:true}).click();await popup.waitFor();await dialog.getByLabel('Rule name',{exact:true}).click();await popup.waitFor({state:'detached'});const preview=dialog.locator('.compact-preview');assert.equal(await preview.getAttribute('open'),null);await preview.locator('summary').click();await preview.screenshot({path:'test-results/compact-preview.png'});assert.deepEqual(errors,[]);console.log('PASS inline delay, anchored nested sources, context, Escape/outside dismissal and collapsed preview');}finally{await b.close();}})().catch(e=>{console.error(e);process.exitCode=1;}); const assert=require('node:assert/strict');(async()=>{const b=await require('./support/browser.cjs').launchBrowser();try{const p=await b.newPage({viewport:{width:1440,height:1000}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.goto('http://127.0.0.1:3000/?view=configuration');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).click();const dialog=p.getByRole('dialog'),popup=p.locator('.binding-popover');await dialog.getByLabel('Delay in minutes',{exact:true}).fill('7');assert.equal(await p.evaluate(()=>Alpine.$data(document.body).form.blocks.find(b=>b.kind==='escalation').steps[0].delay.value),7);await dialog.getByRole('button',{name:'Bind Delay in minutes',exact:true}).click();await popup.waitFor();assert.equal(await popup.getByLabel('Fixed number',{exact:true}).inputValue(),'7');await popup.getByLabel('Value source',{exact:true}).selectOption('lookup');await p.waitForTimeout(50);assert.equal(await popup.getByLabel('Value source',{exact:true}).count(),2);const box=await popup.boundingBox();assert(box.y>=0&&box.y+box.height<=1000);await p.screenshot({path:'test-results/inline-binding-context.png'});await popup.getByLabel('Value source',{exact:true}).first().selectOption('literal');await popup.getByLabel('Fixed number',{exact:true}).fill('10');await popup.press('Escape');await popup.waitFor({state:'detached'});assert(await dialog.isVisible());assert.equal(await dialog.getByLabel('Delay in minutes',{exact:true}).inputValue(),'10');await dialog.getByRole('button',{name:'Bind Delay in minutes',exact:true}).click();await popup.waitFor();await dialog.getByLabel('Rule name',{exact:true}).click();await popup.waitFor({state:'detached'});const preview=dialog.locator('.compact-preview');assert.equal(await preview.getAttribute('open'),null);await preview.locator('summary').click();await preview.screenshot({path:'test-results/compact-preview.png'});assert.deepEqual(errors,[]);console.log('PASS inline delay, anchored nested sources, context, Escape/outside dismissal and collapsed preview');}finally{await b.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
+1 -1
View File
@@ -6,7 +6,7 @@ const assert=require('node:assert/strict');
const page=await browser.newPage({viewport:{width:1280,height:950}}),errors=[]; const page=await browser.newPage({viewport:{width:1280,height:950}}),errors=[];
page.on('pageerror',e=>errors.push(e.message)); page.on('pageerror',e=>errors.push(e.message));
await page.goto(process.env.MEGURI_URL||'http://127.0.0.1:3000/?view=configuration'); await page.goto(process.env.MEGURI_URL||'http://127.0.0.1:3000/?view=configuration');
await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click(); await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await page.getByRole('button',{name:'Sign in',exact:true}).click();
await page.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).click(); await page.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).click();
await page.evaluate(()=>{const a=Alpine.$data(document.body),b=a.form.blocks.find(b=>b.kind==='escalation');a.editBinding(b.fields.repeat_count ||= a.literal(0),'number','Repeat count');}); await page.evaluate(()=>{const a=Alpine.$data(document.body),b=a.form.blocks.find(b=>b.kind==='escalation');a.editBinding(b.fields.repeat_count ||= a.literal(0),'number','Repeat count');});
const preview=page.getByRole('textbox',{name:'Value preview',exact:true});await preview.waitFor();assert.equal(await preview.inputValue(),'0'); const preview=page.getByRole('textbox',{name:'Value preview',exact:true});await preview.waitFor();assert.equal(await preview.inputValue(),'0');
+7 -14
View File
@@ -1,4 +1,4 @@
const {openSetup}=require('./support/browser.cjs'); const {openSetup,isolatedUser,signIn}=require('./support/browser.cjs');
// End-to-end coverage for scheduling contacts, country imports, working hours, and display timezones. // End-to-end coverage for scheduling contacts, country imports, working hours, and display timezones.
const assert=require('node:assert/strict'); const assert=require('node:assert/strict');
@@ -14,26 +14,19 @@ fs.mkdirSync('test-results',{recursive:true});
const save=async()=>{await dialog().getByRole('button',{name:'Save changes',exact:true}).click();await dialog().waitFor({state:'hidden'});}; const save=async()=>{await dialog().getByRole('button',{name:'Save changes',exact:true}).click();await dialog().waitFor({state:'hidden'});};
const state=()=>page.evaluate(()=>{const app=Alpine.$data(document.body);return JSON.parse(JSON.stringify({workspace:app.workspace,user:app.user,endpoint:app.endpoint(),displayZone:app.displayZone}));}); const state=()=>page.evaluate(()=>{const app=Alpine.$data(document.body);return JSON.parse(JSON.stringify({workspace:app.workspace,user:app.user,endpoint:app.endpoint(),displayZone:app.displayZone}));});
try { try {
const label='Calendars '+Date.now(); // Sandbox 2 belongs to this suite: an administrator plus Bob Chen as an employee.
await page.goto(base); const tester=await isolatedUser({sandbox:2,prefix:'calendars',display:'Calendar tester',employees:['Bob Chen']});
await page.getByRole('button',{name:'New here? Create an account'}).click(); await signIn(page,tester.username,tester.password);
await page.getByLabel('Username',{exact:true}).fill('calendars-'+Date.now()); await page.getByLabel('Workspace',{exact:true}).waitFor();
await page.getByLabel('Display name',{exact:true}).fill('Calendar tester');
await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');
await page.getByLabel('Email address',{exact:true}).fill('browser-'+Date.now()+'-'+Math.random().toString(16).slice(2)+'@example.com');await page.getByRole('button',{name:'Create account',exact:true}).click();
await page.getByRole('button',{name:'New workspace',exact:true}).waitFor();
await page.getByRole('button',{name:'New workspace',exact:true}).click();
await dialog().getByLabel('Name',{exact:true}).fill(label);await save();
await page.getByRole('button',{name:'New workspace',exact:true}).click();
await dialog().getByLabel('Name',{exact:true}).fill(label);await save();
await openSetup(page,'Workspace settings'); await openSetup(page,'Workspace settings');
await page.getByRole('button',{name:'+ Add teammate'}).click();await dialog().getByLabel('Registered username').fill('bob');await save(); await page.locator('#workspace-members').getByText('Bob Chen',{exact:true}).waitFor();
await openSetup(page,'Schedules'); await openSetup(page,'Schedules');
await page.locator('.schedule-create > summary').click(); await page.locator('.schedule-create > summary').click();
await page.getByRole('button',{name:'+ Rotation',exact:true}).click(); await page.getByRole('button',{name:'+ Rotation',exact:true}).click();
await dialog().getByLabel('Schedule name').fill('Distributed rotation'); await dialog().getByLabel('Schedule name').fill('Distributed rotation');
await dialog().getByLabel('Handover time').fill('09:00'); await dialog().getByLabel('Handover time').fill('09:00');
await dialog().getByLabel('Remove participant').first().click(); await dialog().getByLabel('Remove participant').first().click();
await dialog().getByLabel('Remove participant').first().click();
await dialog().getByRole('button',{name:'+ Add participant · name or email'}).click(); await dialog().getByRole('button',{name:'+ Add participant · name or email'}).click();
const picker=()=>page.getByRole('region',{name:'Choose a person'}); const picker=()=>page.getByRole('region',{name:'Choose a person'});
await picker().getByLabel('Search workspace users or enter email').fill('Bob'); await picker().getByLabel('Search workspace users or enter email').fill('Bob');
+5 -12
View File
@@ -1,4 +1,4 @@
const {openSetup}=require('./support/browser.cjs'); const {openSetup,isolatedUser,signIn}=require('./support/browser.cjs');
// Regression for dependency-aware removal and routing precedence through the real UI/API. // Regression for dependency-aware removal and routing precedence through the real UI/API.
const assert=require('node:assert/strict'); const assert=require('node:assert/strict');
@@ -24,17 +24,10 @@ fs.mkdirSync('test-results',{recursive:true});
assert.deepEqual((await state()).document.routes.map(r=>r.order),names.map((_,i)=>i)); assert.deepEqual((await state()).document.routes.map(r=>r.order),names.map((_,i)=>i));
}; };
try { try {
const unique=Date.now(); const tester=await isolatedUser({sandbox:3,prefix:'configuration',display:'Configuration tester'});
await page.goto(base);await page.getByRole('button',{name:'New here? Create an account'}).click(); await signIn(page,tester.username,tester.password);
await page.getByLabel('Username',{exact:true}).fill('configuration-'+unique); await page.getByLabel('Workspace',{exact:true}).waitFor();
await page.getByLabel('Display name',{exact:true}).fill('Configuration tester'); // Seed only this test's emptied workspace; all mutations under test use visible controls.
await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');
await page.getByLabel('Email address',{exact:true}).fill('browser-'+Date.now()+'-'+Math.random().toString(16).slice(2)+'@example.com');await page.getByRole('button',{name:'Create account',exact:true}).click();
await page.getByRole('button',{name:'New workspace',exact:true}).click();
await dialog().getByLabel('Name',{exact:true}).fill('Configuration '+unique);await save();
await page.getByRole('button',{name:'New workspace',exact:true}).click();
await dialog().getByLabel('Name',{exact:true}).fill('Configuration '+unique);await save();
// Seed only this test's new team; all mutations under test use visible controls.
await page.evaluate(async()=>{ await page.evaluate(async()=>{
const app=Alpine.$data(document.body),doc=JSON.parse(JSON.stringify(app.doc)); const app=Alpine.$data(document.body),doc=JSON.parse(JSON.stringify(app.doc));
doc.schedules=['Payments · primary','Payments · secondary','Alternate'].map(name=>({id:crypto.randomUUID(),name,handover:'11:00:00',type:'rotation',participants:[app.user.id],anchor:'2026-09-21',shift_days:7})); doc.schedules=['Payments · primary','Payments · secondary','Alternate'].map(name=>({id:crypto.randomUUID(),name,handover:'11:00:00',type:'rotation',participants:[app.user.id],anchor:'2026-09-21',shift_days:7}));
+6 -9
View File
@@ -1,6 +1,6 @@
const assert=require('node:assert/strict'); const assert=require('node:assert/strict');
const fs=require('node:fs'); const fs=require('node:fs');
const {launchBrowser}=require('./support/browser.cjs'); const {launchBrowser,isolatedUser,signIn}=require('./support/browser.cjs');
const base=process.env.MEGURI_BROWSER_URL||'http://127.0.0.1:3000'; const base=process.env.MEGURI_BROWSER_URL||'http://127.0.0.1:3000';
assert(['localhost','127.0.0.1'].includes(new URL(base).hostname)); assert(['localhost','127.0.0.1'].includes(new URL(base).hostname));
(async()=>{ (async()=>{
@@ -8,16 +8,13 @@ assert(['localhost','127.0.0.1'].includes(new URL(base).hostname));
try { try {
const page=await browser.newPage({locale:'en-US',viewport:{width:1280,height:1000}}); const page=await browser.newPage({locale:'en-US',viewport:{width:1280,height:1000}});
const errors=[];page.on('pageerror',e=>errors.push(e.message)); const errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.goto(base); // Sandbox 9 belongs to this suite; workspaces cannot be created, so the fixture configures it.
await page.getByRole('button',{name:'New here? Create an account'}).click(); const tester=await isolatedUser({sandbox:9,prefix:'contact-approval',display:'Contact planner'});
await page.getByLabel('Username',{exact:true}).fill('contact-approval-'+Date.now()); await signIn(page,tester.username,tester.password);
await page.getByLabel('Display name',{exact:true}).fill('Contact planner'); await page.getByLabel('Workspace',{exact:true}).waitFor();
await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');
await page.getByLabel('Email address',{exact:true}).fill('browser-'+Date.now()+'-'+Math.random().toString(16).slice(2)+'@example.com');await page.getByRole('button',{name:'Create account',exact:true}).click();
await page.getByRole('button',{name:'New workspace',exact:true}).waitFor();
const fixture=await page.evaluate(async()=>{ const fixture=await page.evaluate(async()=>{
const a=Alpine.$data(document.body); const a=Alpine.$data(document.body);
const r=await a.api('/api/workspaces','POST',{name:'Contact approval regression'}); const r=(await a.api('/api/workspaces/'+a.workspace.id)).workspace;
const start=a.addDays(a.today(),2),contact=crypto.randomUUID(); const start=a.addDays(a.today(),2),contact=crypto.randomUUID();
const doc=r.document; const doc=r.document;
doc.people.push({id:contact,user_id:null,display_name:'Email colleague',email:'colleague@example.com',country:'CY'}); doc.people.push({id:contact,user_id:null,display_name:'Email colleague',email:'colleague@example.com',country:'CY'});
+8 -6
View File
@@ -1,4 +1,5 @@
const {isolatedUser}=require('./support/browser.cjs');
const assert=require('node:assert/strict'); const assert=require('node:assert/strict');
const fs=require('node:fs'); const fs=require('node:fs');
const base=process.env.MEGURI_BROWSER_URL||'http://127.0.0.1:3000'; const base=process.env.MEGURI_BROWSER_URL||'http://127.0.0.1:3000';
@@ -7,19 +8,20 @@ fs.mkdirSync('test-results',{recursive:true});
(async()=>{ (async()=>{
const browser=await require('./support/browser.cjs').launchBrowser(); const browser=await require('./support/browser.cjs').launchBrowser();
const errors=[]; const errors=[];
async function login(name){const page=await browser.newPage({viewport:{width:1440,height:1050},timezoneId:'America/New_York'});page.setDefaultTimeout(10000);page.on('pageerror',e=>errors.push(e.message));await page.goto(base);await page.getByLabel('Username',{exact:true}).fill(name);await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.locator('.day').first().waitFor();return page;} async function login(name,secret){const page=await browser.newPage({viewport:{width:1440,height:1050},timezoneId:'America/New_York'});page.setDefaultTimeout(10000);page.on('pageerror',e=>errors.push(e.message));await page.goto(base);await page.getByLabel('Username',{exact:true}).fill(name);await page.getByLabel('Password',{exact:true}).fill(secret);await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.waitForFunction(()=>{const a=Alpine.$data(document.body);return a.ready&&a.user&&!a.busy&&a.workspace;});return page;}
async function refresh(page){await page.reload();await page.locator('.day').first().waitFor();} async function refresh(page){await page.reload();await page.locator('.day').first().waitFor();}
const dialog=p=>p.getByRole('dialog'); const dialog=p=>p.getByRole('dialog');
const post=(page,path,body)=>page.evaluate(async({path,body})=>{const a=Alpine.$data(document.body);return a.api(path,'POST',body);},{path,body}); const post=(page,path,body)=>page.evaluate(async({path,body})=>{const a=Alpine.$data(document.body);return a.api(path,'POST',body);},{path,body});
let alice,bob; let alice,bob;
try{ try{
alice=await login('alice');bob=await login('bob'); // Sandbox 6 belongs to this suite: an administrator (Alice Morgan) and one employee (Bob Chen).
// Isolate the workflow from the seeded examples and from other browser suites. const sandbox=await isolatedUser({sandbox:6,prefix:'cover-lead',display:'Alice Morgan',employees:['Bob Chen']});
const ids=await alice.evaluate(()=>{const a=Alpine.$data(document.body);return {alice:a.user.id,bob:a.members.find(m=>m.user.username==='bob').user.id};}); const [bobAccount]=sandbox.staff,team=sandbox.workspace;
const team=await post(alice,'/api/workspaces',{name:'Cover UX '+Date.now()}); alice=await login(sandbox.username,sandbox.password);bob=await login(bobAccount.username,bobAccount.password);
const ids=await alice.evaluate(()=>{const a=Alpine.$data(document.body);return {alice:a.user.id,bob:a.members.find(m=>m.user.display_name==='Bob Chen').user.id};});
const endpoint=`/api/workspaces/${team.id}`; const endpoint=`/api/workspaces/${team.id}`;
const start=await alice.evaluate(()=>{const a=Alpine.$data(document.body);return a.addDays(a.today(),2);}); const start=await alice.evaluate(()=>{const a=Alpine.$data(document.body);return a.addDays(a.today(),2);});
const configured=await alice.evaluate(async({endpoint,ids,start})=>{const a=Alpine.$data(document.body);let r=await a.api(endpoint+'/members','PUT',{revision:1,username:'bob',permissions:['view','request_overrides']});const doc=r.document;doc.schedules=[{id:crypto.randomUUID(),name:'Weekly duty',type:'rotation',participants:[ids.alice,ids.bob],anchor:start,shift_days:7,handover:'11:00:00'}];return a.api(endpoint+'/config','PUT',{revision:r.revision,document:doc});},{endpoint,ids,start}); const configured=await alice.evaluate(async({endpoint,ids,start})=>{const a=Alpine.$data(document.body);const r=(await a.api(endpoint)).workspace;const doc=r.document;doc.schedules=[{id:crypto.randomUUID(),name:'Weekly duty',type:'rotation',participants:[ids.alice,ids.bob],anchor:start,shift_days:7,handover:'11:00:00'}];return a.api(endpoint+'/config','PUT',{revision:r.revision,document:doc});},{endpoint,ids,start});
const url=base+'/?workspace='+team.id; const url=base+'/?workspace='+team.id;
await alice.goto(url);await bob.goto(url);await bob.locator('.day').first().waitFor(); await alice.goto(url);await bob.goto(url);await bob.locator('.day').first().waitFor();
assert.equal(await bob.getByRole('button',{name:'Schedules',exact:true}).isVisible(),false,'Setup stays out of the daily flow'); assert.equal(await bob.getByRole('button',{name:'Schedules',exact:true}).isVisible(),false,'Setup stays out of the daily flow');
+1 -1
View File
@@ -1,7 +1,7 @@
const assert=require('node:assert/strict'); const assert=require('node:assert/strict');
(async()=>{const b=await require('./support/browser.cjs').launchBrowser();try{ (async()=>{const b=await require('./support/browser.cjs').launchBrowser();try{
const p=await b.newPage({viewport:{width:1280,height:950}});await p.goto('http://127.0.0.1:3000');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.waitForFunction(()=>Alpine.$data(document.body).workspace); const p=await b.newPage({viewport:{width:1280,height:950}});await p.goto('http://127.0.0.1:3000');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.waitForFunction(()=>Alpine.$data(document.body).workspace);
await p.evaluate(()=>{const a=Alpine.$data(document.body),alice=a.user.id,bob=a.people.find(x=>x.display_name==='Bob Chen').id,schedule=a.doc.schedules[0].id; a.doc.requests=[{id:'test',status:'pending',required_approvers:[alice,bob],approvals:[alice],legs:[{schedule_id:schedule,from_user:alice,to_user:bob,start:'2026-10-08T11:00:00+03:00',end:'2026-10-10T08:00:00.000Z'}]}];a.detail.coverage={total:1,issues:[{schedule_id:schedule,user_id:alice,start:'2026-10-08T08:00:00Z',end:'2026-10-11T08:00:00Z'}]};}); await p.evaluate(()=>{const a=Alpine.$data(document.body),alice=a.user.id,bob=a.people.find(x=>x.display_name==='Bob Chen').id,schedule=a.doc.schedules[0].id; a.doc.requests=[{id:'test',status:'pending',required_approvers:[alice,bob],approvals:[alice],legs:[{schedule_id:schedule,from_user:alice,to_user:bob,start:'2026-10-08T11:00:00+03:00',end:'2026-10-10T08:00:00.000Z'}]}];a.detail.coverage={total:1,issues:[{schedule_id:schedule,user_id:alice,start:'2026-10-08T08:00:00Z',end:'2026-10-11T08:00:00Z'}]};});
const box=p.locator('.coverage-problems');await box.waitFor({state:'visible'});assert.equal(await box.locator('.coverage-issue').count(),2);await box.getByText('Waiting for: Bob Chen',{exact:true}).waitFor();assert.equal(await box.getByText('No substitute assigned',{exact:true}).count(),1);assert.equal(await box.getByText('Awaiting approval',{exact:true}).count(),1); const box=p.locator('.coverage-problems');await box.waitFor({state:'visible'});assert.equal(await box.locator('.coverage-issue').count(),2);await box.getByText('Waiting for: Bob Chen',{exact:true}).waitFor();assert.equal(await box.getByText('No substitute assigned',{exact:true}).count(),1);assert.equal(await box.getByText('Awaiting approval',{exact:true}).count(),1);
const parts=await p.evaluate(()=>Alpine.$data(document.body).coverageItems);assert.equal(parts[0].end,parts[1].start);assert.equal(parts[1].start,'2026-10-10T08:00:00.000Z');assert(!parts[1].awaiting); const parts=await p.evaluate(()=>Alpine.$data(document.body).coverageItems);assert.equal(parts[0].end,parts[1].start);assert.equal(parts[1].start,'2026-10-10T08:00:00.000Z');assert(!parts[1].awaiting);
+1 -1
View File
@@ -1,2 +1,2 @@
const assert=require('node:assert/strict'); const assert=require('node:assert/strict');
(async()=>{const b=await require('./support/browser.cjs').launchBrowser();try{const p=await b.newPage({viewport:{width:1280,height:950}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.goto('http://127.0.0.1:3000/?view=configuration');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.getByRole('button',{name:'Edit variable on_call',exact:true}).click();const dialog=p.getByRole('dialog'),status=dialog.locator('.modal-footer:visible .validation-toggle');await status.waitFor();assert.equal(await status.getAttribute('aria-expanded'),'false');const original=await p.evaluate(()=>{const a=Alpine.$data(document.body),n=a.form.node.entries[0],v={...n};n.kind='date';n.value='2026-09-24';return v;});await p.waitForFunction(()=>{const a=Alpine.$data(document.body);return !a.generationLoading&&a.generationResult?.issues.some(i=>i.rule_id);});assert.match(await status.innerText(),/Issues to resolve/);assert.equal(await dialog.locator('.generation-errors').count(),0);await status.click();await dialog.getByRole('button',{name:'Edit rule: Weekly Time off',exact:true}).click();await p.waitForFunction(()=>Alpine.$data(document.body).modal==='generation-rule');assert.equal(await p.evaluate(()=>Alpine.$data(document.body).generationDraft.variables.find(v=>v.name==='on_call').node.entries[0].value),'2026-09-24');await p.waitForFunction(()=>!Alpine.$data(document.body).generationLoading);await status.click();await dialog.getByRole('button',{name:'Edit variable: on_call',exact:true}).click();await p.waitForFunction(()=>Alpine.$data(document.body).modal==='variable');await p.evaluate(original=>Object.assign(Alpine.$data(document.body).form.node.entries[0],original),original);await p.waitForFunction(()=>{const a=Alpine.$data(document.body);return !a.generationLoading&&a.generationResult&&!a.generationResult.issues.length;});assert.match(await status.innerText(),/Draft checked/);await p.setViewportSize({width:390,height:844});await p.screenshot({path:'test-results/diagnostics-mobile.png'});assert(await p.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));assert.deepEqual(errors,[]);console.log('PASS persistent diagnostics, primary source links, draft preservation and recovery');}finally{await b.close();}})().catch(e=>{console.error(e);process.exitCode=1;}); (async()=>{const b=await require('./support/browser.cjs').launchBrowser();try{const p=await b.newPage({viewport:{width:1280,height:950}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.goto('http://127.0.0.1:3000/?view=configuration');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.getByRole('button',{name:'Edit variable on_call',exact:true}).click();const dialog=p.getByRole('dialog'),status=dialog.locator('.modal-footer:visible .validation-toggle');await status.waitFor();assert.equal(await status.getAttribute('aria-expanded'),'false');const original=await p.evaluate(()=>{const a=Alpine.$data(document.body),n=a.form.node.entries[0],v={...n};n.kind='date';n.value='2026-09-24';return v;});await p.waitForFunction(()=>{const a=Alpine.$data(document.body);return !a.generationLoading&&a.generationResult?.issues.some(i=>i.rule_id);});assert.match(await status.innerText(),/Issues to resolve/);assert.equal(await dialog.locator('.generation-errors').count(),0);await status.click();await dialog.getByRole('button',{name:'Edit rule: Weekly Time off',exact:true}).click();await p.waitForFunction(()=>Alpine.$data(document.body).modal==='generation-rule');assert.equal(await p.evaluate(()=>Alpine.$data(document.body).generationDraft.variables.find(v=>v.name==='on_call').node.entries[0].value),'2026-09-24');await p.waitForFunction(()=>!Alpine.$data(document.body).generationLoading);await status.click();await dialog.getByRole('button',{name:'Edit variable: on_call',exact:true}).click();await p.waitForFunction(()=>Alpine.$data(document.body).modal==='variable');await p.evaluate(original=>Object.assign(Alpine.$data(document.body).form.node.entries[0],original),original);await p.waitForFunction(()=>{const a=Alpine.$data(document.body);return !a.generationLoading&&a.generationResult&&!a.generationResult.issues.length;});assert.match(await status.innerText(),/Draft checked/);await p.setViewportSize({width:390,height:844});await p.screenshot({path:'test-results/diagnostics-mobile.png'});assert(await p.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));assert.deepEqual(errors,[]);console.log('PASS persistent diagnostics, primary source links, draft preservation and recovery');}finally{await b.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
+4 -12
View File
@@ -1,6 +1,6 @@
const assert=require('node:assert/strict'); const assert=require('node:assert/strict');
const fs=require('node:fs'); const fs=require('node:fs');
const {launchBrowser}=require('./support/browser.cjs'); const {launchBrowser,isolatedUser,signIn}=require('./support/browser.cjs');
const base=process.env.MEGURI_BROWSER_URL||'http://127.0.0.1:3000'; const base=process.env.MEGURI_BROWSER_URL||'http://127.0.0.1:3000';
assert(['localhost','127.0.0.1'].includes(new URL(base).hostname)); assert(['localhost','127.0.0.1'].includes(new URL(base).hostname));
(async()=>{ (async()=>{
@@ -9,17 +9,9 @@ assert(['localhost','127.0.0.1'].includes(new URL(base).hostname));
const page=await browser.newPage({locale:'en-US',viewport:{width:1280,height:900}}); const page=await browser.newPage({locale:'en-US',viewport:{width:1280,height:900}});
page.setDefaultTimeout(12000); page.setDefaultTimeout(12000);
const errors=[];page.on('pageerror',e=>errors.push(e.message)); const errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.goto(base); const tester=await isolatedUser({sandbox:7,prefix:'map-origin',display:'Path tester'});
await page.getByRole('button',{name:'New here? Create an account'}).click(); await signIn(page,tester.username,tester.password);
await page.getByLabel('Username',{exact:true}).fill('map-origin-'+Date.now()); await page.getByLabel('Workspace',{exact:true}).waitFor();
await page.getByLabel('Display name',{exact:true}).fill('Path tester');
await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');
await page.getByLabel('Email address',{exact:true}).fill('browser-'+Date.now()+'-'+Math.random().toString(16).slice(2)+'@example.com');await page.getByRole('button',{name:'Create account',exact:true}).click();
await page.getByRole('button',{name:'New workspace',exact:true}).click();
const dialog=page.getByRole('dialog');
await dialog.getByLabel('Name',{exact:true}).fill('Layered map regression');
await dialog.getByRole('button',{name:'Save changes',exact:true}).click();
await dialog.waitFor({state:'hidden'});
await page.evaluate(async()=>{ await page.evaluate(async()=>{
const a=Alpine.$data(document.body),doc=structuredClone(Alpine.raw(a.doc)); const a=Alpine.$data(document.body),doc=structuredClone(Alpine.raw(a.doc));
+1 -1
View File
@@ -1,2 +1,2 @@
const assert=require('node:assert/strict'); const assert=require('node:assert/strict');
(async()=>{const browser=await require('./support/browser.cjs').launchBrowser();try{const page=await browser.newPage({viewport:{width:1200,height:850}}),errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.getByRole('button',{name:'Edit rule Project owners during working hours',exact:true}).click();for(const width of [1200,390]){await page.setViewportSize({width,height:850});const button=page.locator('.binding-tweak').last();await button.scrollIntoViewIfNeeded();await button.click();const saved=await page.evaluate(()=>Alpine.$data(document.body).editorScroll);assert(saved>0);const popup=page.locator('.binding-popover');await popup.waitFor();const bounds=await popup.boundingBox();assert(bounds.x>=0&&bounds.x+bounds.width<=width&&bounds.y>=0&&bounds.y+bounds.height<=850);assert(await page.locator('.rule-constructor').isVisible());await popup.screenshot({path:'test-results/binding-popover-'+width+'.png'});await page.getByRole('button',{name:'Done',exact:true}).click();await page.waitForFunction(top=>Math.abs(document.querySelector('[role=dialog]').scrollTop-top)<2,saved);assert.equal(await page.locator('select.template-token,input.template-token,button.template-token').count(),0);assert(await page.locator('.template-token').count()>0);}await page.getByLabel('Iteration collection',{exact:true}).scrollIntoViewIfNeeded();await page.screenshot({path:'test-results/standard-template-controls.png'});assert.deepEqual(errors,[]);console.log('PASS standard controls; template notation retained; modal scroll restored desktop/mobile');}finally{await browser.close();}})().catch(e=>{console.error(e);process.exitCode=1;}); (async()=>{const browser=await require('./support/browser.cjs').launchBrowser();try{const page=await browser.newPage({viewport:{width:1200,height:850}}),errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.getByRole('button',{name:'Edit rule Project owners during working hours',exact:true}).click();for(const width of [1200,390]){await page.setViewportSize({width,height:850});const button=page.locator('.binding-tweak').last();await button.scrollIntoViewIfNeeded();await button.click();const saved=await page.evaluate(()=>Alpine.$data(document.body).editorScroll);assert(saved>0);const popup=page.locator('.binding-popover');await popup.waitFor();const bounds=await popup.boundingBox();assert(bounds.x>=0&&bounds.x+bounds.width<=width&&bounds.y>=0&&bounds.y+bounds.height<=850);assert(await page.locator('.rule-constructor').isVisible());await popup.screenshot({path:'test-results/binding-popover-'+width+'.png'});await page.getByRole('button',{name:'Done',exact:true}).click();await page.waitForFunction(top=>Math.abs(document.querySelector('[role=dialog]').scrollTop-top)<2,saved);assert.equal(await page.locator('select.template-token,input.template-token,button.template-token').count(),0);assert(await page.locator('.template-token').count()>0);}await page.getByLabel('Iteration collection',{exact:true}).scrollIntoViewIfNeeded();await page.screenshot({path:'test-results/standard-template-controls.png'});assert.deepEqual(errors,[]);console.log('PASS standard controls; template notation retained; modal scroll restored desktop/mobile');}finally{await browser.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
+5 -11
View File
@@ -1,6 +1,6 @@
const assert=require('node:assert/strict'); const assert=require('node:assert/strict');
const fs=require('node:fs'); const fs=require('node:fs');
const {launchBrowser}=require('./support/browser.cjs'); const {launchBrowser,isolatedUser,signIn}=require('./support/browser.cjs');
const base=process.env.MEGURI_BROWSER_URL||'http://127.0.0.1:3000'; const base=process.env.MEGURI_BROWSER_URL||'http://127.0.0.1:3000';
assert(['localhost','127.0.0.1'].includes(new URL(base).hostname)); assert(['localhost','127.0.0.1'].includes(new URL(base).hostname));
(async()=>{ (async()=>{
@@ -8,17 +8,11 @@ assert(['localhost','127.0.0.1'].includes(new URL(base).hostname));
try { try {
const page=await browser.newPage({locale:'en-US',viewport:{width:1280,height:900}}); const page=await browser.newPage({locale:'en-US',viewport:{width:1280,height:900}});
const errors=[];page.on('pageerror',e=>errors.push(e.message)); const errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.goto(base); // Sandbox 11 belongs to this suite; workspaces cannot be created, so it starts empty.
await page.getByRole('button',{name:'New here? Create an account'}).click(); const tester=await isolatedUser({sandbox:11,prefix:'generation-team',display:'Path tester'});
await page.getByLabel('Username',{exact:true}).fill('generation-team-'+Date.now()); await signIn(page,tester.username,tester.password);
await page.getByLabel('Display name',{exact:true}).fill('Path tester'); await page.getByLabel('Workspace',{exact:true}).waitFor();
await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');
await page.getByLabel('Email address',{exact:true}).fill('browser-'+Date.now()+'-'+Math.random().toString(16).slice(2)+'@example.com');await page.getByRole('button',{name:'Create account',exact:true}).click();
await page.getByRole('button',{name:'New workspace',exact:true}).click();
const dialog=page.getByRole('dialog'); const dialog=page.getByRole('dialog');
await dialog.getByLabel('Name',{exact:true}).fill('Team escalation regression');
await dialog.getByRole('button',{name:'Save changes',exact:true}).click();
await dialog.waitFor({state:'hidden'});
await page.evaluate(()=>{const a=Alpine.$data(document.body);a.editGenerationRule();a.form.name='Team escalation';a.form.blocks=[a.newBlock('escalation')];}); await page.evaluate(()=>{const a=Alpine.$data(document.body);a.editGenerationRule();a.form.name='Team escalation';a.form.blocks=[a.newBlock('escalation')];});
await dialog.getByLabel('Recipient type',{exact:true}).selectOption('team_all'); await dialog.getByLabel('Recipient type',{exact:true}).selectOption('team_all');
+8 -7
View File
@@ -1,4 +1,4 @@
const {openSetup}=require('./support/browser.cjs'); const {openSetup,isolatedUser,signIn,demoPassword}=require('./support/browser.cjs');
const assert=require('node:assert/strict'); const assert=require('node:assert/strict');
const fs=require('node:fs'); const fs=require('node:fs');
@@ -13,9 +13,9 @@ fs.mkdirSync('test-results',{recursive:true});
const save=async(name='Save changes')=>{await dialog().getByRole('button',{name,exact:true}).click();await dialog().waitFor({state:'hidden'});}; const save=async(name='Save changes')=>{await dialog().getByRole('button',{name,exact:true}).click();await dialog().waitFor({state:'hidden'});};
const checked=()=>page.waitForFunction(()=>{const a=Alpine.$data(document.body);return a.generationResult && !a.generationLoading && a.generationResult.key===JSON.stringify(a.generationPayload());}); const checked=()=>page.waitForFunction(()=>{const a=Alpine.$data(document.body);return a.generationResult && !a.generationLoading && a.generationResult.key===JSON.stringify(a.generationPayload());});
try { try {
await page.goto(base);await page.locator('.demo-note').waitFor();await page.getByRole('status',{name:'Demo data retention',exact:true}).waitFor();assert.equal(await page.locator('.demo-banner button').count(),0);assert.match(await page.locator('.demo-protection').innerText(),/left untouched/);assert.match(await page.locator('.demo-note').innerText(),/alice[\s\S]*all four workspaces[\s\S]*bob[\s\S]*carol/); await page.goto(base);await page.locator('.demo-note').waitFor();await page.getByRole('status',{name:'Demo data retention',exact:true}).waitFor();assert.equal(await page.locator('.demo-banner button').count(),0);assert.match(await page.locator('.demo-protection').innerText(),/left untouched/);for(const [name,role] of [['admin','Instance administrator'],['alice','Workspace administrator'],['bob','Employee']])assert.match(await page.locator(`[data-demo-account=${name}]`).innerText(),new RegExp(role));assert.equal((await page.locator('[data-demo-password]').innerText()).trim(),await demoPassword());
await page.screenshot({path:'test-results/demo-login.png',fullPage:true}); await page.screenshot({path:'test-results/demo-login.png',fullPage:true});
await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.waitForFunction(()=>Alpine.$data(document.body).workspace); await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.waitForFunction(()=>Alpine.$data(document.body).workspace);
const demo=await page.evaluate(()=>{const a=Alpine.$data(document.body);return {matrix:a.directoryState.workspaces.find(t=>t.name==='Acme Engineering'),support:a.directoryState.workspaces.find(t=>t.name==='Customer Support')};}); const demo=await page.evaluate(()=>{const a=Alpine.$data(document.body);return {matrix:a.directoryState.workspaces.find(t=>t.name==='Acme Engineering'),support:a.directoryState.workspaces.find(t=>t.name==='Customer Support')};});
assert(demo.matrix && demo.support);assert.notEqual(demo.matrix.id,demo.support.id); assert(demo.matrix && demo.support);assert.notEqual(demo.matrix.id,demo.support.id);
await page.getByLabel('Workspace',{exact:true}).selectOption(demo.matrix.id);await openSetup(page,'Variables & rules');await checked(); await page.getByLabel('Workspace',{exact:true}).selectOption(demo.matrix.id);await openSetup(page,'Variables & rules');await checked();
@@ -40,11 +40,12 @@ fs.mkdirSync('test-results',{recursive:true});
await openSetup(page,'Escalations & routing');await page.screenshot({path:'test-results/support-demo.png',fullPage:true}); await openSetup(page,'Escalations & routing');await page.screenshot({path:'test-results/support-demo.png',fullPage:true});
// Distinct members cannot read the other example's team, even with its URL. // Distinct members cannot read the other example's team, even with its URL.
for(const [username,denied] of [['bob',demo.support],['dave',demo.support]]){ for(const [username,denied] of [['bob',demo.support],['dave',demo.support]]){
const ctx=await browser.newContext();const api=ctx.request;const login=await api.post(base+'/api/login',{headers:{Origin:base},data:{username,password:'meguri-demo-only'}});assert.equal(login.status(),200);const response=await api.get(`${base}/api/workspaces/${denied.id}`);assert.equal(response.status(),404);await ctx.close(); const ctx=await browser.newContext();const api=ctx.request;const login=await api.post(base+'/api/login',{headers:{Origin:base},data:{username,password:await require('./support/browser.cjs').demoPassword()}});assert.equal(login.status(),200);const response=await api.get(`${base}/api/workspaces/${denied.id}`);assert.equal(response.status(),404);await ctx.close();
} }
// Mutating UI checks use a separate account, leaving the examples untouched. // Mutating UI checks use Sandbox 5 and a separate administrator, leaving the demo examples untouched.
await page.getByRole('button',{name:'Sign out',exact:true}).click();await page.getByRole('button',{name:'New here? Create an account',exact:true}).click();const unique=Date.now();await page.getByLabel('Username',{exact:true}).fill('matrix-'+unique);await page.getByLabel('Display name',{exact:true}).fill('Matrix tester');await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');await page.getByLabel('Email address',{exact:true}).fill('browser-'+Date.now()+'-'+Math.random().toString(16).slice(2)+'@example.com');await page.getByRole('button',{name:'Create account',exact:true}).click(); await page.getByRole('button',{name:'Sign out',exact:true}).click();
await page.getByRole('button',{name:'New workspace',exact:true}).click();await dialog().getByLabel('Name',{exact:true}).fill('Matrix '+unique);await save(); const tester=await isolatedUser({sandbox:5,prefix:'matrix',display:'Matrix tester'});
await signIn(page,tester.username,tester.password);await page.getByLabel('Workspace',{exact:true}).waitFor();
await openSetup(page,'Variables & rules'); await openSetup(page,'Variables & rules');
await page.evaluate(()=>{const a=Alpine.$data(document.body),projects={id:crypto.randomUUID(),name:'projects',node:a.newNode('list')},responsible={id:crypto.randomUUID(),name:'responsible',node:a.newNode('dictionary')};for(const name of ['A','B']){projects.node.entries.push(a.newNode('text',name));const entry=a.newNode('list',null,name);entry.entries=['alice@example.com','bob@example.com'].map(email=>a.newNode('person',email));responsible.node.entries.push(entry);}const iteration={id:crypto.randomUUID(),name:'Project',source:{kind:'variable',variable:projects.id,path:[]}},block=a.newBlock('rotation');block.fields.name={kind:'item',iteration:iteration.id,path:[],suffix:' on call'};block.fields.participants={kind:'lookup',variable:responsible.id,key:{kind:'item',iteration:iteration.id,path:[]},path:[]};a.generationDraft.variables.push(projects,responsible);a.generationDraft.rules.push({id:crypto.randomUUID(),name:'Project rotations',enabled:true,iterations:[iteration],when:null,blocks:[block]});a.generationChanged();});await checked(); await page.evaluate(()=>{const a=Alpine.$data(document.body),projects={id:crypto.randomUUID(),name:'projects',node:a.newNode('list')},responsible={id:crypto.randomUUID(),name:'responsible',node:a.newNode('dictionary')};for(const name of ['A','B']){projects.node.entries.push(a.newNode('text',name));const entry=a.newNode('list',null,name);entry.entries=['alice@example.com','bob@example.com'].map(email=>a.newNode('person',email));responsible.node.entries.push(entry);}const iteration={id:crypto.randomUUID(),name:'Project',source:{kind:'variable',variable:projects.id,path:[]}},block=a.newBlock('rotation');block.fields.name={kind:'item',iteration:iteration.id,path:[],suffix:' on call'};block.fields.participants={kind:'lookup',variable:responsible.id,key:{kind:'item',iteration:iteration.id,path:[]},path:[]};a.generationDraft.variables.push(projects,responsible);a.generationDraft.rules.push({id:crypto.randomUUID(),name:'Project rotations',enabled:true,iterations:[iteration],when:null,blocks:[block]});a.generationChanged();});await checked();
assert.equal(await page.evaluate(()=>Alpine.$data(document.body).generationCount('create')),2); assert.equal(await page.evaluate(()=>Alpine.$data(document.body).generationCount('create')),2);
+9 -17
View File
@@ -1,6 +1,6 @@
const assert=require('node:assert/strict'); const assert=require('node:assert/strict');
const fs=require('node:fs'); const fs=require('node:fs');
const {launchBrowser,openSetup}=require('./support/browser.cjs'); const {launchBrowser,openSetup,isolatedUser,createUser,signIn}=require('./support/browser.cjs');
const base=process.env.MEGURI_BROWSER_URL||'http://127.0.0.1:3000'; const base=process.env.MEGURI_BROWSER_URL||'http://127.0.0.1:3000';
assert(['127.0.0.1','localhost'].includes(new URL(base).hostname)); assert(['127.0.0.1','localhost'].includes(new URL(base).hostname));
fs.mkdirSync('test-results',{recursive:true}); fs.mkdirSync('test-results',{recursive:true});
@@ -9,24 +9,16 @@ fs.mkdirSync('test-results',{recursive:true});
const context=await browser.newContext({locale:'en-US',viewport:{width:1440,height:1000}}); const context=await browser.newContext({locale:'en-US',viewport:{width:1440,height:1000}});
const page=await context.newPage();page.setDefaultTimeout(15000); const page=await context.newPage();page.setDefaultTimeout(15000);
const errors=[];page.on('pageerror',e=>errors.push(e.message)); const errors=[];page.on('pageerror',e=>errors.push(e.message));
const suffix=Date.now(),username='sync-owner-'+suffix,password='local-workflow-test-password';
const colleague='sync-colleague-'+suffix;
try{ try{
await page.goto(base); // Sandbox 13 belongs to this suite: two administrators (the owner and a colleague with the same access).
await page.getByRole('button',{name:'New here? Create an account'}).click(); const owner=await isolatedUser({sandbox:13,prefix:'sync-owner',display:'Workflow owner'});
await page.getByLabel('Username',{exact:true}).fill(username); const colleague=await createUser(owner.admin,{sandbox:13,role:'admin',prefix:'sync-colleague',display:'Workflow colleague'});
await page.getByLabel('Display name',{exact:true}).fill('Workflow owner'); const username=owner.username,password=owner.password;
await page.getByLabel('Password',{exact:true}).fill(password); await signIn(page,username,password);
await page.getByLabel('Email address',{exact:true}).fill('browser-'+Date.now()+'-'+Math.random().toString(16).slice(2)+'@example.com');await page.getByRole('button',{name:'Create account',exact:true}).click(); await page.getByLabel('Workspace',{exact:true}).waitFor();
await page.waitForFunction(()=>Alpine.$data(document.body).user && !Alpine.$data(document.body).busy);
await page.getByRole('button',{name:'New workspace',exact:true}).click();
await page.getByRole('dialog').getByLabel('Name',{exact:true}).fill('Shared publication test');
await page.getByRole('dialog').getByRole('button',{name:'Save changes',exact:true}).click();
await page.getByRole('dialog').waitFor({state:'hidden'});
const workspace=await page.evaluate(()=>Alpine.$data(document.body).workspace.id); const workspace=await page.evaluate(()=>Alpine.$data(document.body).workspace.id);
const secondContext=await browser.newContext({locale:'en-US'}); const secondContext=await browser.newContext({locale:'en-US'});
const registered=await secondContext.request.post(base+'/api/register',{headers:{Origin:base},data:{username:colleague,email:colleague+'@example.com',password,display_name:'Workflow colleague'}});assert.equal(registered.status(),200); await page.evaluate(async()=>{const a=Alpine.$data(document.body);const doc=structuredClone(Alpine.raw(a.doc));doc.schedules.push({id:crypto.randomUUID(),name:'Awaiting publication',type:'rotation',participants:[a.user.id],anchor:new Date().toISOString().slice(0,10),shift_days:7,handover:'11:00:00'});await a.api(a.endpoint('/config'),'PUT',{revision:a.workspace.revision,document:doc});await a.loadWorkspace();});
await page.evaluate(async colleague=>{const a=Alpine.$data(document.body);await a.api(a.endpoint('/members'),'PUT',{revision:a.workspace.revision,username:colleague,permissions:['view','edit_schedules','manage_integration']});await a.loadWorkspace();const doc=structuredClone(Alpine.raw(a.doc));doc.schedules.push({id:crypto.randomUUID(),name:'Awaiting publication',type:'rotation',participants:[a.user.id],anchor:new Date().toISOString().slice(0,10),shift_days:7,handover:'11:00:00'});await a.api(a.endpoint('/config'),'PUT',{revision:a.workspace.revision,document:doc});await a.loadWorkspace();},colleague);
const review=page.locator('[data-publication-review]'); const review=page.locator('[data-publication-review]');
await review.getByText('+1 new',{exact:true}).waitFor(); await review.getByText('+1 new',{exact:true}).waitFor();
const initialSync=await page.evaluate(()=>structuredClone(Alpine.raw(Alpine.$data(document.body).detail.sync))); const initialSync=await page.evaluate(()=>structuredClone(Alpine.raw(Alpine.$data(document.body).detail.sync)));
@@ -61,11 +53,11 @@ fs.mkdirSync('test-results',{recursive:true});
await page.waitForTimeout(6000); await page.waitForTimeout(6000);
const before=await page.evaluate(async()=>{const a=Alpine.$data(document.body);return (await a.api(a.endpoint())).sync;});assert.equal(before.published_revision,null);assert.equal(before.authorized_revision,null); const before=await page.evaluate(async()=>{const a=Alpine.$data(document.body);return (await a.api(a.endpoint())).sync;});assert.equal(before.published_revision,null);assert.equal(before.authorized_revision,null);
await page.getByRole('button',{name:'Sign out',exact:true}).click(); await page.getByRole('button',{name:'Sign out',exact:true}).click();
await page.getByRole('button',{name:'Already registered? Sign in',exact:true}).click();
await page.getByLabel('Username',{exact:true}).fill(username);await page.getByLabel('Password',{exact:true}).fill(password); await page.getByLabel('Username',{exact:true}).fill(username);await page.getByLabel('Password',{exact:true}).fill(password);
await page.getByRole('button',{name:'Sign in',exact:true}).click();await openSetup(page,'Variables & rules'); await page.getByRole('button',{name:'Sign in',exact:true}).click();await openSetup(page,'Variables & rules');
await page.getByRole('button',{name:'Edit variable Shared unsynchronized draft',exact:true}).waitFor(); await page.getByRole('button',{name:'Edit variable Shared unsynchronized draft',exact:true}).waitFor();
const second=await secondContext.newPage();second.on('pageerror',e=>errors.push(e.message)); const second=await secondContext.newPage();second.on('pageerror',e=>errors.push(e.message));
await signIn(second,colleague.username,colleague.password);
await second.goto(base+'/?workspace='+workspace+'&view=configuration'); await second.goto(base+'/?workspace='+workspace+'&view=configuration');
await second.getByRole('button',{name:'Edit variable Shared unsynchronized draft',exact:true}).waitFor(); await second.getByRole('button',{name:'Edit variable Shared unsynchronized draft',exact:true}).waitFor();
await second.waitForFunction(()=>!Alpine.$data(document.body).generationLoading); await second.waitForFunction(()=>!Alpine.$data(document.body).generationLoading);
+1 -1
View File
@@ -2,7 +2,7 @@ const assert=require('node:assert/strict'),fs=require('node:fs');
const base=process.env.MEGURI_BROWSER_URL||'http://127.0.0.1:3000';assert(['127.0.0.1','localhost'].includes(new URL(base).hostname)); const base=process.env.MEGURI_BROWSER_URL||'http://127.0.0.1:3000';assert(['127.0.0.1','localhost'].includes(new URL(base).hostname));
(async()=>{const browser=await require('./support/browser.cjs').launchBrowser();const errors=[];let page;try{ (async()=>{const browser=await require('./support/browser.cjs').launchBrowser();const errors=[];let page;try{
const context=await browser.newContext({locale:'en-US',viewport:{width:1440,height:1000}});page=await context.newPage();page.setDefaultTimeout(12000);page.on('pageerror',e=>errors.push(e.message)); const context=await browser.newContext({locale:'en-US',viewport:{width:1440,height:1000}});page=await context.newPage();page.setDefaultTimeout(12000);page.on('pageerror',e=>errors.push(e.message));
await page.goto(base+'/?view=policies');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.waitForFunction(()=>Alpine.$data(document.body).workspace);await page.getByRole('heading',{name:'Schedule setup',exact:true}).waitFor(); await page.goto(base+'/?view=policies');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.waitForFunction(()=>Alpine.$data(document.body).workspace);await page.getByRole('heading',{name:'Schedule setup',exact:true}).waitFor();
const matrix=await page.evaluate(()=>Alpine.$data(document.body).selected);const support=await page.evaluate(()=>Alpine.$data(document.body).directoryState.workspaces.find(t=>t.id!==Alpine.$data(document.body).selected).id); const matrix=await page.evaluate(()=>Alpine.$data(document.body).selected);const support=await page.evaluate(()=>Alpine.$data(document.body).directoryState.workspaces.find(t=>t.id!==Alpine.$data(document.body).selected).id);
assert.equal(new URL(page.url()).searchParams.get('view'),'configuration');await page.reload();await page.getByRole('heading',{name:'Schedule setup',exact:true}).waitFor(); assert.equal(new URL(page.url()).searchParams.get('view'),'configuration');await page.reload();await page.getByRole('heading',{name:'Schedule setup',exact:true}).waitFor();
await page.getByRole('link',{name:'Requests',exact:true}).click();await page.waitForURL(/view=requests/);await page.getByLabel('Workspace',{exact:true}).selectOption(support);await page.waitForFunction(id=>Alpine.$data(document.body).workspace.id===id,support);assert.equal(new URL(page.url()).searchParams.get('workspace'),support); await page.getByRole('link',{name:'Requests',exact:true}).click();await page.waitForURL(/view=requests/);await page.getByLabel('Workspace',{exact:true}).selectOption(support);await page.waitForFunction(id=>Alpine.$data(document.body).workspace.id===id,support);assert.equal(new URL(page.url()).searchParams.get('workspace'),support);
+5 -11
View File
@@ -1,6 +1,6 @@
const assert=require('node:assert/strict'); const assert=require('node:assert/strict');
const fs=require('node:fs'); const fs=require('node:fs');
const {launchBrowser}=require('./support/browser.cjs'); const {launchBrowser,isolatedUser,signIn}=require('./support/browser.cjs');
const base=process.env.MEGURI_BROWSER_URL||'http://127.0.0.1:3000'; const base=process.env.MEGURI_BROWSER_URL||'http://127.0.0.1:3000';
assert(['localhost','127.0.0.1'].includes(new URL(base).hostname)); assert(['localhost','127.0.0.1'].includes(new URL(base).hostname));
(async()=>{ (async()=>{
@@ -9,17 +9,11 @@ assert(['localhost','127.0.0.1'].includes(new URL(base).hostname));
const page=await browser.newPage({locale:'en-US',viewport:{width:1280,height:900}}); const page=await browser.newPage({locale:'en-US',viewport:{width:1280,height:900}});
page.setDefaultTimeout(12000); page.setDefaultTimeout(12000);
const errors=[];page.on('pageerror',e=>errors.push(e.message)); const errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.goto(base); // Sandbox 10 belongs to this suite; workspaces cannot be created, so it starts empty.
await page.getByRole('button',{name:'New here? Create an account'}).click(); const tester=await isolatedUser({sandbox:10,prefix:'config-transfer',display:'Path tester'});
await page.getByLabel('Username',{exact:true}).fill('config-transfer-'+Date.now()); await signIn(page,tester.username,tester.password);
await page.getByLabel('Display name',{exact:true}).fill('Path tester'); await page.getByLabel('Workspace',{exact:true}).waitFor();
await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');
await page.getByLabel('Email address',{exact:true}).fill('browser-'+Date.now()+'-'+Math.random().toString(16).slice(2)+'@example.com');await page.getByRole('button',{name:'Create account',exact:true}).click();
await page.getByRole('button',{name:'New workspace',exact:true}).click();
const dialog=page.getByRole('dialog'); const dialog=page.getByRole('dialog');
await dialog.getByLabel('Name',{exact:true}).fill('Portable config regression');
await dialog.getByRole('button',{name:'Save changes',exact:true}).click();
await dialog.waitFor({state:'hidden'});
await page.evaluate(()=>{const a=Alpine.$data(document.body);a.view='generation';}); await page.evaluate(()=>{const a=Alpine.$data(document.body);a.view='generation';});
const initial=await page.evaluate(()=>({revision:Alpine.$data(document.body).workspace.revision,workspace:Alpine.$data(document.body).workspace.id})); const initial=await page.evaluate(()=>({revision:Alpine.$data(document.body).workspace.revision,workspace:Alpine.$data(document.body).workspace.id}));
+1 -1
View File
@@ -1 +1 @@
const assert=require('node:assert/strict');(async()=>{const b=await require('./support/browser.cjs').launchBrowser();try{const p=await b.newPage({viewport:{width:1440,height:1000}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.goto('http://127.0.0.1:3000/?view=configuration');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).click();const d=p.getByRole('dialog');assert.equal(await d.getByLabel('Repeat count · Fixed value',{exact:true}).inputValue(),'0');assert.equal(await d.getByLabel('Minutes between repeats · Fixed value',{exact:true}).inputValue(),'0');await d.getByLabel('Repeat count · Fixed value',{exact:true}).fill('2');await d.getByLabel('Minutes between repeats · Fixed value',{exact:true}).fill('10');await p.waitForFunction(()=>{const a=Alpine.$data(document.body);return !a.generationLoading&&a.generationResult;});assert.deepEqual(await p.evaluate(()=>Alpine.$data(document.body).generationResult.issues),[]);await d.locator('.generation-block').nth(1).screenshot({path:'test-results/repeat-defaults.png'});await d.getByRole('button',{name:'Use in draft',exact:true}).click();await p.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).click();assert.equal(await d.getByLabel('Repeat count · Fixed value',{exact:true}).inputValue(),'2');assert.equal(await d.getByLabel('Minutes between repeats · Fixed value',{exact:true}).inputValue(),'10');await d.getByRole('button',{name:'Cancel',exact:true}).click();const team=await p.evaluate(()=>Alpine.$data(document.body).directoryState.workspaces.find(t=>t.name==='Commerce Operations').id);await p.goto('http://127.0.0.1:3000/?view=configuration&workspace='+team);await p.getByRole('button',{name:'Edit rule Product → service tier',exact:true}).click();assert.equal(await p.evaluate(()=>Alpine.$data(document.body).form.blocks.find(b=>b.kind==='escalation').fields.repeat_count.kind),'lookup');assert.deepEqual(errors,[]);console.log('PASS optional repeat defaults, editing and draft reopening, explicit lookup preservation');}finally{await b.close();}})().catch(e=>{console.error(e);process.exitCode=1;}); const assert=require('node:assert/strict');(async()=>{const b=await require('./support/browser.cjs').launchBrowser();try{const p=await b.newPage({viewport:{width:1440,height:1000}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.goto('http://127.0.0.1:3000/?view=configuration');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).click();const d=p.getByRole('dialog');assert.equal(await d.getByLabel('Repeat count · Fixed value',{exact:true}).inputValue(),'0');assert.equal(await d.getByLabel('Minutes between repeats · Fixed value',{exact:true}).inputValue(),'0');await d.getByLabel('Repeat count · Fixed value',{exact:true}).fill('2');await d.getByLabel('Minutes between repeats · Fixed value',{exact:true}).fill('10');await p.waitForFunction(()=>{const a=Alpine.$data(document.body);return !a.generationLoading&&a.generationResult;});assert.deepEqual(await p.evaluate(()=>Alpine.$data(document.body).generationResult.issues),[]);await d.locator('.generation-block').nth(1).screenshot({path:'test-results/repeat-defaults.png'});await d.getByRole('button',{name:'Use in draft',exact:true}).click();await p.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).click();assert.equal(await d.getByLabel('Repeat count · Fixed value',{exact:true}).inputValue(),'2');assert.equal(await d.getByLabel('Minutes between repeats · Fixed value',{exact:true}).inputValue(),'10');await d.getByRole('button',{name:'Cancel',exact:true}).click();const team=await p.evaluate(()=>Alpine.$data(document.body).directoryState.workspaces.find(t=>t.name==='Commerce Operations').id);await p.goto('http://127.0.0.1:3000/?view=configuration&workspace='+team);await p.getByRole('button',{name:'Edit rule Product → service tier',exact:true}).click();assert.equal(await p.evaluate(()=>Alpine.$data(document.body).form.blocks.find(b=>b.kind==='escalation').fields.repeat_count.kind),'lookup');assert.deepEqual(errors,[]);console.log('PASS optional repeat defaults, editing and draft reopening, explicit lookup preservation');}finally{await b.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
+1 -1
View File
@@ -1 +1 @@
const assert=require('node:assert/strict');(async()=>{const b=await require('./support/browser.cjs').launchBrowser();try{const p=await b.newPage({viewport:{width:1440,height:1050}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.goto('http://127.0.0.1:3000/?view=configuration');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.locator('.resource-row').first().waitFor();await p.getByRole('heading',{name:'Schedule setup',exact:true}).click();for(const [kind,index] of [['schedules',0],['schedules',1],['escalations',0],['routes',0]]){const row=p.locator('[data-resource-kind='+kind+']').nth(index);await row.locator('summary').click();await row.locator('.resource-widget').waitFor();assert(await row.locator('.widget-node').count()>0);await row.screenshot({path:'test-results/resource-'+kind+'-'+index+'.png'});await p.setViewportSize({width:390,height:844});await row.screenshot({path:'test-results/resource-'+kind+'-'+index+'-mobile.png'});assert((await row.boundingBox()).width<390);await p.setViewportSize({width:1440,height:1050});await row.locator('summary').click();}assert.deepEqual(errors,[]);console.log('PASS shared resource rows, schedule variants, escalation, routing, desktop and mobile');}finally{await b.close();}})().catch(e=>{console.error(e);process.exitCode=1;}); const assert=require('node:assert/strict');(async()=>{const b=await require('./support/browser.cjs').launchBrowser();try{const p=await b.newPage({viewport:{width:1440,height:1050}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.goto('http://127.0.0.1:3000/?view=configuration');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.locator('.resource-row').first().waitFor();await p.getByRole('heading',{name:'Schedule setup',exact:true}).click();for(const [kind,index] of [['schedules',0],['schedules',1],['escalations',0],['routes',0]]){const row=p.locator('[data-resource-kind='+kind+']').nth(index);await row.locator('summary').click();await row.locator('.resource-widget').waitFor();assert(await row.locator('.widget-node').count()>0);await row.screenshot({path:'test-results/resource-'+kind+'-'+index+'.png'});await p.setViewportSize({width:390,height:844});await row.screenshot({path:'test-results/resource-'+kind+'-'+index+'-mobile.png'});assert((await row.boundingBox()).width<390);await p.setViewportSize({width:1440,height:1050});await row.locator('summary').click();}assert.deepEqual(errors,[]);console.log('PASS shared resource rows, schedule variants, escalation, routing, desktop and mobile');}finally{await b.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
+1 -1
View File
@@ -1,7 +1,7 @@
const assert=require('node:assert/strict'); const assert=require('node:assert/strict');
(async()=>{const browser=await require('./support/browser.cjs').launchBrowser();try{ (async()=>{const browser=await require('./support/browser.cjs').launchBrowser();try{
const page=await browser.newPage({viewport:{width:1440,height:1000}}),errors=[];page.on('pageerror',e=>errors.push(e.message)); const page=await browser.newPage({viewport:{width:1440,height:1000}}),errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click(); await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await page.getByRole('button',{name:'Sign in',exact:true}).click();
await page.getByRole('button',{name:'Edit rule Project owners during working hours',exact:true}).click(); await page.getByRole('button',{name:'Edit rule Project owners during working hours',exact:true}).click();
const panel=page.locator('.rule-result');const ready=()=>page.waitForFunction(()=>{const a=Alpine.$data(document.body);return !a.generationLoading&&a.generationResult;});await ready(); const panel=page.locator('.rule-result');const ready=()=>page.waitForFunction(()=>{const a=Alpine.$data(document.body);return !a.generationLoading&&a.generationResult;});await ready();
assert.equal(await panel.locator('.rule-result-row').count(),18);const text=await panel.innerText();assert.match(text,/Payments/);assert.match(text,/Alice Morgan/);assert.match(text,/Time off/);assert.match(text,/Escalation/);assert.match(text,/Routing/);await panel.screenshot({path:'test-results/rule-result.png'}); assert.equal(await panel.locator('.rule-result-row').count(),18);const text=await panel.innerText();assert.match(text,/Payments/);assert.match(text,/Alice Morgan/);assert.match(text,/Time off/);assert.match(text,/Escalation/);assert.match(text,/Routing/);await panel.screenshot({path:'test-results/rule-result.png'});
+1 -1
View File
@@ -1,2 +1,2 @@
const assert=require('node:assert/strict'); const assert=require('node:assert/strict');
(async()=>{const browser=await require('./support/browser.cjs').launchBrowser();try{const page=await browser.newPage({viewport:{width:1440,height:1050}}),errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).waitFor();await page.waitForFunction(()=>{const a=Alpine.$data(document.body);return a.generationResult&&!a.generationLoading;});assert.deepEqual(await page.evaluate(()=>Alpine.$data(document.body).generationResult.issues),[]);assert.equal(await page.locator('.generation-review').getAttribute('open'),null);assert.equal(await page.locator('#public-holidays').getAttribute('open'),null);assert.equal((await page.locator('#public-holidays > .widget-heading .badge').innerText()).trim(),'33');assert.equal(await page.locator('.setup-jumps').count(),0);assert.equal(await page.getByText('Start with projects & responsible example',{exact:true}).count(),0);assert.equal(await page.locator('.resource-row[open]').count(),0);assert(await page.locator('.resource-row.generated-resource').count()>0);assert.equal(await page.locator('.resource-create > button').count(),2);assert(await page.locator('#availability').evaluate(el=>el.getBoundingClientRect().left > document.querySelector('.generation-panel:nth-child(2)').getBoundingClientRect().left));const placement=page.locator('.generated-routing-placement');assert(!await placement.isVisible());await page.evaluate(()=>{const a=Alpine.$data(document.body);a.doc.routes.push({id:'placement-test-b',name:'Catch all',order:200,conditions:[]},{id:'placement-test-a',name:'Critical alerts',order:100,conditions:[]});});await placement.waitFor({state:'visible'});assert.deepEqual(await placement.locator('option').allTextContents(),['Before manual rules','After “Critical alerts”','After “Catch all”']);await placement.locator('select').selectOption('1');assert.equal(await page.evaluate(()=>Alpine.$data(document.body).generationDraft.route_position),1);await page.evaluate(()=>{const a=Alpine.$data(document.body);a.doc.routes=a.doc.routes.filter(r=>!r.id.startsWith('placement-test-'));a.generationDraft.route_position=0;});await placement.waitFor({state:'hidden'});const row=page.locator('.resource-row').first();await row.locator('summary').click();await row.locator('.resource-body').waitFor();await row.locator('summary').click();await page.locator('.schedule-create > summary').click();await page.getByRole('button',{name:'+ Rotation',exact:true}).click();await page.getByRole('dialog').waitFor();await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();const overview=await page.evaluate(()=>{const a=Alpine.$data(document.body);return a.generationDraft.rules.map(r=>a.ruleOverview(r));});assert(overview.some(t=>t.includes('{ var.projects }')&&t.includes('Escalation')));assert(!overview[0].includes('Iterations'));for(const kind of ['schedules','escalations','routes']){const resource=page.locator('[data-resource-kind="'+kind+'"] .resource-summary').first();assert((await resource.locator('.resource-brief').innerText()).length>10);await resource.click();const link=resource.locator('..').locator('.source-rule-link');const name=(await link.innerText()).replace('Generated by: ','');await link.click();await page.waitForFunction(name=>Alpine.$data(document.body).form.name===name,name);await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();await resource.click();}await page.evaluate(()=>window.scrollTo(0,0));await page.screenshot({path:'test-results/setup-desktop.png',fullPage:true});const japan=page.locator('.holiday-group').filter({has:page.locator('summary').filter({hasText:'Japan'})});assert.equal(await japan.getAttribute('open'),null);assert.equal((await japan.locator('.badge').innerText()).trim(),'18');await japan.locator('summary').click();await japan.getByText('Coming of Age Day',{exact:true}).waitFor();await page.locator('#public-holidays').screenshot({path:'test-results/setup-holidays.png'});await page.getByRole('button',{name:'Import CSV',exact:true}).click();await page.getByRole('dialog').waitFor();await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();await page.setViewportSize({width:390,height:844});await page.evaluate(()=>window.scrollTo(0,0));await page.screenshot({path:'test-results/setup-mobile.png',fullPage:true});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));assert.deepEqual(errors,[]);console.log('PASS setup layout, collapsed review, 33 country holidays, CSV modal and mobile');}finally{await browser.close();}})().catch(e=>{console.error(e);process.exitCode=1;}); (async()=>{const browser=await require('./support/browser.cjs').launchBrowser();try{const page=await browser.newPage({viewport:{width:1440,height:1050}}),errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).waitFor();await page.waitForFunction(()=>{const a=Alpine.$data(document.body);return a.generationResult&&!a.generationLoading;});assert.deepEqual(await page.evaluate(()=>Alpine.$data(document.body).generationResult.issues),[]);assert.equal(await page.locator('.generation-review').getAttribute('open'),null);assert.equal(await page.locator('#public-holidays').getAttribute('open'),null);assert.equal((await page.locator('#public-holidays > .widget-heading .badge').innerText()).trim(),'36');assert.equal(await page.locator('.setup-jumps').count(),0);assert.equal(await page.getByText('Start with projects & responsible example',{exact:true}).count(),0);assert.equal(await page.locator('.resource-row[open]').count(),0);assert(await page.locator('.resource-row.generated-resource').count()>0);assert.equal(await page.locator('.resource-create > button').count(),2);assert(await page.locator('#availability').evaluate(el=>el.getBoundingClientRect().left > document.querySelector('.generation-panel:nth-child(2)').getBoundingClientRect().left));const placement=page.locator('.generated-routing-placement');assert(!await placement.isVisible());await page.evaluate(()=>{const a=Alpine.$data(document.body);a.doc.routes.push({id:'placement-test-b',name:'Catch all',order:200,conditions:[]},{id:'placement-test-a',name:'Critical alerts',order:100,conditions:[]});});await placement.waitFor({state:'visible'});assert.deepEqual(await placement.locator('option').allTextContents(),['Before manual rules','After “Critical alerts”','After “Catch all”']);await placement.locator('select').selectOption('1');assert.equal(await page.evaluate(()=>Alpine.$data(document.body).generationDraft.route_position),1);await page.evaluate(()=>{const a=Alpine.$data(document.body);a.doc.routes=a.doc.routes.filter(r=>!r.id.startsWith('placement-test-'));a.generationDraft.route_position=0;});await placement.waitFor({state:'hidden'});const row=page.locator('.resource-row').first();await row.locator('summary').click();await row.locator('.resource-body').waitFor();await row.locator('summary').click();await page.locator('.schedule-create > summary').click();await page.getByRole('button',{name:'+ Rotation',exact:true}).click();await page.getByRole('dialog').waitFor();await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();const overview=await page.evaluate(()=>{const a=Alpine.$data(document.body);return a.generationDraft.rules.map(r=>a.ruleOverview(r));});assert(overview.some(t=>t.includes('{ var.projects }')&&t.includes('Escalation')));assert(!overview[0].includes('Iterations'));for(const kind of ['schedules','escalations','routes']){const resource=page.locator('[data-resource-kind="'+kind+'"] .resource-summary').first();assert((await resource.locator('.resource-brief').innerText()).length>10);await resource.click();const link=resource.locator('..').locator('.source-rule-link');const name=(await link.innerText()).replace('Generated by: ','');await link.click();await page.waitForFunction(name=>Alpine.$data(document.body).form.name===name,name);await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();await resource.click();}await page.evaluate(()=>window.scrollTo(0,0));await page.screenshot({path:'test-results/setup-desktop.png',fullPage:true});for(const [country,count] of [['Cyprus','15'],['United Kingdom','8'],['Armenia','13']]){const group=page.locator('.holiday-group').filter({has:page.locator('summary').filter({hasText:country})});assert.equal(await group.getAttribute('open'),null,country);assert.equal((await group.locator('.badge').innerText()).trim(),count,country);}const armenia=page.locator('.holiday-group').filter({has:page.locator('summary').filter({hasText:'Armenia'})});await armenia.locator('summary').click();await armenia.getByText('Independence Day',{exact:true}).waitFor();await page.locator('#public-holidays').screenshot({path:'test-results/setup-holidays.png'});await page.getByRole('button',{name:'Import CSV',exact:true}).click();await page.getByRole('dialog').waitFor();await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();await page.setViewportSize({width:390,height:844});await page.evaluate(()=>window.scrollTo(0,0));await page.screenshot({path:'test-results/setup-mobile.png',fullPage:true});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));assert.deeLine truncated
-65
View File
@@ -1,65 +0,0 @@
const assert=require('node:assert/strict');
const fs=require('node:fs');
const {launchBrowser}=require('./support/browser.cjs');
const base=process.env.MEGURI_BROWSER_URL||'http://127.0.0.1:3011';
const username=process.env.MEGURI_BROWSER_SITE_USERNAME;
assert(username,'Set MEGURI_BROWSER_SITE_USERNAME to a dedicated test administrator');
assert(['localhost','127.0.0.1'].includes(new URL(base).hostname));
(async()=>{
const browser=await launchBrowser();
try{
const context=await browser.newContext({locale:'en-US',viewport:{width:1400,height:1050}});
const page=await context.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));page.on('dialog',d=>d.accept());
await page.goto(base+'/?view=site');
await page.getByLabel('Username',{exact:true}).fill(username);
await page.getByLabel('Password',{exact:true}).fill(process.env.MEGURI_BROWSER_SITE_PASSWORD||'local-browser-test-password');
await page.getByRole('button',{name:'Sign in',exact:true}).click();
await page.getByRole('link',{name:'Site administration',exact:true}).click();
await page.waitForFunction(()=>Alpine.$data(document.body).siteData);
const root=page.locator('.site-admin');
await root.getByLabel('Search',{exact:true}).fill(username);
await root.getByRole('button',{name:'Edit',exact:true}).click();
await root.getByLabel('Display name',{exact:true}).fill('Administration browser test');
await root.getByRole('button',{name:'Save changes',exact:true}).click();
await page.waitForFunction(()=>!Alpine.$data(document.body).busy&&!Alpine.$data(document.body).siteEdit);
await root.getByRole('button',{name:'Authentication',exact:true}).click();
assert(await root.getByText(/Configuration draft only/).isVisible());
await root.getByLabel('Enable OIDC single sign-on',{exact:true}).check();
await root.getByLabel('Allow password login',{exact:true}).uncheck();
await root.getByLabel('Issuer URL',{exact:true}).fill('https://identity.example.com/realms/company');
await root.getByLabel('Client ID',{exact:true}).fill('meguri-browser-test');
await root.getByLabel('Login button label',{exact:true}).fill('Company SSO');
await root.getByRole('button',{name:'Save configuration draft',exact:true}).click();
await page.waitForFunction(()=>!Alpine.$data(document.body).busy);
assert.equal(await page.locator('.error:visible').count(),0);
await page.reload();await page.waitForFunction(()=>Alpine.$data(document.body).siteData);
await root.getByRole('button',{name:'Authentication',exact:true}).click();
assert.equal(await root.getByLabel('Login button label',{exact:true}).inputValue(),'Company SSO');
assert.equal(await root.getByLabel('Allow password login',{exact:true}).isChecked(),false);
fs.mkdirSync('test-results',{recursive:true});await page.screenshot({path:'test-results/site-admin-desktop.png',fullPage:true});
await page.setViewportSize({width:390,height:844});await page.screenshot({path:'test-results/site-admin-mobile.png',fullPage:true});
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));
// Current password authentication stays active despite the saved draft.
const guest=await browser.newContext({locale:'en-US'});const other=await guest.newPage();await other.goto(base);
await other.getByLabel('Username',{exact:true}).fill(username);await other.getByLabel('Password',{exact:true}).fill(process.env.MEGURI_BROWSER_SITE_PASSWORD||'local-browser-test-password');await other.getByRole('button',{name:'Sign in',exact:true}).click();await other.waitForFunction(()=>Alpine.$data(document.body).user);
// Non-admins cannot enumerate the site, even with direct requests.
const ordinary=await browser.newContext();const userPage=await ordinary.newPage();await userPage.goto(base);
const denied=await userPage.evaluate(async()=>{const username='site-member-'+Date.now();const r=await fetch('/api/register',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username,email:username+'@example.com',display_name:'Ordinary user',password:'local-browser-test-password'})});if(!r.ok)throw new Error('signup');return (await fetch('/api/site')).status;});assert.equal(denied,403);
const created=await userPage.evaluate(async()=>{const session=await (await fetch('/api/session')).json();const result=await fetch('/api/workspaces',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':session.csrf},body:JSON.stringify({name:'Site UI workspace '+Date.now()})});if(!result.ok)throw new Error('workspace');return {workspace:await result.json(),user:session.user};});
await page.setViewportSize({width:1400,height:1050});
await root.getByRole('button',{name:'Refresh',exact:true}).click();
await page.waitForFunction(()=>!Alpine.$data(document.body).siteLoading);
await root.getByRole('button',{name:'Workspaces',exact:true}).click();
await root.getByLabel('Search',{exact:true}).fill('Site UI workspace');
await root.getByRole('button',{name:'Edit',exact:true}).last().click();
await root.getByLabel('Name',{exact:true}).fill('Site UI renamed workspace');
await root.getByLabel('Grant owner access to',{exact:true}).selectOption({label:username});
await root.getByRole('button',{name:'Save changes',exact:true}).click();
await page.waitForFunction(()=>!Alpine.$data(document.body).busy&&!Alpine.$data(document.body).siteEdit);
assert.equal(await page.locator('.error:visible').count(),0);
assert(await page.evaluate(()=>Alpine.$data(document.body).directoryState.workspaces.some(w=>w.name==='Site UI renamed workspace')));
assert.deepEqual(errors,[]);
await context.close();await guest.close();await ordinary.close();
console.log('PASS: site navigation, profile editing, persisted OIDC draft, unchanged password login, denied non-admin access, desktop/mobile');
}finally{await browser.close();}
})().catch(e=>{console.error(e);process.exitCode=1;});
+1 -1
View File
@@ -1,3 +1,3 @@
const assert=require('node:assert/strict'); const assert=require('node:assert/strict');
(async()=>{const browser=await require('./support/browser.cjs').launchBrowser();try{const page=await browser.newPage({viewport:{width:1440,height:1000}});const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.getByRole('button',{name:'Edit variable projects',exact:true}).click();const d=page.getByRole('dialog');assert.equal(await d.locator('.data-row').count(),7);assert.equal(await d.getByLabel('Value type',{exact:true}).filter({visible:true}).count(),0);await page.screenshot({path:'test-results/compact-variables.png'});await d.getByRole('button',{name:'Expand or collapse Payments',exact:true}).click();await d.getByLabel('Text value',{exact:true}).fill('Payments edited');await d.getByRole('button',{name:'Expand or collapse responsible',exact:true}).click();assert.equal(await d.getByLabel('Registered person',{exact:true}).count(),3);const verifyIndent=async()=>{const geometry=await d.locator('.data-row').evaluateAll(es=>es.slice(0,4).map(e=>({depth:Number([...e.classList].find(c=>c.startsWith('data-depth-')).slice(11)),padding:parseFloat(getComputedStyle(e).paddingLeft)})));assert(geometry[0].padding>=12);assert(geometry[1].padding>geometry[0].padding);assert(geometry[2].padding>geometry[1].padding);};await verifyIndent();await d.getByRole('button',{name:'Structure',exact:true}).click();await d.getByLabel('Value type',{exact:true}).first().waitFor({state:'visible'});await d.getByRole('button',{name:'Values',exact:true}).click();assert.equal(await d.getByLabel('Text value',{exact:true}).inputValue(),'Payments edited');await d.getByRole('button',{name:'Duplicate entry',exact:true}).first().click();const ids=await page.evaluate(()=>{const root=Alpine.$data(document.body).form.node,out=[];const walk=n=>{out.push(n.id);n.entries.forEach(walk)};walk(root);return out});assert.equal(ids.length,new Set(ids).size);await page.setViewportSize({width:390,height:844});await verifyIndent();await page.screenshot({path:'test-results/compact-variables-mobile.png'});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));assert.deepEqual(errors,[]);console.log('PASS compact nested editor, retained values, schema controls, fresh duplicate IDs, mobile');}finally{await browser.close()}})().catch(e=>{console.error(e);process.exitCode=1}); (async()=>{const browser=await require('./support/browser.cjs').launchBrowser();try{const page=await browser.newPage({viewport:{width:1440,height:1000}});const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.getByRole('button',{name:'Edit variable projects',exact:true}).click();const d=page.getByRole('dialog');assert.equal(await d.locator('.data-row').count(),7);assert.equal(await d.getByLabel('Value type',{exact:true}).filter({visible:true}).count(),0);await page.screenshot({path:'test-results/compact-variables.png'});await d.getByRole('button',{name:'Expand or collapse Payments',exact:true}).click();await d.getByLabel('Text value',{exact:true}).fill('Payments edited');await d.getByRole('button',{name:'Expand or collapse responsible',exact:true}).click();assert.equal(await d.getByLabel('Registered person',{exact:true}).count(),3);const verifyIndent=async()=>{const geometry=await d.locator('.data-row').evaluateAll(es=>es.slice(0,4).map(e=>({depth:Number([...e.classList].find(c=>c.startsWith('data-depth-')).slice(11)),padding:parseFloat(getComputedStyle(e).paddingLeft)})));assert(geometry[0].padding>=12);assert(geometry[1].padding>geometry[0].padding);assert(geometry[2].padding>geometry[1].padding);};await verifyIndent();await d.getByRole('button',{name:'Structure',exact:true}).click();await d.getByLabel('Value type',{exact:true}).first().waitFor({state:'visible'});await d.getByRole('button',{name:'Values',exact:true}).click();assert.equal(await d.getByLabel('Text value',{exact:true}).inputValue(),'Payments edited');await d.getByRole('button',{name:'Duplicate entry',exact:true}).first().click();const ids=await page.evaluate(()=>{const root=Alpine.$data(document.body).form.node,out=[];const walk=n=>{out.push(n.id);n.entries.forEach(walk)};walk(root);return out});assert.equal(ids.length,new Set(ids).size);await page.setViewportSize({width:390,height:844});await verifyIndent();await page.screenshot({path:'test-results/compact-variables-mobile.png'});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));assert.deepEqual(errors,[]);console.log('PASS compact nested editor, retained values, schema controls, fresh duplicate IDs, mobile');}finally{await browser.close()}})().catch(e=>{console.error(e);process.exitCode=1});
+25 -13
View File
@@ -1,4 +1,4 @@
const {openSetup}=require('./support/browser.cjs'); const {openSetup,isolatedUser,demoPassword}=require('./support/browser.cjs');
// Run against an explicitly seeded localhost development instance. // Run against an explicitly seeded localhost development instance.
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
@@ -9,11 +9,11 @@ fs.mkdirSync('test-results', { recursive: true });
(async () => { (async () => {
const browser = await require('./support/browser.cjs').launchBrowser(); const browser = await require('./support/browser.cjs').launchBrowser();
const errors = []; const errors = [];
async function signIn(name) { async function signIn(name, secret) {
const context = await browser.newContext({ viewport: { width: 1440, height: 1100 } }); const context = await browser.newContext({ viewport: { width: 1440, height: 1100 } });
const page = await context.newPage(); page.setDefaultTimeout(10000); page.on('pageerror', error => errors.push(error.message)); const page = await context.newPage(); page.setDefaultTimeout(10000); page.on('pageerror', error => errors.push(error.message));
await page.goto(base); await page.getByLabel('Username', { exact: true }).fill(name); await page.goto(base); await page.getByLabel('Username', { exact: true }).fill(name);
await page.getByLabel('Password', { exact: true }).fill('meguri-demo-only'); await page.getByLabel('Password', { exact: true }).fill(secret ?? await demoPassword());
await page.getByRole('button', { name: 'Sign in', exact: true }).click(); await page.getByRole('button', { name: 'Sign in', exact: true }).click();
await page.getByRole('heading', { name: 'Calendar', exact: true }).waitFor(); await page.getByRole('heading', { name: 'Calendar', exact: true }).waitFor();
return page; return page;
@@ -23,22 +23,30 @@ fs.mkdirSync('test-results', { recursive: true });
await page.getByRole('dialog').waitFor({ state: 'hidden' }); await page.getByRole('dialog').waitFor({ state: 'hidden' });
} }
try { try {
const alice = await signIn('alice'); // Workspaces come only from configuration: this suite owns Sandbox 1 (an administrator and one employee).
const label = 'Sandbox 1';
const team1 = await isolatedUser({ sandbox: 1, prefix: 'browser-lead', display: 'Alice Morgan', employees: ['Bob Chen'] });
const [bobAccount] = team1.staff;
const alice = await signIn(team1.username, team1.password);
await alice.locator('.day').first().waitFor(); await alice.locator('.day').first().waitFor();
await alice.screenshot({ path: 'test-results/calendar-desktop.png', fullPage: true }); await alice.screenshot({ path: 'test-results/calendar-desktop.png', fullPage: true });
const label = 'Browser ' + Date.now(); const workspaceId = await alice.evaluate(label => Alpine.$data(document.body).directoryState.workspaces.find(w=>w.name===label).id, label);
await alice.getByRole('button', { name: 'New workspace' }).click(); await alice.getByLabel('Workspace', { exact: true }).selectOption(workspaceId);
await alice.getByRole('dialog').getByLabel('Name', { exact: true }).fill(label); await save(alice); await alice.waitForFunction(id => Alpine.$data(document.body).workspace?.id===id, workspaceId);
const created = await alice.evaluate(() => { const a=Alpine.$data(document.body); return {record:a.workspace, listing:a.directoryState}; }); const created = await alice.evaluate(() => { const a=Alpine.$data(document.body); return {record:a.workspace, listing:a.directoryState}; });
assert.deepEqual(Object.keys(created.listing), ['workspaces']); assert.deepEqual(Object.keys(created.listing), ['workspaces']);
assert.equal(created.listing.workspaces.filter(w=>w.name===label).length,1); assert.equal(created.listing.workspaces.filter(w=>w.name===label).length,1);
assert.equal(created.record.document.name,label); assert.equal(created.record.document.name,label);
assert.equal(created.record.revision,1);
assert.equal('tenant_id' in created.record,false); assert.equal('tenant_id' in created.record,false);
assert.equal((await alice.request.get(base+'/api/tenants')).status(),404); assert.equal((await alice.request.get(base+'/api/tenants')).status(),404);
const session0 = await alice.evaluate(() => fetch('/api/session').then(r=>r.json()));
const creation = await alice.request.post(base+'/api/workspaces', { headers: { Origin: base, 'X-CSRF-Token': session0.csrf }, data: { name: 'Nope' } });
assert([404,405].includes(creation.status()), 'Workspaces cannot be created through the API');
await openSetup(alice,'Workspace settings'); await openSetup(alice,'Workspace settings');
await alice.getByRole('button', { name: '+ Add teammate' }).click(); await alice.locator('#workspace-members').getByText('Bob Chen',{exact:true}).waitFor();
await alice.getByRole('dialog').getByLabel('Registered username').fill('bob'); await save(alice); assert.equal(await alice.getByRole('button', { name: '+ Add teammate' }).count(),0);
assert.equal(await alice.locator(`#workspace-members [data-username=${bobAccount.username}] .role-badge`).innerText(),'Employee');
assert.equal(await alice.locator(`#workspace-members [data-username=${team1.username}] .role-badge`).innerText(),'Workspace administrator');
await openSetup(alice,'Schedules'); await openSetup(alice,'Schedules');
await alice.locator('.schedule-create > summary').click(); await alice.locator('.schedule-create > summary').click();
await alice.getByRole('button', { name: '+ Rotation', exact: true }).click(); await alice.getByRole('button', { name: '+ Rotation', exact: true }).click();
@@ -52,11 +60,15 @@ fs.mkdirSync('test-results', { recursive: true });
await alice.getByRole('dialog').getByLabel('Comment (optional)', {exact:true}).fill('Browser regression: planned appointment'); await alice.getByRole('dialog').getByLabel('Comment (optional)', {exact:true}).fill('Browser regression: planned appointment');
await alice.getByRole('dialog').getByRole('button', {name:'Send request',exact:true}).click(); await alice.getByRole('dialog').getByRole('button', {name:'Send request',exact:true}).click();
await alice.getByRole('dialog').waitFor({state:'hidden'}); await alice.getByRole('dialog').waitFor({state:'hidden'});
const bob = await signIn('bob'); const bob = await signIn(bobAccount.username, bobAccount.password);
const selectedTeam = await bob.evaluate(label => Alpine.$data(document.body).directoryState.workspaces.find(t=>t.name===label).id,label); const selectedTeam = await bob.evaluate(label => Alpine.$data(document.body).directoryState.workspaces.find(t=>t.name===label).id,label);
await bob.getByLabel('Workspace', { exact: true }).selectOption(selectedTeam); await bob.getByLabel('Workspace', { exact: true }).selectOption(selectedTeam);
await bob.waitForFunction(id => { const a=Alpine.$data(document.body); return a.workspace?.id===id && !a.busy; }, selectedTeam);
await bob.getByRole('link', { name: /Requests/ }).click(); await bob.getByRole('link', { name: /Requests/ }).click();
await bob.getByRole('button', { name: 'Accept', exact: true }).click(); await bob.getByRole('heading', { name: 'Requests', exact: true }).waitFor();
await bob.waitForFunction(() => { const a=Alpine.$data(document.body); return !a.busy && a.needsMe.length>0; });
// The calendar's reply panel holds a hidden twin of this button; target the inbox row's actions.
await bob.locator('.request-card .actions button.primary', { hasText: /^Accept$/ }).click();
await bob.locator('.badge.applied').waitFor(); await bob.locator('.badge.applied').waitFor();
await bob.screenshot({ path: 'test-results/approved-request.png', fullPage: true }); await bob.screenshot({ path: 'test-results/approved-request.png', fullPage: true });
await bob.getByRole('link', { name: 'Calendar', exact: true }).click(); await bob.getByRole('link', { name: 'Calendar', exact: true }).click();
@@ -77,7 +89,7 @@ fs.mkdirSync('test-results', { recursive: true });
await bob.screenshot({ path:'test-results/calendar-mobile.png',fullPage:true }); await bob.screenshot({ path:'test-results/calendar-mobile.png',fullPage:true });
assert(await bob.evaluate(() => document.documentElement.scrollWidth<=innerWidth), 'Mobile layout must not overflow'); assert(await bob.evaluate(() => document.documentElement.scrollWidth<=innerWidth), 'Mobile layout must not overflow');
assert.deepEqual(errors, [], 'No browser runtime errors'); assert.deepEqual(errors, [], 'No browser runtime errors');
console.log('PASS: calendar, workspace creation, membership, rotation, request, other-user approval, workspace isolation, CSRF/origin, mobile layout'); console.log('PASS: calendar, configured workspaces (no creation API), role-labelled membership, rotation, request, other-user approval, workspace isolation, CSRF/origin, mobile layout');
} catch (error) { } catch (error) {
console.error('Browser errors:', errors); console.error('Browser errors:', errors);
for(const context of browser.contexts()) for(const page of context.pages()) { for(const context of browser.contexts()) for(const page of context.pages()) {
+326
View File
@@ -0,0 +1,326 @@
//! Demonstration lifecycle. Kept in its own test binary because installing the demonstration
//! notice is process-wide and disables real provider writes.
mod support;
use chrono::{Duration, Utc};
use cot::db::query;
use meguri::{domain::*, engine, models::SyncJobRow, service};
use uuid::Uuid;
#[tokio::test]
#[ignore = "requires MEGURI_TEST_DATABASE_URL and CREATEDB for isolated lifecycle tests"]
async fn demo_bootstrap_protects_unmarked_and_normal_databases_and_resets_only_application_state() {
use cot::db::{Database, Model};
use meguri::{
demo_runtime as runtime,
models::{UserRow, WorkspaceRow},
store::Store,
};
let admin = Database::new(std::env::var("MEGURI_TEST_DATABASE_URL").unwrap())
.await
.unwrap();
let name = format!("meguri_demo_test_{}", Uuid::new_v4().simple());
admin.raw(&format!("CREATE DATABASE {name}")).await.unwrap();
let mut url = reqwest::Url::parse(&std::env::var("MEGURI_TEST_DATABASE_URL").unwrap()).unwrap();
url.set_path(&format!("/{name}"));
let source_url = runtime::application_url(url.as_str(), false).unwrap();
let source = Store::connect(&source_url).await.unwrap();
support::install_demo_notice();
let owner = meguri::identity::new_user_row("protected-owner".into(), "Keep me".into(), None);
owner.clone().insert(&source.db).await.unwrap();
let tenant = meguri::identity::ensure_workspace(
&source,
&support::unique_workspace("Protected workspace"),
)
.await
.unwrap();
assert!(!runtime::is_demo_database(&source.db).await);
assert!(runtime::reset(&source.db).await.is_err());
let alternate_name = format!("meguri_demo_test_{}", Uuid::new_v4().simple());
url.set_path(&format!("/{alternate_name}"));
let (selected, isolated) = runtime::select_database(&source_url, Some(url.as_str()))
.await
.unwrap();
assert!(isolated);
assert_ne!(
reqwest::Url::parse(&selected).unwrap().path(),
reqwest::Url::parse(&source_url).unwrap().path()
);
let demo = Store::connect(&selected).await.unwrap();
assert!(runtime::is_demo_database(&demo.db).await);
meguri::demo::seed(&demo).await.unwrap();
assert_eq!(UserRow::objects().all(&demo.db).await.unwrap().len(), 6);
assert_eq!(
WorkspaceRow::objects().all(&demo.db).await.unwrap().len(),
4
);
let old_user = query!(UserRow,$username=="alice")
.get(&demo.db)
.await
.unwrap()
.unwrap();
demo.db.raw("DO $$ BEGIN CREATE TABLE unrelated_sentinel(value text); INSERT INTO unrelated_sentinel VALUES('keep'); END $$").await.unwrap();
runtime::reset(&demo.db).await.unwrap();
assert!(UserRow::objects().all(&demo.db).await.unwrap().is_empty());
assert!(runtime::is_demo_database(&demo.db).await);
demo.db.raw("DO $$ BEGIN IF NOT EXISTS(SELECT 1 FROM unrelated_sentinel WHERE value='keep') THEN RAISE EXCEPTION 'unrelated data lost'; END IF; END $$").await.unwrap();
let rerun = Store::connect(&selected).await.unwrap();
meguri::demo::seed(&rerun).await.unwrap();
assert_ne!(
query!(UserRow,$username=="alice")
.get(&rerun.db)
.await
.unwrap()
.unwrap()
.id,
old_user.id
);
runtime::mark_normal(&source.db).await.unwrap();
assert!(!runtime::is_demo_database(&source.db).await);
assert!(runtime::reset(&source.db).await.is_err());
assert!(
query!(WorkspaceRow,$id==tenant.id.to_string())
.exists(&source.db)
.await
.unwrap()
);
assert!(
runtime::cleanup(
&source,
Utc::now() + Duration::days(100),
&runtime::Retention::default()
)
.await
.is_err()
);
assert!(
query!(UserRow,$id==owner.id.clone())
.exists(&source.db)
.await
.unwrap()
);
// Names are generated above exclusively for this test; never drop the configured DB.
admin
.raw(&format!("DROP DATABASE {alternate_name} WITH (FORCE)"))
.await
.unwrap();
admin
.raw(&format!("DROP DATABASE {name} WITH (FORCE)"))
.await
.unwrap();
}
#[tokio::test]
#[ignore = "requires MEGURI_TEST_DATABASE_URL and CREATEDB for isolated lifecycle tests"]
async fn demo_retention_soft_deletes_then_purges_by_creation_and_reseeds_examples() {
use cot::db::{Database, Model};
use meguri::{
demo_runtime as runtime,
models::{UserRow, WorkspaceRow},
store::Store,
};
let admin = Database::new(std::env::var("MEGURI_TEST_DATABASE_URL").unwrap())
.await
.unwrap();
let name = format!("meguri_demo_test_{}", Uuid::new_v4().simple());
admin.raw(&format!("CREATE DATABASE {name}")).await.unwrap();
let mut url = reqwest::Url::parse(&std::env::var("MEGURI_TEST_DATABASE_URL").unwrap()).unwrap();
url.set_path(&format!("/{name}"));
let url = runtime::application_url(url.as_str(), true).unwrap();
let raw = Database::new(&url).await.unwrap();
runtime::claim_empty(&raw).await.unwrap();
let db = Store::connect(&url).await.unwrap();
support::install_demo_notice();
meguri::demo::seed(&db).await.unwrap();
let alice = query!(UserRow,$username=="alice")
.get(&db.db)
.await
.unwrap()
.unwrap();
let alice_id = Uuid::parse_str(&alice.id).unwrap();
// A temporary workspace and account next to the permanent examples, both granted through
// the ordinary group mapping.
let mut config = meguri::demo::config().unwrap();
let temporary = support::unique_workspace("Temporary team");
config.workspaces.push(temporary.clone());
meguri::identity::ensure_workspace(&db, &temporary)
.await
.unwrap();
let visitor =
support::account(&db, &config, "Temporary visitor", &temporary.admin_groups).await;
let mut alice_groups = meguri::identity::stored_groups(&alice).unwrap();
alice_groups.extend(temporary.member_groups.clone());
meguri::identity::apply_access(&db, &config, alice_id, Some(alice_groups))
.await
.unwrap();
let mut team = meguri::identity::find_workspace(&db, &temporary.key)
.await
.unwrap()
.unwrap();
let tenant = team.clone();
let original_revision = team.revision;
// Editing does not extend lifetime: created_at is the expiration anchor.
let mut doc = team.document.clone();
doc.name = "Edited temporary team".into();
team = service::update_document(&db, visitor.id, team.id, team.revision, doc)
.await
.unwrap();
assert!(team.revision > original_revision);
let now = query!(WorkspaceRow,$id==team.id.to_string())
.get(&db.db)
.await
.unwrap()
.unwrap()
.created_at;
let schedule = Uuid::new_v4();
let mut document = team.document.clone();
document.schedules.push(Schedule {
id: schedule,
name: "Temporary rotation".into(),
handover: default_handover(),
kind: ScheduleKind::Rotation {
participants: vec![visitor.id],
anchor: now.date_naive(),
shift_days: 7,
},
});
team = service::update_document(&db, visitor.id, team.id, team.revision, document)
.await
.unwrap();
let start = engine::handover(&team.document, now.date_naive() + Duration::days(2)).unwrap();
team = service::request_replacement(
&db,
visitor.id,
team.id,
service::NewRequest {
remaining: false,
revision: team.revision,
reason: "Demo coverage".into(),
force: false,
legs: vec![ReplacementLeg {
schedule_id: schedule,
start,
end: start + Duration::hours(1),
from_user: visitor.id,
to_user: alice_id,
}],
},
)
.await
.unwrap();
let ttl = runtime::Retention {
workspaces: 120,
accounts: 300,
requests: 60,
events: 180,
purge_grace: 30,
};
runtime::cleanup(&db, now + Duration::seconds(61), &ttl)
.await
.unwrap();
let after = db.workspace(team.id).await.unwrap();
assert!(after.document.requests.is_empty());
assert_eq!(after.revision, team.revision + 1);
assert_eq!(
query!(SyncJobRow,$workspace_id==team.id.to_string())
.get(&db.db)
.await
.unwrap()
.unwrap()
.requested_revision,
after.revision
);
runtime::cleanup(&db, now + Duration::seconds(119), &ttl)
.await
.unwrap();
assert!(db.workspace(team.id).await.is_ok());
runtime::cleanup(&db, now + Duration::seconds(121), &ttl)
.await
.unwrap();
assert!(db.workspace(team.id).await.is_err());
assert!(
query!(WorkspaceRow,$id==team.id.to_string())
.get(&db.db)
.await
.unwrap()
.unwrap()
.demo_deleted_at
.is_some()
);
assert!(
service::list_workspaces(&db, visitor.id).await.unwrap()["workspaces"]
.as_array()
.unwrap()
.is_empty()
);
assert!(
service::update_document(
&db,
visitor.id,
team.id,
team.revision,
team.document.clone()
)
.await
.is_err()
);
runtime::cleanup(&db, now + Duration::seconds(152), &ttl)
.await
.unwrap();
assert!(
!query!(WorkspaceRow,$id==team.id.to_string())
.exists(&db.db)
.await
.unwrap()
);
runtime::cleanup(&db, now + Duration::seconds(242), &ttl)
.await
.unwrap();
assert!(
service::list_workspaces(&db, alice_id).await.unwrap()["workspaces"]
.as_array()
.unwrap()
.is_empty()
);
runtime::cleanup(&db, now + Duration::seconds(301), &ttl)
.await
.unwrap();
assert!(
service::login(
&db,
true,
visitor.username.clone(),
"test-only-password".into()
)
.await
.is_err()
);
runtime::cleanup(&db, now + Duration::seconds(332), &ttl)
.await
.unwrap();
assert!(
!query!(UserRow,$id==visitor.id.to_string())
.exists(&db.db)
.await
.unwrap()
);
assert!(
!query!(WorkspaceRow,$id==tenant.id.to_string())
.exists(&db.db)
.await
.unwrap()
);
assert_eq!(UserRow::objects().all(&db.db).await.unwrap().len(), 6);
meguri::demo::seed(&db).await.unwrap();
assert_eq!(
service::list_workspaces(&db, alice_id).await.unwrap()["workspaces"]
.as_array()
.unwrap()
.len(),
4
);
assert_eq!(WorkspaceRow::objects().all(&db.db).await.unwrap().len(), 4);
admin
.raw(&format!("DROP DATABASE {name} WITH (FORCE)"))
.await
.unwrap();
}
+2
View File
@@ -121,6 +121,7 @@ fn priority_skips_absence_country_holidays_then_uses_effective_fallback() {
email: None, email: None,
display_name: "Person".into(), display_name: "Person".into(),
country: Some(if id == e.people[0] { "CY" } else { "AM" }.into()), country: Some(if id == e.people[0] { "CY" } else { "AM" }.into()),
aliases: vec![],
})); }));
doc.holidays.push(Holiday { doc.holidays.push(Holiday {
country: "CY".into(), country: "CY".into(),
@@ -168,6 +169,7 @@ fn priority_skips_absence_country_holidays_then_uses_effective_fallback() {
email: Some("cover@example.com".into()), email: Some("cover@example.com".into()),
display_name: "Cover".into(), display_name: "Cover".into(),
country: Some("CY".into()), country: Some("CY".into()),
aliases: vec![],
}); });
doc.requests.push(ReplacementRequest { doc.requests.push(ReplacementRequest {
effective_legs: None, effective_legs: None,
+77 -850
View File
File diff suppressed because it is too large. Load diff
+461
View File
@@ -0,0 +1,461 @@
//! End-to-end SSO over HTTP: the real Cot application, a real PostgreSQL database and a fake
//! OpenID provider. One test function because the instance configuration is process-wide.
#[path = "support/fake_oidc.rs"]
mod fake_oidc;
mod support;
use cot::{
AppBuilder,
config::{DatabaseConfig, ProjectConfig},
db::query,
project::RegisterAppsContext,
test::TestServerBuilder,
};
use fake_oidc::{CLIENT_ID, CLIENT_SECRET, Scenario};
use meguri::{
access::{self, Config},
identity::{self, BootstrapPasswords, SyncOptions},
models::{ExternalIdentityRow, UserRow},
oidc,
web::MeguriApp,
};
use reqwest::{Client, Response, StatusCode, redirect::Policy};
use serde_json::{Value, json};
use support::IsolatedDatabase;
struct TestProject {
database: String,
}
impl cot::Project for TestProject {
fn config(&self, _: &str) -> cot::Result<ProjectConfig> {
Ok(ProjectConfig::builder()
.debug(false)
.database(DatabaseConfig::builder().url(self.database.clone()).build())
.build())
}
fn register_apps(&self, apps: &mut AppBuilder, _: &RegisterAppsContext) {
apps.register_with_views(MeguriApp, "");
}
}
fn cookies(response: &Response) -> Vec<String> {
response
.headers()
.get_all("set-cookie")
.iter()
.map(|v| v.to_str().unwrap().to_owned())
.collect()
}
fn cookie_value(response: &Response, name: &str) -> Option<String> {
cookies(response).iter().find_map(|c| {
let value = c.strip_prefix(&format!("{name}="))?.split(';').next()?;
(!value.is_empty()).then(|| value.to_owned())
})
}
fn location(response: &Response) -> String {
response
.headers()
.get("location")
.expect("redirect location")
.to_str()
.unwrap()
.to_owned()
}
fn claims(subject: &str, name: &str, groups: &[&str]) -> Value {
json!({
"sub":subject,"preferred_username":name,"name":format!("{name} Person"),
"email":format!("{name}@corp.test"),"groups":groups
})
}
struct Harness {
client: Client,
base: String,
idp: fake_oidc::Server,
}
impl Harness {
async fn begin(&self) -> (String, String) {
let response = self
.client
.get(format!("{}/api/oidc/login", self.base))
.send()
.await
.unwrap();
assert!(response.status().is_redirection(), "{}", response.status());
let flow = cookies(&response)
.into_iter()
.find(|c| c.starts_with("meguri_oidc="))
.unwrap();
for attribute in ["HttpOnly", "SameSite=Lax", "Path=/api/oidc", "Max-Age=600"] {
assert!(flow.contains(attribute), "{flow}");
}
let state = flow["meguri_oidc=".len()..].split(';').next().unwrap();
(location(&response), state.to_owned())
}
async fn callback(&self, query: &str, cookie: Option<&str>) -> Response {
let mut request = self
.client
.get(format!("{}/api/oidc/callback?{query}", self.base));
if let Some(cookie) = cookie {
request = request.header("cookie", format!("meguri_oidc={cookie}"));
}
request.send().await.unwrap()
}
async fn attempt(&self, scenario: Scenario) -> Response {
let (url, state) = self.begin().await;
let code = self.idp.authorize(&url, scenario);
self.callback(&format!("code={code}&state={state}"), Some(&state))
.await
}
async fn session(&self, token: &str) -> Value {
self.client
.get(format!("{}/api/session", self.base))
.header("cookie", format!("meguri_session={token}"))
.send()
.await
.unwrap()
.json()
.await
.unwrap()
}
async fn signed_in(&self, response: &Response) -> String {
assert!(response.status().is_redirection());
assert_eq!(location(response), "/", "{:?}", cookies(response));
cookie_value(response, "meguri_session").expect("session cookie")
}
fn refused(&self, response: &Response, code: &str) -> String {
assert!(response.status().is_redirection());
let target = location(response);
assert!(
target.starts_with(&format!("/?login_error={code}&ref=")),
"{target}"
);
assert!(
cookie_value(response, "meguri_session").is_none(),
"a refused sign-in never sets a session"
);
target.split("ref=").nth(1).unwrap().to_owned()
}
}
#[cot::e2e_test]
#[ignore = "requires MEGURI_TEST_DATABASE_URL and CREATEDB"]
async fn sso_login_over_http_validates_tokens_maps_groups_and_never_leaks_a_session() {
let iso = IsolatedDatabase::new().await;
let idp = fake_oidc::Server::start().await;
let project = TestProject {
database: iso.url().await,
};
let server = TestServerBuilder::new(project).start().await;
let base = server.url();
// The process-wide settings can only be known once both servers have addresses.
// SAFETY: this binary has a single test function; nothing else reads the environment.
unsafe {
std::env::set_var("MEGURI_PUBLIC_URL", &base);
}
let config = Config::parse(&format!(
r#"
oidc: {{issuer: '{}', client_id: {CLIENT_ID}}}
instance_admin_groups: ['/meguri/admins']
workspaces:
- {{key: payments, name: Payments, admin_groups: ['/payments/leads'], member_groups: ['/payments/team']}}
- {{key: search, name: Search, admin_groups: ['/search/leads'], member_groups: ['/search/team']}}
"#,
idp.issuer()
))
.unwrap();
access::install(config.clone());
oidc::install(oidc::Settings {
config: config.oidc.clone().unwrap(),
client_secret: CLIENT_SECRET.into(),
redirect_uri: format!("{base}/api/oidc/callback"),
});
identity::sync_config(
&iso.store,
&config,
SyncOptions {
passwords: BootstrapPasswords::Derived,
now: chrono::Utc::now(),
allow_disable_all: false,
},
)
.await
.unwrap();
let h = Harness {
client: Client::builder().redirect(Policy::none()).build().unwrap(),
base: base.clone(),
idp,
};
let users = || async { UserRow::objects_count(&iso).await };
// The login screen offers SSO only; password sign-in is switched off for this instance.
let session: Value = h
.client
.get(format!("{base}/api/session"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(session["auth"], json!({"oidc":true,"password":false}));
assert!(session["user"].is_null());
let password = h
.client
.post(format!("{base}/api/login"))
.header("origin", &base)
.json(&json!({"username":"anyone","password":"anything-at-all"}))
.send()
.await
.unwrap();
assert_eq!(password.status(), StatusCode::UNAUTHORIZED);
// Registration and workspace creation no longer exist.
for (path, expected) in [("/api/register", StatusCode::NOT_FOUND)] {
let response = h
.client
.post(format!("{base}{path}"))
.header("origin", &base)
.json(&json!({}))
.send()
.await
.unwrap();
assert_eq!(response.status(), expected, "{path}");
}
// Happy path: an employee of Payments signs in and gets exactly that.
let (url, _) = h.begin().await;
let url = reqwest::Url::parse(&url).unwrap();
assert_eq!(url.path(), "/realms/corp/protocol/openid-connect/auth");
let query: std::collections::HashMap<_, _> = url.query_pairs().into_owned().collect();
assert_eq!(query["redirect_uri"], format!("{base}/api/oidc/callback"));
assert!(query["state"].len() >= 32 && query["nonce"].len() >= 32);
let response = h
.attempt(Scenario::new(claims(
"u-1",
"carol",
&["/payments/team", "/other"],
)))
.await;
let token = h.signed_in(&response).await;
assert!(
cookies(&response)
.iter()
.any(|c| c.starts_with("meguri_session=")
&& c.contains("HttpOnly")
&& c.contains("SameSite=Lax"))
);
assert!(
cookies(&response)
.iter()
.any(|c| c.starts_with("meguri_oidc=;") && c.contains("Max-Age=0")),
"the one-time flow cookie is cleared"
);
let session = h.session(&token).await;
assert_eq!(session["user"]["username"], "carol");
assert_eq!(session["user"]["email"], "carol@corp.test");
assert_eq!(session["user"]["instance_admin"], false);
assert_eq!(session["user"]["has_password"], false);
let workspaces: Value = h
.client
.get(format!("{base}/api/workspaces"))
.header("cookie", format!("meguri_session={token}"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(workspaces["workspaces"].as_array().unwrap().len(), 1);
assert_eq!(workspaces["workspaces"][0]["key"], "payments");
// The administration API answers 403 to an ordinary employee.
let admin = h
.client
.get(format!("{base}/api/admin/overview"))
.header("cookie", format!("meguri_session={token}"))
.send()
.await
.unwrap();
assert_eq!(admin.status(), StatusCode::FORBIDDEN);
assert_eq!(users().await, 1);
// Instance administrators and workspace leads come from their groups.
let response = h
.attempt(Scenario::new(claims("u-2", "root", &["/meguri/admins"])))
.await;
let admin_token = h.signed_in(&response).await;
assert_eq!(
h.session(&admin_token).await["user"]["instance_admin"],
true
);
let overview: Value = h
.client
.get(format!("{base}/api/admin/overview"))
.header("cookie", format!("meguri_session={admin_token}"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(overview["workspaces"].as_array().unwrap().len(), 2);
assert_eq!(overview["config"]["oidc_issuer"], idp_issuer(&h));
// Groups can come from userinfo when the ID token does not carry them.
let mut scenario =
Scenario::new(json!({"sub":"u-3","preferred_username":"lena","email":"lena@corp.test"}));
scenario.userinfo = Some(json!({"sub":"u-3","groups":["/search/leads"]}));
let response = h.attempt(scenario).await;
let lena = h.signed_in(&response).await;
assert_eq!(h.session(&lena).await["user"]["username"], "lena");
assert_eq!(h.idp.state.lock().unwrap().userinfo_requests, 1);
// Unmapped users and a provider that sends no groups get the same explanation and no account.
let before = users().await;
let reference = h.refused(
&h.attempt(Scenario::new(claims("u-4", "stranger", &["/unknown"])))
.await,
"no_access",
);
assert_eq!(reference.len(), 8);
// The reference on the screen leads an administrator to the stored reason and the groups
// the provider actually sent; ordinary users cannot read the event log.
let found: Value = h
.client
.get(format!("{base}/api/admin/events?search={reference}"))
.header("cookie", format!("meguri_session={admin_token}"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(found["events"].as_array().unwrap().len(), 1);
let event = &found["events"][0];
assert_eq!(event["action"], "login.failed");
assert_eq!(event["details"]["reference"], reference);
assert_eq!(event["details"]["code"], "no_access");
assert_eq!(event["details"]["subject"], "u-4");
assert_eq!(event["details"]["groups"], json!(["/unknown"]));
let denied = h
.client
.get(format!("{base}/api/admin/events"))
.header("cookie", format!("meguri_session={token}"))
.send()
.await
.unwrap();
assert_eq!(denied.status(), StatusCode::FORBIDDEN);
h.refused(
&h.attempt(Scenario::new(
json!({"sub":"u-5","preferred_username":"nogroups"}),
))
.await,
"no_access",
);
assert_eq!(users().await, before);
// Everything that can be wrong with a token or callback is refused without a session.
let mut wrong_issuer = Scenario::new(claims("u-6", "mallory", &["/payments/team"]));
wrong_issuer.issuer = Some("https://evil.example/realms/corp".into());
let mut wrong_audience = Scenario::new(claims("u-6", "mallory", &["/payments/team"]));
wrong_audience.audience = Some(json!("another-client"));
let mut expired = Scenario::new(claims("u-6", "mallory", &["/payments/team"]));
expired.expires_in = -600;
let mut wrong_nonce = Scenario::new(claims("u-6", "mallory", &["/payments/team"]));
wrong_nonce.nonce = Some("replayed-nonce".into());
let mut bad_signature = Scenario::new(claims("u-6", "mallory", &["/payments/team"]));
bad_signature.corrupt_signature = true;
for scenario in [
wrong_issuer,
wrong_audience,
expired,
wrong_nonce,
bad_signature,
] {
h.refused(&h.attempt(scenario).await, "failed");
}
let mut broken = Scenario::new(claims("u-6", "mallory", &["/payments/team"]));
broken.token_status = 500;
h.refused(&h.attempt(broken).await, "failed");
assert!(
!query!(ExternalIdentityRow,$subject=="u-6".to_owned())
.exists(&iso.store.db)
.await
.unwrap()
);
// Flow protections: state cookie binding, one-time use, missing and foreign state.
let good = || Scenario::new(claims("u-7", "dave", &["/payments/team"]));
let (url, state) = h.begin().await;
let code = h.idp.authorize(&url, good());
h.refused(
&h.callback(
&format!("code={code}&state={state}"),
Some("0".repeat(64).as_str()),
)
.await,
"failed",
);
let (url, state) = h.begin().await;
let code = h.idp.authorize(&url, good());
h.refused(
&h.callback(&format!("code={code}&state={state}"), None)
.await,
"failed",
);
let (url, state) = h.begin().await;
let code = h.idp.authorize(&url, good());
h.signed_in(
&h.callback(&format!("code={code}&state={state}"), Some(&state))
.await,
)
.await;
let code = h.idp.authorize(&url, good());
h.refused(
&h.callback(&format!("code={code}&state={state}"), Some(&state))
.await,
"failed",
);
h.refused(
&h.callback("error=access_denied&state=x", Some("x")).await,
"failed",
);
h.refused(&h.callback("", None).await, "failed");
// A user whose groups were revoked is refused at the next sign-in and loses access at once.
let response = h
.attempt(Scenario::new(claims("u-1", "carol", &["/search/team"])))
.await;
h.signed_in(&response).await;
let response = h
.attempt(Scenario::new(claims("u-1", "carol", &["/unknown"])))
.await;
h.refused(&response, "no_access");
let row = query!(UserRow,$username=="carol".to_owned())
.get(&iso.store.db)
.await
.unwrap()
.unwrap();
let id = row.id;
assert!(
!query!(meguri::models::WorkspaceMemberRow,$user_id==id)
.exists(&iso.store.db)
.await
.unwrap()
);
drop(h);
server.close().await;
iso.drop().await;
}
fn idp_issuer(h: &Harness) -> String {
h.idp.issuer()
}
trait Count {
async fn objects_count(iso: &IsolatedDatabase) -> usize;
}
impl Count for UserRow {
async fn objects_count(iso: &IsolatedDatabase) -> usize {
use cot::db::Model;
UserRow::objects().all(&iso.store.db).await.unwrap().len()
}
}
+1
View File
@@ -14,6 +14,7 @@ fn fixture() -> (Uuid, WorkspaceDocument, Draft) {
display_name: email.into(), display_name: email.into(),
email: Some(email.into()), email: Some(email.into()),
country: Some("CY".into()), country: Some("CY".into()),
aliases: vec![],
}); });
} }
( (
+79 -1
View File
@@ -2,14 +2,92 @@ const { chromium } = require('playwright');
function launchBrowser() { function launchBrowser() {
const channel = process.env.MEGURI_BROWSER_CHANNEL || 'chrome'; const channel = process.env.MEGURI_BROWSER_CHANNEL || 'chrome';
const executablePath = process.env.MEGURI_BROWSER_EXECUTABLE;
return chromium.launch({ return chromium.launch({
headless: true, headless: true,
...(channel === 'chromium' ? {} : { channel }), ...(executablePath ? { executablePath, args: ['--no-sandbox'] } : channel === 'chromium' ? {} : { channel }),
}); });
} }
exports.launchBrowser = launchBrowser; exports.launchBrowser = launchBrowser;
const baseUrl = () => process.env.MEGURI_BROWSER_URL || 'http://127.0.0.1:3000';
exports.baseUrl = baseUrl;
// The demo generates one password per server start and publishes it to the login screen.
// Generator drafts are shared and persisted per workspace, so a suite that edited a demo
// workspace without applying leaves them behind; discard them when a suite starts.
let password;
exports.demoPassword = () => password ||= fetch(baseUrl() + '/api/session').then(r => r.json()).then(async session => {
if (!session.demo_lifecycle?.password) throw new Error('Browser tests require a disposable demo instance');
await discardDemoDrafts(session.demo_lifecycle.password);
return session.demo_lifecycle.password;
});
async function discardDemoDrafts(secret) {
const lead = await exports.apiLogin('alice', secret);
for (const { id } of (await lead.get('/api/workspaces')).workspaces) {
const detail = await lead.get('/api/workspaces/' + id);
const draft = detail.generation_draft;
if (!draft?.payload) continue;
const saved = detail.workspace.document.generation || {};
await lead.put(`/api/workspaces/${id}/generation-draft`, { revision: draft.revision, payload: { revision: detail.workspace.revision, variables: saved.variables || [], rules: saved.rules || [], route_position: saved.route_position || 0 } });
}
}
exports.signIn = async (page, username, secret) => {
await page.goto(baseUrl());
await page.getByLabel('Username', { exact: true }).fill(username);
await page.getByLabel('Password', { exact: true }).fill(secret ?? await exports.demoPassword());
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
await page.waitForFunction(() => { const a = window.Alpine && Alpine.$data(document.body); return a && a.ready && a.user && !a.busy; });
};
// Minimal cookie-aware JSON client for test setup that must not go through the UI.
exports.apiLogin = async (username, secret) => {
const base = baseUrl();
const login = await fetch(base + '/api/login', { method: 'POST', headers: { Origin: base, 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password: secret ?? await exports.demoPassword() }) });
if (!login.ok) throw new Error(`Login as ${username} failed: ${login.status}`);
const cookie = login.headers.getSetCookie().map(c => c.split(';')[0]).find(c => c.startsWith('meguri_session='));
const { csrf } = await login.json();
const call = async (method, path, body) => {
const response = await fetch(base + path, { method, headers: { Origin: base, 'Content-Type': 'application/json', Cookie: cookie, 'X-CSRF-Token': csrf }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
const data = await response.json().catch(() => ({}));
return { status: response.status, data };
};
const ok = async (method, path, body) => {
const result = await call(method, path, body);
if (result.status >= 300) throw new Error(`${method} ${path} -> ${result.status} ${JSON.stringify(result.data)}`);
return result.data;
};
return { call, ok, get: path => ok('GET', path), post: (path, body) => ok('POST', path, body), put: (path, body) => ok('PUT', path, body), csrf, cookie };
};
exports.adminApi = () => exports.apiLogin('admin');
// Mutating suites each own one empty sandbox workspace ("Sandbox N", demo started with
// MEGURI_DEMO_SANDBOXES) whose groups are sandbox-N-leads (administrators) and sandbox-N-staff (employees).
// Nobody is in a sandbox except accounts created here, so suites do not depend on each other or on order.
exports.createUser = async (admin, { sandbox, role = 'admin', prefix, display, email }) => {
const overview = await admin.get('/api/admin/overview');
const target = overview.workspaces.find(w => w.name === `Sandbox ${sandbox}` && !w.disabled_at);
if (!target) throw new Error(`Start the demo with MEGURI_DEMO_SANDBOXES>=${sandbox}: Sandbox ${sandbox} not found`);
const groups = role === 'admin' ? target.admin_groups : target.member_groups;
const username = `${prefix}-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`.toLowerCase();
const secret = 'local-browser-test-password';
await admin.post('/api/admin/users', { username, display_name: display, password: secret, groups, ...(email ? { email } : {}) });
return { username, password: secret, display, email, workspace: target };
};
// An administrator of exactly one sandbox, plus any employees (display names) created beside them.
exports.isolatedUser = async ({ sandbox, prefix, display, email, employees = [] }) => {
const admin = await exports.adminApi();
const user = await exports.createUser(admin, { sandbox, role: 'admin', prefix, display, email });
const staff = [];
for (const name of employees) staff.push(await exports.createUser(admin, { sandbox, role: 'member', prefix: name.split(' ')[0], display: name }));
return { admin, ...user, staff };
};
exports.openSetup = async (page, name) => { exports.openSetup = async (page, name) => {
await page.waitForFunction(()=>window.Alpine && Alpine.$data(document.body).ready && Alpine.$data(document.body).workspace); await page.waitForFunction(()=>window.Alpine && Alpine.$data(document.body).ready && Alpine.$data(document.body).workspace);
const combined=['Schedules','Escalations & routing','Variables & rules'].includes(name); const combined=['Schedules','Escalations & routing','Variables & rules'].includes(name);
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more