Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1a42e2d
✨ Admin Portal: Voter editing quality of life features
Findeton Aug 21, 2026
4ba0b37
✨ Admin Portal: name the attribute Keycloak refused a voter save against
Findeton Aug 21, 2026
ab24340
🐞 Admin Portal: stop capping voter attributes as they are typed
Findeton Aug 21, 2026
c7f8931
✨ Admin Portal: report every attribute a voter save was refused for
Findeton Aug 21, 2026
8f2b232
✨ Admin Portal: state and check voter attribute length bounds in the …
Findeton Aug 21, 2026
e03bad1
✨ Admin Portal: stop a voter attribute being typed past its maximum
Findeton Aug 21, 2026
42630d2
🐞 Admin Portal: say why a touched voter field is holding the save
Findeton Aug 21, 2026
a11e508
✨ Admin Portal: honour the hidden annotation on voter attributes
Findeton Aug 21, 2026
35414fd
🐞 Admin Portal: actually disable the save while a voter field is invalid
Findeton Aug 21, 2026
602ad12
🐞 Admin Portal: stop stating length bounds nobody types into
Findeton Aug 21, 2026
36342d4
🐞 Admin Portal: fixes from three independent reviews of the voter editor
Findeton Aug 22, 2026
4050242
🐞 Admin Portal: fixes from the second round of independent reviews
Findeton Aug 22, 2026
d752a18
🐞 Admin Portal: describe each refused voter attribute on its own terms
Findeton Aug 22, 2026
f5bf431
📖 Admin Portal: rewrap an over-long line in the attributes tutorial
Findeton Aug 22, 2026
01975ad
🐞 Admin Portal: stop printing a raw webhook response at whoever gener…
Findeton Aug 22, 2026
79e0984
📖 Admin Portal: document what happens when a voter cannot be saved
Findeton Aug 22, 2026
7650535
📖 Admin Portal: say what hiding an attribute does not do
Findeton Aug 22, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ To enable sex selection:

1. In **Annotations** > **Add annotation**, set Key: `Input type`, Value: `select`
2. **Add Validator** > **Validator type**: `options` and add the desired options (e.g., M, F)
3. Optionally describe each option, so that whoever edits a voter does not have to interpret the
stored values. In **Annotations** > **Add annotation**, set Key: `inputOptionLabels` and a JSON
value mapping each option to its description, such as `{"M": "Male", "F": "Female"}`. A
description written as `${sex_male}` is resolved through the Admin Portal's own localization
overrides (see below) and falls back to a readable form of the key when there is no override.
The `inputOptionLabelsI18nPrefix` annotation is honoured on the voter-facing login forms only;
the Admin Portal ignores it, because it reads a different set of translations.

The dropdown in the Admin Portal shows the stored option followed by its description, for example
`M - Male`, so the value written to the voter stays visible.

### Birth Date

Expand All @@ -45,6 +55,62 @@ To show a date input field:
1. In **Annotations** > **Add annotation**, set Key: `Input type`, Value: `multiselect-checkboxes`
2. **TODO:** Implementation pending

## Hiding an Attribute

An attribute that is carried on the voter but is not meant to be seen or edited can be marked
hidden: in **Annotations** > **Add annotation**, set Key: `hidden`, Value: `true`.

The value must be exactly `true`. This is matched literally, so `TRUE` or a value with spaces around
it does not hide anything — the voter-facing forms read the annotation the same way, and matching it
any more loosely here would hide an attribute from administrators while voters still saw it.

A hidden attribute is left off the voter-facing enrollment and login forms, and off the Admin
Portal's voter list and its create and edit forms — it is not shown as a column, is not offered in
the columns selector or the filters, and is not shown as a field. The Approvals screens are not
affected and still show it.

Hiding an attribute does not remove or alter it. Its value is still stored, still carried through
when a voter is edited, and still included when voters are exported.

Hiding is therefore about keeping a form readable, not about restricting access: the value is still
sent to the browser and still appears in exports. To control who may read or write an attribute, set
its permissions in **Realm settings** > **User profile** instead.

Do not mark an attribute both hidden and required: creating a voter through the Admin Portal would
then be impossible, since the field it insists on is one the form does not show.

Note this is different from setting the `Input type` annotation to `hidden`, which is Keycloak's own
way of rendering an attribute as a hidden input on a form rather than keeping it off the form.

## Limiting the Number of Characters

To bound how much text an attribute accepts, **Add Validator** > **Validator type**: `length`, and
set a minimum, a maximum, or both. Keycloak enforces the bounds when the record is saved.

The Admin Portal states the bounds under the field, so they are known before they are broken, and
checks the value when the field is left. A value that breaks a bound marks the field and says which
bound it broke, and the voter cannot be saved until it is corrected.

Bounds nobody types into are left unstated, to keep the form readable: a maximum in the hundreds,
which Keycloak's own base attributes carry as scaffolding, and a minimum of one, which says only
that the value is present. They are still checked, and still reported when broken.

