Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/+ifc-2721-date-rendering.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The web interface now renders dates and times using your effective preferences — your personal `date_format` and `timezone`, falling back to the organisation default and then to your browser's locale and timezone when neither is set. Preferences affect display only; stored values are unchanged.
140 changes: 140 additions & 0 deletions docs/docs/deploy-manage/user-management/managing-preferences.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
---
title: Managing preferences
---
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';

# Managing preferences

Preferences control how Infrahub renders dates and times in the web interface. Each user sets their own preferences, and administrators set organisation-wide defaults that apply to everyone who has not chosen their own.

Two preferences are available today: **date format** and **timezone**.

## How preferences resolve

Infrahub resolves each preference field on its own, in this order:

1. **Your value** — the value you set for yourself.
2. **The organisation default** — the value an administrator set for everyone.
3. **The browser default** — your browser's locale for date formatting, and your browser's timezone.

Resolution is per field, so you can take the date format from your own value and the timezone from the organisation default at the same time. A field you never set falls through to the next layer; it is not stored until you set it.

## Available preferences

### Date format

The date format is a semantic key, not a rendering pattern. Each preset renders both a date and a time. The web interface renders the following presets:

| Key | Format | Example |
|---|---|---|
| `ISO_DATETIME` (default) | `yyyy-MM-dd HH:mm` | `2026-07-01 14:30` |
| `ISO_DATETIME_SECONDS` | `yyyy-MM-dd HH:mm:ss` | `2026-07-01 14:30:00` |
| `ISO_8601` | `yyyy-MM-dd'T'HH:mm:ssXXX` | `2026-07-01T14:30:00+02:00` |
| `EU_DATETIME` | `dd/MM/yyyy HH:mm` | `01/07/2026 14:30` |
| `US_12H` | `MM/dd/yyyy hh:mm a` | `07/01/2026 02:30 PM` |

When you select a format, the interface shows a live example next to the field.

### Timezone

The timezone is an IANA name, such as `Europe/Paris` or `UTC`. The picker lists the timezones your browser supports. A field left unset uses your browser's own timezone.

## Set your preferences

<Tabs groupId="method" queryString>
<TabItem value="web" label="Via the web interface" default>

1. Open **Account settings** from the user menu in the left sidebar.
2. On the **Profile** tab, find the **Preferences** card.
3. Select a **Date format** and a **Timezone**.
4. Click **Save**.

A field you leave on **Automatic (inherited)** takes the organisation default, or your browser default when no organisation default is set. The information icon next to each field shows where the current value comes from.

To clear an override and go back to the inherited value, re-select the value that is currently applied.

</TabItem>
<TabItem value="graphql" label="Via the GraphQL interface">

Write your own preferences with `InfrahubSetPreferences` using `scope: USER`. The mutation only ever writes your own row — there is no account argument.

```graphql
mutation {
InfrahubSetPreferences(scope: USER, date_format: EU_DATETIME, timezone: "Europe/Paris") {
ok
date_format
timezone
}
}
```

An omitted argument leaves that field unchanged. An explicit `null` clears the field, so it falls back to the organisation default or your browser default.

Read your resolved values — the ones the interface renders with — with `InfrahubEffectivePreferences`. Each field returns its `value` and the `source` it resolved from (`USER`, `GLOBAL`, or `DEFAULT`).

```graphql
query {
InfrahubEffectivePreferences {
date_format {
value
source
}
timezone {
value
source
}
}
}
```

</TabItem>
</Tabs>

## Set organisation defaults

Organisation defaults apply to every user who has not set their own value. Setting them requires the `manage_global_preferences` permission (super administrators have it implicitly). See the [permissions reference](../../reference/permissions.mdx) for the full list of permissions.

<Tabs groupId="method" queryString>
<TabItem value="web" label="Via the web interface" default>

1. Open **Global preferences** from the user menu in the left sidebar. The menu item is visible only to users with the `manage_global_preferences` permission.
2. Select a **Date format** and a **Timezone**.
3. Click **Save**.

A field left on **Automatic (browser default)** sets no organisation default for that field, so users without their own value fall back to their browser default.

</TabItem>
<TabItem value="graphql" label="Via the GraphQL interface">

Write organisation defaults with `InfrahubSetPreferences` using `scope: GLOBAL`. The mutation enforces the `manage_global_preferences` permission before it writes.

```graphql
mutation {
InfrahubSetPreferences(scope: GLOBAL, date_format: ISO_DATETIME, timezone: "UTC") {
ok
date_format
timezone
}
}
```

Read the raw organisation defaults with `InfrahubGlobalPreferences`. This query also requires the `manage_global_preferences` permission.

```graphql
query {
InfrahubGlobalPreferences {
date_format
timezone
}
}
```

</TabItem>
</Tabs>

## Best practices

