Finding principals
Search-as-you-type over users and groups — for assigning owners and readers, with pagination and cancellation.
When a user has to pick people — the owners of a container, the readers of a resource, somebody to impersonate — you need a directory lookup, not a document search.
const { principals, pagination } = await client.api.principal.search({
offset: 0,
limit: 20,
search: "ali",
});Concept — a security principal
A user or a group. Both can hold rights, so both appear in the same results, distinguished by isGroup. A
picker that only offers users cannot express "everyone in Legal may read this".
What comes back
Prop
Type
A people picker
Same discipline as autocomplete: debounce, and cancel. This call takes an
AbortSignal directly, which makes cancellation clean.
import { createAtomicClient } from "@sinequa/atomic";
import type { SecurityPrincipal } from "@sinequa/atomic";
const client = createAtomicClient({
app: "<your-app>",
backendUrl: "https://<your-sinequa-server>",
});
await client.initializeConfig();
await client.auth.login();
let timer: ReturnType<typeof setTimeout> | undefined;
let inFlight: AbortController | undefined;
export function onInput(
text: string,
render: (principals: SecurityPrincipal[], total: number) => void,
) {
clearTimeout(timer);
// The user keeps typing: abandon the request that is already out.
inFlight?.abort();
if (text.length < 2) {
render([], 0);
return;
}
timer = setTimeout(async () => {
inFlight = new AbortController();
try {
const response = await client.api.principal.search(
{ offset: 0, limit: 20, search: text },
inFlight.signal,
);
render(response.principals, response.pagination.total);
} catch (error) {
// An abort is the expected outcome of typing, not a failure to report.
if (error instanceof DOMException && error.name === "AbortError") return;
throw error;
}
}, 200);
}
/** Groups and users are both selectable; label them so the choice is informed. */
export function label(principal: SecurityPrincipal) {
return `${principal.isGroup ? "Group" : "User"}: ${principal.longName || principal.name}`;
}import { ChangeDetectionStrategy, Component, inject, output, resource, signal } from "@angular/core";
import type { SecurityPrincipal } from "@sinequa/atomic";
import { SINEQUA } from "./sinequa";
@Component({
selector: "app-people-picker",
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<input
[value]="text()"
(input)="text.set(value($event))"
role="combobox"
aria-autocomplete="list"
aria-label="Find a user or group"
/>
@if (found.value(); as response) {
<p>{{ response.principals.length }} of {{ response.pagination.total }}</p>
<ul role="listbox">
@for (principal of response.principals; track principal.id) {
<li role="option" [attr.aria-selected]="false">
<button type="button" (click)="picked.emit(principal)">
{{ principal.isGroup ? 'Group' : 'User' }}:
{{ principal.longName || principal.name }}
</button>
</li>
}
</ul>
}
`,
})
export class PeoplePickerComponent {
private readonly client = inject(SINEQUA);
readonly text = signal("");
readonly picked = output<SecurityPrincipal>();
readonly found = resource({
params: () => ({ search: this.text() }),
loader: ({ params, abortSignal }) => {
if (params.search.length < 2) {
return Promise.resolve({
principals: [],
pagination: { offset: 0, limit: 0, total: 0 },
groups: [],
methodresult: "",
});
}
// `resource` aborts the previous load, and this call takes the signal.
return this.client.api.principal.search(
{ offset: 0, limit: 20, search: params.search },
abortSignal,
);
},
});
value(event: Event) {
return (event.target as HTMLInputElement).value;
}
}import type { SecurityPrincipal } from "@sinequa/atomic";
import { useEffect, useState } from "react";
import { ready, sinequa } from "./sinequa";
export function PeoplePicker({ onPick }: { onPick: (principal: SecurityPrincipal) => void }) {
const [text, setText] = useState("");
const [principals, setPrincipals] = useState<SecurityPrincipal[]>([]);
const [total, setTotal] = useState(0);
useEffect(() => {
if (text.length < 2) {
setPrincipals([]);
setTotal(0);
return;
}
// Debounce, then cancel: the user keeps typing while a request is out.
const controller = new AbortController();
const timer = setTimeout(() => {
ready
.then(() =>
sinequa.api.principal.search({ offset: 0, limit: 20, search: text }, controller.signal),
)
.then((response) => {
setPrincipals(response.principals);
setTotal(response.pagination.total);
})
.catch((error: unknown) => {
// An abort is the expected outcome of typing, not a failure.
if (error instanceof DOMException && error.name === "AbortError") return;
throw error;
});
}, 200);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [text]);
return (
<>
<input
value={text}
onChange={(event) => setText(event.target.value)}
role="combobox"
aria-autocomplete="list"
aria-label="Find a user or group"
/>
<p>
{principals.length} of {total}
</p>
<ul role="listbox">
{principals.map((principal) => (
<li key={principal.id} role="option" aria-selected={false}>
<button type="button" onClick={() => onPick(principal)}>
{principal.isGroup ? "Group" : "User"}: {principal.longName || principal.name}
</button>
</li>
))}
</ul>
</>
);
}Which lookup to use
Three calls touch principals, and they are not interchangeable:
| Call | Endpoint | For |
|---|---|---|
principal.search | security.principal | Search-as-you-type. Paginated, cancellable, users and groups. |
principal.list | principal/list | Browsing, with isuser/isgroup filters. Different response shape. |
principal.byIds | security.principal | Resolving domain|id identities (a container's owners/readers) to names. |
principal.userid(s) | principal?action=userid | Resolving identifiers you already hold. |
For a picker, search is the one: it is the only one with pagination and an AbortSignal.