A field with a maximum also stops accepting characters once it is reached, so the maximum cannot be
exceeded by typing. That count is of the characters as typed, so a value padded with spaces can
stop being accepted a little before the validator would object to it. Note that pasting a longer
value into such a field keeps only what fits, without warning, and that a stored value already
longer than the maximum can only be shortened, never extended — in that case the field reports the
value as too long until it is brought within the bound. A minimum cannot be applied while typing at
all, so it is only checked when the field is left.

By default Keycloak measures the value with leading and trailing spaces removed, and the Admin
Portal measures it the same way. Setting the validator's `trim-disabled` option changes both.

A bound that is somehow reached anyway — a value written by an import, or an attribute the form
does not show — is refused on save and reported naming each field and the bound it broke. Note
that on Datafix election events the save is carried out by a background task, so its refusal is
reported through that task rather than in the form.

## Localization Overrides

To customize the display label of a user attribute, add a translation override via the Admin Portal under **Settings** > **Localization** > **Add**.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,23 @@ The **Actions** column provides options to interact with voter records:

---

#### Adding and editing a voter

Fields are checked as they are left. One that is required and left empty, or whose value breaks a
limit configured for that attribute, is marked and explains what it expects, and the voter cannot be
saved until it is corrected. Where an attribute sets a limit worth knowing in advance, it is stated
under the field before it is reached.

If a voter still cannot be saved, the editor stays open with the values as entered, so nothing has to
be typed again, and reports why. Where the rejection concerns particular fields, each one is named
along with the rule it broke — up to ten of them, with any beyond that counted rather than listed.

Limits come from the attribute's configuration in Keycloak; see
[Adding User Attributes to Keycloak](../../01-tutorials/99-admin_portal_tutorials_add-user-attributes-to-keycloak.md)
for how they are set and which ones the portal states up front.

---

#### Send Notifications

Use this tab to send notifications to voters through various methods, schedules, and templates.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {ContentCopy, VideoCall} from "@mui/icons-material"
import {useTranslation} from "react-i18next"
import {useMutation} from "@apollo/client"
import {GENERATE_GOOGLE_MEET} from "../../../queries/GenerateGoogleMeet"
import {IGraphQLActionError} from "@sequentech/ui-core"
import {getGraphQLActionErrorReason} from "@/services/graphqlActionError"
import {GenerateGoogleMeetMutation} from "@/gql/graphql"
import {IPermissions} from "@/types/keycloak"

Expand Down Expand Up @@ -90,20 +90,16 @@ export const GoogleMeetLinkGenerator: React.FC<GoogleMeetLinkGeneratorProps> = (
} else {
setError("Link is null.")
}
} catch (err: any) {
console.error("Error generating Google Meet link:", err)
let error = err as IGraphQLActionError
let status =
error?.graphQLErrors?.[0]?.extensions?.internal?.response?.status?.toString() ?? ""
let body = error?.graphQLErrors?.[0]?.extensions?.internal?.response?.body ?? ""
let message = "Failed to generate Google Meet link. "
if (status) {
message += `Status: ${status}. `
}
if (body) {
message += `Body: ${body}. `
}
setError(message)
} catch (error: unknown) {
console.error("Error generating Google Meet link:", error)
// The handler's own message, rather than the status and the raw
// response body this used to print at whoever was generating a link.
const reason = getGraphQLActionErrorReason(error)
setError(
reason
? `Failed to generate Google Meet link: ${reason}`
: "Failed to generate Google Meet link."
)
}
setIsGenerating(false)
}
Expand Down
13 changes: 3 additions & 10 deletions packages/admin-portal/src/resources/User/EditPassword.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {styled} from "@mui/material/styles"
import {useTenantStore} from "@/providers/TenantContextProvider"
import IconTooltip from "@/components/IconTooltip"
import FormDialog from "@/components/FormDialog"
import {getPasswordPolicyViolation} from "./editPasswordError"
import {getPasswordPolicyMessage} from "./editPasswordError"
interface EditPasswordProps {
open: boolean
handleClose: () => void
Expand Down Expand Up @@ -163,15 +163,8 @@ const EditPassword = ({open, handleClose, id, electionEventId}: EditPasswordProp
refresh()
handleClose?.()
} catch (error: unknown) {
const violation = getPasswordPolicyViolation(error)
if (violation) {
const message =
violation.rule && violation.requiredCount !== undefined
? t(
`usersAndRolesScreen.editPassword.passwordPolicyRules.${violation.rule}`,
{count: violation.requiredCount}
)
: t("usersAndRolesScreen.editPassword.passwordPolicyViolation")
const message = getPasswordPolicyMessage(error, t)
if (message) {
setPasswordPolicyError(message)
notify(message, {type: "error"})
return
Expand Down
Loading
Loading