- **Set an organisation default for the timezone when your teams work in one region.** Everyone then reads timestamps in the same zone until they choose their own.
- **Leave the date format on the default (`ISO_DATETIME`) unless your organisation has a house style.** The ISO presets sort and compare the same way in every locale.
- **Preferences affect display only.** They change how the web interface renders timestamps; they never change the stored data. A timezone preference renders each timestamp in that zone without altering the underlying value.
1 change: 1 addition & 0 deletions docs/sidebars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,7 @@ const sidebars: SidebarsConfig = {
},
// Managing API Tokens (PR 14)
{ type: 'doc', id: 'deploy-manage/user-management/managing-api-tokens', label: 'Managing API tokens' },
{ type: 'doc', id: 'deploy-manage/user-management/managing-preferences', label: 'Managing preferences' },
],
},
],
Expand Down
17 changes: 17 additions & 0 deletions tests/e2e/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,23 @@ async def login(page: Page, username: str, password: str) -> None:
await expect(page.get_by_test_id(AUTHENTICATED_MENU_TRIGGER)).to_be_visible()


async def select_combobox_option(page: Page, label: str, option: str) -> None:
"""Open a Combobox by its accessible name and pick an option by its exact label.

The preference forms use a searchable Combobox — a button trigger (its
accessible name is ``label``) that opens a dialog holding a searchbox and a
listbox. Typing into the search narrows the list, which the virtualized
timezone picker needs (only rendered options are in the DOM) and the short
date-format list tolerates. The option is then clicked by its exact label, so
a value that is a prefix of another (``yyyy-MM-dd HH:mm`` vs
``yyyy-MM-dd HH:mm:ss``) still resolves unambiguously.
"""
await page.get_by_role("button", name=label, exact=True).click()
dialog = page.get_by_role("dialog", name=label)
await dialog.get_by_role("searchbox").fill(option)
await dialog.get_by_role("option", name=option, exact=True).click()


class BranchAPI:
"""Port of tests/e2e/utils/graphql.ts branch helpers.

Expand Down
66 changes: 66 additions & 0 deletions tests/e2e/preferences/test_global_preferences.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""E2E coverage for organisation-wide default preferences.

A user holding ``manage_global_preferences`` sets organisation defaults on the
Global preferences page. Every user without a personal override then inherits
those defaults — surfaced in the Preferences card as the "organisation default"
source.

Uses the read-only account (from the RBAC slice) as the inheriting user, so the
test declares the ``data_rbac`` dependency transitively via ``read_only_page``.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

import pytest
from helpers import select_combobox_option
from playwright.async_api import expect

pytestmark = pytest.mark.shard_foundation

if TYPE_CHECKING:
from collections.abc import AsyncGenerator

from infrahub_sdk import InfrahubClient
from playwright.async_api import Page

# US 12-hour preset — distinct from the ISO default so an inherited value is
# unambiguous.
GLOBAL_DATE_FORMAT = "MM/dd/yyyy hh:mm a"


class TestGlobalPreferences:
@pytest.fixture(autouse=True)
async def reset_global_preferences(self, infrahub_client: InfrahubClient) -> AsyncGenerator[None, None]:
"""Clear the organisation-wide defaults after the test.

The global row is shared by every account on the session instance, so it
must be reset or later tests inherit a stale default.
"""
yield
await infrahub_client.execute_graphql(
query=("mutation { InfrahubSetPreferences(scope: GLOBAL, date_format: null, timezone: null) { ok } }"),
)

async def test_global_default_is_inherited_by_users_without_an_override(
self, admin_page: Page, read_only_page: Page
) -> None:
# An administrator sets the organisation default.
await admin_page.goto("/global-preferences")
await select_combobox_option(admin_page, "Date format", GLOBAL_DATE_FORMAT)
await admin_page.get_by_role("button", name="Save", exact=True).click()
await expect(admin_page.get_by_text("Global preferences updated")).to_be_visible()

# A user with no personal override sees the field inherit, and the source
# hint attributes it to the organisation default.
await read_only_page.goto("/profile")
await expect(read_only_page.get_by_role("button", name="Date format", exact=True)).to_contain_text(
"Automatic (inherited)"
)

# Focus (not hover): React Aria opens the tooltip immediately on keyboard
# focus, while hover goes through the warm-up delay — the deterministic
# option for CI.
await read_only_page.get_by_role("button", name="Where this value comes from").first.focus()
await expect(read_only_page.get_by_text("From the organisation default")).to_be_visible()
50 changes: 50 additions & 0 deletions tests/e2e/preferences/test_preferences_permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""E2E coverage for the manage_global_preferences permission gate.

The Global preferences page is gated by the ``manage_global_preferences`` global
permission. An administrator (super admin, who holds it implicitly) sees the
account-menu entry and the editor; a user without the permission never sees the
menu entry, and a direct visit to the page is refused.

Uses the read-only account (from the RBAC slice) as the unprivileged user, so the
test declares the ``data_rbac`` dependency transitively via ``read_only_page``.
"""

from __future__ import annotations

import re
from typing import TYPE_CHECKING

import pytest
from playwright.async_api import expect

pytestmark = pytest.mark.shard_foundation

if TYPE_CHECKING:
from playwright.async_api import Page


class TestPreferencesPermissions:
async def test_admin_sees_the_global_preferences_menu_entry_and_editor(self, admin_page: Page) -> None:
await admin_page.goto("/")
await admin_page.get_by_test_id("authenticated-menu-trigger").click()
await expect(admin_page.get_by_role("menuitem", name="Global preferences")).to_be_visible()

await admin_page.get_by_role("menuitem", name="Global preferences").click()
await expect(admin_page).to_have_url(re.compile(r"/global-preferences$"))
await expect(admin_page.get_by_role("button", name="Save", exact=True)).to_be_visible()

async def test_non_admin_cannot_see_or_open_global_preferences(self, read_only_page: Page) -> None:
await read_only_page.goto("/")
# The account menu opens, but the gated entry is absent.
await read_only_page.get_by_test_id("authenticated-menu-trigger").click()
await expect(read_only_page.get_by_role("menuitem", name="Account settings")).to_be_visible()
await expect(read_only_page.get_by_role("menuitem", name="Global preferences")).to_have_count(0)
await read_only_page.keyboard.press("Escape")

# A direct visit to the gated page is refused. The detailed message sits
# inside a collapsed accordion, so expand it before asserting on it.
await read_only_page.goto("/global-preferences")
await expect(read_only_page.get_by_text("You can't access this view")).to_be_visible()
await read_only_page.get_by_text("You can't access this view").click()
await expect(read_only_page.get_by_text("You don't have permission to edit global preferences")).to_be_visible()
await expect(read_only_page.get_by_role("button", name="Save", exact=True)).to_have_count(0)
85 changes: 85 additions & 0 deletions tests/e2e/preferences/test_user_preferences.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""E2E coverage for personal user preferences.

Each user sets their own date format and timezone from the Preferences card on
the account Profile tab. A saved value persists across a reload; re-selecting the
currently-applied value clears the override so the field inherits again.

Runs as admin only (the bootstrap account), so no demo data is required.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

import pytest
from helpers import select_combobox_option
from playwright.async_api import expect

pytestmark = pytest.mark.shard_foundation

if TYPE_CHECKING:
from collections.abc import AsyncGenerator

from infrahub_sdk import InfrahubClient
from playwright.async_api import Page

# A preset that differs from every layer's default, so a persisted value is
# unambiguous. The option's accessible name is its date-fns pattern (the label
# the Combobox shows).
EU_DATE_FORMAT = "dd/MM/yyyy HH:mm"
TIMEZONE = "Asia/Tokyo"


class TestUserPreferences:
@pytest.fixture(autouse=True)
async def reset_admin_preferences(self, infrahub_client: InfrahubClient) -> AsyncGenerator[None, None]:
"""Clear the admin account's own preference row after each test.

Preferences persist per account on the shared session instance, so an
override left behind would leak into later tests. An explicit ``null``
resets each field (an omitted field would be left unchanged).
"""
yield
await infrahub_client.execute_graphql(
query=("mutation { InfrahubSetPreferences(scope: USER, date_format: null, timezone: null) { ok } }"),
)

async def test_set_personal_preferences_persist_across_reload(self, admin_page: Page) -> None:
await admin_page.goto("/profile")
await expect(admin_page.get_by_text("Preferences", exact=True)).to_be_visible()

await select_combobox_option(admin_page, "Date format", EU_DATE_FORMAT)
await select_combobox_option(admin_page, "Timezone", TIMEZONE)
await admin_page.get_by_role("button", name="Save", exact=True).click()
await expect(admin_page.get_by_text("Preferences updated")).to_be_visible()

# The saved override survives a full reload (it is read back from the server).
await admin_page.reload()
await expect(admin_page.get_by_role("button", name="Date format", exact=True)).to_contain_text(EU_DATE_FORMAT)
await expect(admin_page.get_by_role("button", name="Timezone", exact=True)).to_contain_text(TIMEZONE)

async def test_reselecting_the_current_value_clears_the_override(self, admin_page: Page) -> None:
await admin_page.goto("/profile")

# Set an override first.
await select_combobox_option(admin_page, "Date format", EU_DATE_FORMAT)
await admin_page.get_by_role("button", name="Save", exact=True).click()
await expect(admin_page.get_by_text("Preferences updated")).to_be_visible()
# Let the toast auto-dismiss so the second save's toast is unambiguous;
# otherwise the next visibility check can pass against this stale toast
# and the reload below races the in-flight mutation.
await expect(admin_page.get_by_text("Preferences updated")).not_to_be_visible()

# Re-selecting the currently-applied value clears the override; the field
# falls back to the inherited value and shows its placeholder again.
await select_combobox_option(admin_page, "Date format", EU_DATE_FORMAT)
await expect(admin_page.get_by_role("button", name="Date format", exact=True)).to_contain_text(
"Automatic (inherited)"
)
await admin_page.get_by_role("button", name="Save", exact=True).click()
await expect(admin_page.get_by_text("Preferences updated")).to_be_visible()

await admin_page.reload()
await expect(admin_page.get_by_role("button", name="Date format", exact=True)).to_contain_text(
"Automatic (inherited)"
)
Loading