@k2b/cloud 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -3
- package/src/access/PermissionEditor.tsx +5 -4
- package/src/access/PrincipalPicker.tsx +2 -1
- package/src/account/EntitySearch.tsx +5 -3
- package/src/ai/code-mode-skill.ts +2 -2
- package/src/api/accounts-entities.ts +3 -1
- package/src/api/app-approval.ts +4 -1
- package/src/browser/reload.ts +34 -0
- package/src/contracts/shared.ts +10 -0
- package/src/services/accounts/app.ts +4 -1
- package/src/services/accounts/base-group.ts +29 -0
- package/src/services/accounts/entities.ts +22 -1
- package/src/services/accounts/groups.ts +13 -5
- package/src/services/accounts/identities.ts +7 -3
- package/src/services/accounts/local-groups.ts +12 -12
- package/src/services/app-approval.ts +62 -5
- package/src/services/ipa/groups.ts +3 -0
- package/src/services/ipa/search.ts +1 -0
- package/src/services/webauthn.ts +1 -1
- package/src/shared/account-display.ts +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k2b/cloud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"repository": {
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"./browser/app-approval": "./src/browser/app-approval.ts",
|
|
33
33
|
"./browser/live": "./src/browser/live-websocket.ts",
|
|
34
34
|
"./browser/mermaid": "./src/browser/mermaid.ts",
|
|
35
|
+
"./browser/reload": "./src/browser/reload.ts",
|
|
35
36
|
"./browser/notifications": "./src/browser/notifications.ts",
|
|
36
37
|
"./browser/resource-clipboard": "./src/browser/resource-clipboard.ts",
|
|
37
38
|
"./browser/commands": "./src/browser/commands.ts",
|
|
@@ -98,8 +99,8 @@
|
|
|
98
99
|
"@tailwindcss/typography": "0.5.20",
|
|
99
100
|
"@k2b/nessi": "0.12.1",
|
|
100
101
|
"@k2b/ssr": "0.14.0",
|
|
101
|
-
"@k2b/ui": "0.
|
|
102
|
-
"@k2b/stdlib": "0.
|
|
102
|
+
"@k2b/ui": "0.6.0",
|
|
103
|
+
"@k2b/stdlib": "0.26.0",
|
|
103
104
|
"@k2b/sync": "6.5.0",
|
|
104
105
|
"@nats-io/transport-node": "3.4.0",
|
|
105
106
|
"@modelcontextprotocol/sdk": "1.30.0",
|
|
@@ -3,6 +3,7 @@ import { IconButton, Placeholder, prompts, SelectChip, Tooltip, useLocale } from
|
|
|
3
3
|
import { createSignal, For, Show } from "solid-js";
|
|
4
4
|
import { CloudAvatar } from "../account/Avatar";
|
|
5
5
|
import type { AccessEntry, PermissionLevel, Principal } from "../contracts/shared";
|
|
6
|
+
import { groupDisplayName } from "../shared/account-display";
|
|
6
7
|
import { accessMessages } from "./messages";
|
|
7
8
|
import PrincipalPicker from "./PrincipalPicker";
|
|
8
9
|
|
|
@@ -112,8 +113,8 @@ const resolveEntryDisplay = (
|
|
|
112
113
|
return defaults[permission] ?? defaults.none;
|
|
113
114
|
};
|
|
114
115
|
|
|
115
|
-
const getEntryDisplayName = (entry: AccessEntry, t: ReturnType<typeof accessMessages.resolve>["t"]): string => {
|
|
116
|
-
if (entry.displayName) return entry.displayName;
|
|
116
|
+
const getEntryDisplayName = (entry: AccessEntry, t: ReturnType<typeof accessMessages.resolve>["t"], locale: string): string => {
|
|
117
|
+
if (entry.displayName) return entry.principal.type === "group" ? groupDisplayName(entry.displayName, locale) : entry.displayName;
|
|
117
118
|
if (entry.principal.type === "authenticated") return t.allUsers;
|
|
118
119
|
if (entry.principal.type === "public") return t.public;
|
|
119
120
|
if (entry.principal.type === "user") return entry.principal.userId;
|
|
@@ -184,7 +185,7 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
184
185
|
|
|
185
186
|
const revokeMut = mutation.create<string | null, AccessEntry>({
|
|
186
187
|
mutation: async (entry) => {
|
|
187
|
-
const displayName = getEntryDisplayName(entry, t());
|
|
188
|
+
const displayName = getEntryDisplayName(entry, t(), locale());
|
|
188
189
|
const confirmed = await prompts.confirm(t().removeAccessConfirm({ name: displayName }), {
|
|
189
190
|
title: t().removeAccess,
|
|
190
191
|
variant: "danger",
|
|
@@ -263,7 +264,7 @@ function AccessEntryRow(props: {
|
|
|
263
264
|
}) {
|
|
264
265
|
const locale = useLocale();
|
|
265
266
|
const t = () => accessMessages.resolve([locale()]).t;
|
|
266
|
-
const displayName = () => getEntryDisplayName(props.entry, t());
|
|
267
|
+
const displayName = () => getEntryDisplayName(props.entry, t(), locale());
|
|
267
268
|
const display = () => resolveEntryDisplay(props.entry.permission, props.allowed, t());
|
|
268
269
|
const isInteractive = () =>
|
|
269
270
|
props.canEdit && !props.disabled && !props.singlePicker && props.allowed.some((option) => option.level === props.entry.permission);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Combobox, type ComboboxOption, useLocale } from "@k2b/ui";
|
|
2
2
|
import type { Principal } from "../contracts/shared";
|
|
3
|
+
import { groupDisplayName } from "../shared/account-display";
|
|
3
4
|
import { accessMessages } from "./messages";
|
|
4
5
|
|
|
5
6
|
export const principalKey = (p: Principal) =>
|
|
@@ -66,7 +67,7 @@ export default function PrincipalPicker(props: {
|
|
|
66
67
|
else if (e.kind === "group")
|
|
67
68
|
entries.push({
|
|
68
69
|
principal: { type: "group", groupId: e.group.id },
|
|
69
|
-
label: e.group.name,
|
|
70
|
+
label: groupDisplayName(e.group.name, locale()),
|
|
70
71
|
icon: "ti ti-users-group",
|
|
71
72
|
description: e.group.description ?? undefined,
|
|
72
73
|
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { timed } from "@k2b/stdlib/solid";
|
|
2
|
-
import { Button, ScrollArea, TextInput } from "@k2b/ui";
|
|
2
|
+
import { Button, ScrollArea, TextInput, useLocale } from "@k2b/ui";
|
|
3
3
|
import type { JSX } from "solid-js";
|
|
4
4
|
import { createSignal, For, Show } from "solid-js";
|
|
5
|
+
import { groupDisplayName } from "../shared/account-display";
|
|
5
6
|
import { CloudAvatar } from "./Avatar";
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -108,6 +109,7 @@ type ApiServiceAccount = {
|
|
|
108
109
|
};
|
|
109
110
|
|
|
110
111
|
const EntitySearch = (props: EntitySearchProps) => {
|
|
112
|
+
const locale = useLocale();
|
|
111
113
|
const [search, setSearch] = createSignal("");
|
|
112
114
|
const [users, setUsers] = createSignal<ApiUser[]>([]);
|
|
113
115
|
const [groups, setGroups] = createSignal<ApiGroup[]>([]);
|
|
@@ -270,7 +272,7 @@ const EntitySearch = (props: EntitySearchProps) => {
|
|
|
270
272
|
{(group) => (
|
|
271
273
|
<ResultRow
|
|
272
274
|
icon="ti-users-group"
|
|
273
|
-
title={group.name}
|
|
275
|
+
title={groupDisplayName(group.name, locale())}
|
|
274
276
|
subtitle={group.description ?? undefined}
|
|
275
277
|
disabled={props.disabled}
|
|
276
278
|
onSelect={() =>
|
|
@@ -339,7 +341,7 @@ const ResultRow = (props: {
|
|
|
339
341
|
disabled?: boolean;
|
|
340
342
|
onSelect: () => void;
|
|
341
343
|
}) => (
|
|
342
|
-
<Button onClick={props.onSelect} disabled={props.disabled} variant="ghost" class="w-full
|
|
344
|
+
<Button onClick={props.onSelect} disabled={props.disabled} variant="ghost" align="start" class="w-full">
|
|
343
345
|
<Show
|
|
344
346
|
when={props.avatar}
|
|
345
347
|
fallback={
|
|
@@ -4,7 +4,7 @@ import type { AiSkillTemplate } from "./skills";
|
|
|
4
4
|
|
|
5
5
|
export const ASSISTANT_CODE_MODE_SKILL = {
|
|
6
6
|
"key": "assistant:code-mode",
|
|
7
|
-
"version":
|
|
7
|
+
"version": 52,
|
|
8
8
|
"name": "assistant-code-mode",
|
|
9
9
|
"description": "Inspect and transform unfamiliar data, analyze files, compare results across Cloud apps, or build and improve interactive and agent-only Apps in Assistant Studio. Use for quick code experiments, data analysis, file generation, resource SQL queries and combining discovered Cloud capabilities. For plain arithmetic or date offsets, answer directly or use calculate.",
|
|
10
10
|
"instructions": "# Assistant code mode\n\nChoose the smallest useful result: one-off answer, exported file, or reusable\nStudio App. Apps may expose agent actions, a display-only dashboard, or both.\nPersistence is optional. One-off scripts stay in their chat and cannot be shared. Reuse an\nexisting Cloud feature when it fits. For a\nquick reading of an uploaded PDF or Office document, `read_file` can return\nMarkdown; use code for exact cells, calculations, original PDF text or positions.\n\n## Start from the contract\n\nLoad the needed `code_*` tools individually through `load_tools` and read their\ninput schemas. They are Assistant tools, not capabilities or functions inside\ncode. Discover other Cloud operations before using `capabilities.run`.\n\nRuntime namespaces are globals: no imports or package installation are needed.\nOnly relative imports of the resource's own source files are supported. There is\nno DOM or native network access. Before using a namespace, read its reference\nbelow for signatures, options and return values. Do not invent methods or infer\nan API from a familiar library. For discovered Cloud capabilities and external\nAPIs, obtain their actual contracts separately.\n\nInspect supplied data before joining, filtering or calculating: column names,\ntypes, units, date ranges and missing values. Ask only for decisions or inputs\nthat cannot be established from available evidence. For several real steps,\nkeep a short `todo_write` plan and update it as work changes; skip ceremony for a\nsmall experiment. A failed experiment should change the next hypothesis.\n\n## First file script\n\nPass exact current-chat manifest paths as `code_run.inputPaths`, and this entry\nas `code_run.code` for a small CSV:\n\n```js\nexport default async () => {\n const [input] = await files.list();\n if (!input) throw new Error(\"Select a CSV input.\");\n const rows = await sheet.fromCsv(await files.read(input.name));\n return { rows: rows.length, columns: Object.keys(rows[0] ?? {}), sample: rows.slice(0, 3) };\n};\n```\n\n`input.name` is the full path, such as `/sales.csv`; pass it unchanged to\n`files.read`, which returns a `File`. CSV rows are objects keyed by headers:\n`rows[0]` is already data. Do not drop it. For older Excel CSVs, use\n`sheet.fromCsv(file, {encoding:\"windows-1252\"})`. Inspect actual headings first.\nFor a tiny experiment without files, `export default () => ({answer:42})` suffices.\nEach run has fresh variables. No saved resource or UI is required.\n\n## Reference routing\n\nRead only the rows relevant to the task. Each link describes its own complete\nsupported surface; links within references add related workflows when needed.\n\n| Task / API | Read |\n| --- | --- |\n| Source entry, input/output files, pickers, CSV, IDs | [Runtime and files](/skills/assistant-code-mode/references/runtime.md) |\n| Inspect PDF pages, read PDF text/positions or XLSX/ODS cells, write ODS | [Documents](/skills/assistant-code-mode/references/documents.md) |\n| Generate a PDF, embed attachments, combine invoice HTML and XML | [PDF generation](/skills/assistant-code-mode/references/pdf.md) |\n| Exact amounts, taxes, allocation, localized money | [Money](/skills/assistant-code-mode/references/money.md) |\n| Export DATEV bookings or SEPA transfers | [DATEV and SEPA](/skills/assistant-code-mode/references/finance.md) |\n| Parse a CAMT bank report | [Bank reports](/skills/assistant-code-mode/references/camt.md) |\n| Calculate, create or read electronic invoices/XML/PDF attachments | [Electronic invoices](/skills/assistant-code-mode/references/einvoice.md) |\n| Controls, layouts and dialogs | [UI and dialogs](/skills/assistant-code-mode/references/ui.md), [Analytics UI](/skills/assistant-code-mode/references/analytics.md) |\n| Chart types, series and axes | [Charts](/skills/assistant-code-mode/references/charts.md) |\n| Long processing, progress, cancellation | [Background work](/skills/assistant-code-mode/references/work.md) |\n| Persist JSON or files locally/shared | [Storage](/skills/assistant-code-mode/references/storage.md) |\n| Copy files between stores; list and download Filesv2 beside Grids documents | [File transfers](/skills/assistant-code-mode/references/files.md) |\n| Resource SQL, schema, row CRUD, imports | [Database](/skills/assistant-code-mode/references/database.md) |\n| Generate text, classify data or extract structured fields | [AI calculations](/skills/assistant-code-mode/references/ai.md) |\n| Discovered Cloud queries/actions | [Capability calls](/skills/assistant-code-mode/references/capabilities.md) |\n| External HTTPS and personal secrets | [HTTP and secrets](/skills/assistant-code-mode/references/http.md) |\n| Call a published App action; declare handlers | [App actions](/skills/assistant-code-mode/references/app-actions.md) |\n| Reuse work across chats, create or edit an App | [Source workflow](/skills/assistant-code-mode/references/source-workflow.md) |\n| Publish, restore, copy | [Publishing](/skills/assistant-code-mode/references/publishing.md) |\n| Find recipients or change App/Skill sharing | [Access](/skills/assistant-code-mode/references/access.md) |\n| Inspect, export, clear server data, or delete an App | [Management](/skills/assistant-code-mode/references/management.md) |\n| Execute, inspect, interact, export, stop, diagnose errors | [Run and debug](/skills/assistant-code-mode/references/debugging.md) |\n| Unfamiliar inputs or cross-app investigation | [Investigation](/skills/assistant-code-mode/references/investigation.md) |\n| Complete app starters | [Examples](/skills/assistant-code-mode/references/examples.md) |\n\nFor a new app, read Source workflow and the closest complete example before\nwriting source, plus only the API references it uses. For analytical reports or\ndashboards, also load `assistant-data-analysis` for metrics and source validation.\n\n## Choose the delivery\n\nFor a one-off chart, calculator, or interactive analysis in this conversation,\nuse `code_run({code,inputPaths})`, test the controls, then\n`code_present({runId,title})`. Read [Chat visualizations](/skills/assistant-code-mode/references/chat.md).\nA successful run is visible to the agent only; present it before saying the\nuser can see it. No saved App or chat file is necessary.\n\nUse a Studio App when the user needs an independently accessible, reusable\napplication. Use `files.save`, `code_export`, and `present` when the requested\nresult is a file. These are separate delivery choices.\n\n## Verify and deliver\n\nRun the actual source (the saved revision for Apps) and test relevant controls with IDs returned by\n`code_run`/`code_interact`, including invalid inputs and picker fixtures. Creating,\ncompiling or saving source does not verify behavior. If `work.status` is\n`running`, wait with `code_inspect({runId,waitMs:30000})`; do not restart the job.\nInspect only when the returned snapshot needs more detail. Errors and\n`outputTruncated` are not successful complete results.\n\nFor a CSV, call `await files.save(sheet.toCsv(rows), \"result.csv\")` inside code;\nfor a spreadsheet, `await files.save(await sheet.toOds(sheets), \"result.ods\")`.\nThen call the **tool** `code_export` with the returned `runId` and captured file\nname, and `present` its returned chat path. `files.save` returns no path.\nReuse exported data via its path/version rather than retyping truncated output.\nReconcile row counts, exclusions and totals before reporting findings.\n\nOpen GUI apps with `code_open`. Saving or testing does\nnot replace a user's already-running app. Stop runs no longer needed that retain\nUI, jobs or output files. Never claim an unexecuted result is verified.\n\nAgent execution runs independently of the user's tab. Agent local storage is\ntemporary; shared storage, database writes and external actions are real, even\nin tests. Cancellation and source restore do not undo them. Apps select local\nfiles explicitly; they never gain implicit access to chat attachments. Use\n`code_secret` for credentials, never chat or app controls. Honor normal access\nand approval decisions; availability is not authorization for unrelated actions.",
|
|
@@ -56,7 +56,7 @@ export const ASSISTANT_CODE_MODE_SKILL = {
|
|
|
56
56
|
},
|
|
57
57
|
{
|
|
58
58
|
"path": "references/einvoice.md",
|
|
59
|
-
"content": "# Electronic invoices\n\n`einvoice` is a global. These methods return Results: inspect `ok`, then use\n`data` or `error: {code,status,message,issues}`. Issue entries contain\n`{code,path,message,line?,column?}`; paths have zero-based row indices.\n\n| Call | Successful `data` |\n| --- | --- |\n| `einvoice.validate(input)` | `Invoice` |\n| `einvoice.calculate(lines)` | `InvoiceCalculation` |\n| `einvoice.serialize(invoice, {format: \"zugferd-2.5-en16931\"})` | `{format, xml: string, bytes: Uint8Array}` |\n| `einvoice.parseXml(xml, options?)` | `ParsedInvoice` |\n| `await einvoice.parsePdf(bytes, options?)` | `ParsedInvoice` |\n\nAll calls except `parsePdf` are synchronous. `parsePdf` takes a `Uint8Array`,\nfor example `new Uint8Array(await file.arrayBuffer())`. It reads embedded XML,\nnot scanned pages or arbitrary visual invoice layouts. For those, use the\n[local PDF text reader](documents.md) or the agent's document/vision tools.\n\nThe supported slice covers EUR CII EN16931 invoices, credit notes and self-billing,\
|
|
59
|
+
"content": "# Electronic invoices\n\n`einvoice` is a global. These methods return Results: inspect `ok`, then use\n`data` or `error: {code,status,message,issues}`. Issue entries contain\n`{code,path,message,line?,column?}`; paths have zero-based row indices.\n\n| Call | Successful `data` |\n| --- | --- |\n| `einvoice.validate(input)` | `Invoice` |\n| `einvoice.calculate(lines)` | `InvoiceCalculation` |\n| `einvoice.serialize(invoice, {format: \"zugferd-2.5-en16931\"})` | `{format, xml: string, bytes: Uint8Array}` |\n| `einvoice.parseXml(xml, options?)` | `ParsedInvoice` |\n| `await einvoice.parsePdf(bytes, options?)` | `ParsedInvoice` |\n| `einvoice.parseXml(xml, {mode: \"incoming\"})` | `ParsedIncomingInvoice` |\n| `await einvoice.parsePdf(bytes, {mode: \"incoming\"})` | `ParsedIncomingInvoice` |\n\nAll calls except `parsePdf` are synchronous. `parsePdf` takes a `Uint8Array`,\nfor example `new Uint8Array(await file.arrayBuffer())`. It reads embedded XML,\nnot scanned pages or arbitrary visual invoice layouts. For those, use the\n[local PDF text reader](documents.md) or the agent's document/vision tools.\n\nThe supported slice covers EUR CII EN16931 invoices, credit notes and self-billing,\nVAT categories S/Z/E/AE/K/G/O, units C62/HUR/DAY/KGM. Generation does not\nsupport UBL, XRechnung, discounts or prepayments. Readers preserve declared totals;\nparsing is not arithmetic verification. Validation is not XSD or Schematron\ncertification. No XSD validator is exposed.\n\n## Complete input and result shapes\n\nType descriptions only; no imports are needed. All fields are required unless\nmarked `?`; unknown fields are rejected.\n\n```ts\ntype Party = {\n name: string; id?: string; vatId: string; // \"\" when the party has no VAT ID\n address: {line1: string; city: string; postalCode: string; countryCode: string};\n};\ntype Tax = {\n taxCategory?: \"S\" | \"Z\" | \"E\" | \"AE\" | \"K\" | \"G\" | \"O\"; // default \"S\"\n taxRate: string; taxExemptionReason?: string; taxExemptionReasonCode?: string;\n};\ntype InvoiceLine = Tax & {\n id: string; name: string; description?: string;\n quantity: string; unitPrice: string; unitCode: \"C62\" | \"HUR\" | \"DAY\" | \"KGM\";\n netAmount?: string;\n};\ntype InvoiceTotals = {\n netAmount: string; taxAmount: string; grossAmount: string; dueAmount: string;\n taxGroups: (Tax & {netAmount: string; taxAmount: string})[];\n};\ntype Invoice = {\n kind: \"invoice\" | \"creditNote\" | \"selfBilling\";\n number: string; invoiceDate: string; serviceDate: string; dueDate: string;\n currency: \"EUR\"; seller: Party & {taxRegistrationId?: string}; buyer: Party;\n deliverToCountryCode?: string; buyerReference: string;\n notes?: string[];\n precedingInvoice?: {number: string; invoiceDate: string};\n payment: {iban: string; accountName: string};\n lines: InvoiceLine[]; totals?: InvoiceTotals;\n};\ntype InvoiceCalculation = InvoiceTotals & {\n lines: (InvoiceLine & {netAmount: string})[];\n};\ntype ParsedInvoice = {\n format: \"zugferd-2.5-en16931\"; profile: string; xml: string;\n invoice: Invoice; filename?: string;\n};\ntype ParseOptions = {maxCharacters?: number; maxElements?: number; maxDepth?: number};\ntype PdfOptions = ParseOptions & {maxPdfBytes?: number};\n```\n\nXML options default to 10 Mi UTF-16 code units, 100,000 elements, depth 64.\nPDF input defaults to 25 MiB. Overrides must be positive safe integers.\nA parser result's business fields are under **`data.invoice`**. Calculated\namounts are directly under **`data.netAmount`**, etc., with no `data.totals` wrapper.\n\n- Dates are real `YYYY-MM-DD` dates; `dueDate` cannot precede `invoiceDate`.\n Credit notes require `precedingInvoice`, whose date cannot be later than the\n credit note; other kinds cannot supply it. Credit-note amounts stay unsigned.\n- Lines: 1–1000, unique IDs. Quantities are positive, prices nonnegative,\n VAT rates at most 100. Decimal strings allow up to four\n fractional digits and no leading zeros. Totals/net amounts require exactly\n two fractional digits; do not convert through JavaScript Number.\n- Country codes: two uppercase letters. `payment.iban` must be valid.\n Required text is nonblank valid XML text. Limits: number/reference/line ID/VAT ID\n 100; names/address line/accountName 200; city 100; postalCode 20;\n line description and each note 4000; at most 100 notes.\n- Category S needs a positive rate; every other category uses `taxRate: \"0\"`\n and zero tax. E/AE/K/G/O need `taxExemptionReason` or a VATEX\n `taxExemptionReasonCode`; S/Z forbid both. O cannot be mixed with other\n categories and requires `vatId: \"\"` for both parties. A seller without a VAT\n ID needs `seller.id` and, outside O, `seller.taxRegistrationId`. AE/K need a\n buyer VAT ID, K/G a seller VAT ID, and K `deliverToCountryCode`.\n- `calculate` rounds each line half up to cents, then VAT per category and rate. It recalculates\n line `netAmount`; `serialize` also rejects supplied line/totals values that\n disagree. Render these calculated amounts in HTML instead of another arithmetic path.\n\n## Minimal supported invoice\n\nUse real business data and an app-owned invoice number. This illustrative\nfixture demonstrates the required fields; it is not a document to issue.\n\n```js\nconst invoice = {\n kind: \"invoice\",\n number: \"EXAMPLE-42\",\n invoiceDate: \"2026-09-15\",\n serviceDate: \"2026-09-15\",\n dueDate: \"2026-09-30\",\n currency: \"EUR\",\n seller: {\n name: \"Example Seller\", vatId: \"DE123456789\",\n address: { line1: \"Street 1\", city: \"Ulm\", postalCode: \"89073\", countryCode: \"DE\" },\n },\n buyer: {\n name: \"Example Buyer\", vatId: \"DE987654321\",\n address: { line1: \"Street 2\", city: \"Berlin\", postalCode: \"10115\", countryCode: \"DE\" },\n },\n buyerReference: \"ORDER-42\",\n payment: { iban: \"DE89370400440532013000\", accountName: \"Example Seller\" },\n lines: [{ id: \"1\", name: \"Service\", quantity: \"2.0000\", unitPrice: \"50.0000\", unitCode: \"HUR\", taxRate: \"19.00\" }],\n};\nconst result = einvoice.serialize(invoice, { format: \"zugferd-2.5-en16931\" });\nif (!result.ok) throw new Error(JSON.stringify(result.error));\nawait files.save(new Blob([result.data.bytes], { type: \"application/xml\" }), \"invoice.xml\");\n```\n\nNever infer a missing VAT identifier, tax category, exemption reason, account\nor business reference merely to satisfy input validation.\n\n## Reading received invoices\n\n`{mode: \"incoming\"}` (plus the same limits) reads a broader separate model:\nalso XRechnung 3.0/2.3 CII, other currencies, discounts, prepayments, all\npayment means and optional references. `data` is\n`{format: \"cii-en16931\", profile, xml, invoice, unmapped, filename?}`.\nAmounts are declared strings, never recalculated; O lines have no `taxRate`.\n`unmapped` lists supplementary XML elements and attributes with their paths; review it before\naccounting. Do not pass this `invoice` to `validate` or `serialize`.\n\nFor an invoice PDF, pass `serialized.data.xml` to\n[`pdf.facturX`](pdf.md) with profile `\"EN 16931\"` and matching HTML.\nNumbering, business mapping, issuance and persistence belong to the app.\n"
|
|
60
60
|
},
|
|
61
61
|
{
|
|
62
62
|
"path": "references/examples.md",
|
|
@@ -41,6 +41,7 @@ const QuerySchema = z
|
|
|
41
41
|
parent_group_id: z.uuid().optional(),
|
|
42
42
|
managed_by_user_id: z.uuid().optional(),
|
|
43
43
|
recursive: z.enum(["true", "false"]).optional(),
|
|
44
|
+
include_personal: z.enum(["true", "false"]).optional(),
|
|
44
45
|
})
|
|
45
46
|
.refine(
|
|
46
47
|
(value) => {
|
|
@@ -98,7 +99,7 @@ export const createAccountsEntitiesRoutes = (dependencies: AccountsEntitiesRoute
|
|
|
98
99
|
tags: ["Accounts"],
|
|
99
100
|
summary: "List mixed users and groups",
|
|
100
101
|
description:
|
|
101
|
-
"List visible users and groups with SQL-backed filtering and pagination. Guest accounts see only themselves and their effective groups; relation filters require a full user account.",
|
|
102
|
+
"List visible users and groups with SQL-backed filtering and pagination. Guest accounts see only themselves and their effective groups; relation filters require a full user account. Personal Linux groups (a user's private primary group, flagged with `personalOwner`) are left out unless `include_personal=true`; exact `group_ids` lookups and relation filters always return them.",
|
|
102
103
|
...requiresAuth,
|
|
103
104
|
responses: {
|
|
104
105
|
200: jsonResponse(EntitiesListResponseSchema, "Paginated mixed entity list"),
|
|
@@ -159,6 +160,7 @@ export const createAccountsEntitiesRoutes = (dependencies: AccountsEntitiesRoute
|
|
|
159
160
|
parentGroupId: query.parent_group_id,
|
|
160
161
|
managedByUserId: query.managed_by_user_id,
|
|
161
162
|
recursive: query.recursive === "true",
|
|
163
|
+
includePersonal: query.include_personal === "true",
|
|
162
164
|
});
|
|
163
165
|
|
|
164
166
|
return respond(c, {
|
package/src/api/app-approval.ts
CHANGED
|
@@ -113,7 +113,10 @@ export const createAppApprovalRoutes = (service: Service = appApproval) => {
|
|
|
113
113
|
)
|
|
114
114
|
.post("/login/status", v("json", AppLoginReferenceSchema), async (c) => {
|
|
115
115
|
const input = c.req.valid("json");
|
|
116
|
-
|
|
116
|
+
// Hold a pending request for one poll interval: the browser learns a decision at once,
|
|
117
|
+
// keeps its request cadence, and stays below Bun's 10-second idle timeout.
|
|
118
|
+
const hold = { ms: APP_APPROVAL_LIMITS.pollSeconds * 1000, signal: c.req.raw.signal };
|
|
119
|
+
return c.json(await service.browserStatus(input.requestId, input.browserSecret, hold));
|
|
117
120
|
})
|
|
118
121
|
.post(
|
|
119
122
|
"/login/complete",
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default window in which a second automatic reload for the same key is
|
|
3
|
+
* suppressed. A page load, hydration, and the first live subscription finish
|
|
4
|
+
* within a few seconds, so a condition that fails again right after a reload
|
|
5
|
+
* lands well inside it; a genuinely new change minutes later still reloads.
|
|
6
|
+
*/
|
|
7
|
+
export const AUTOMATIC_RELOAD_WINDOW_MS = 30_000;
|
|
8
|
+
|
|
9
|
+
const storageKey = (key: string) => `cloud.reload.${key}`;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Reloads the page for an automatic reason (a live event, a failed live
|
|
13
|
+
* subscription, a changed view) at most once per `key` and window in this tab.
|
|
14
|
+
*
|
|
15
|
+
* Returns `false` without reloading when the same key already reloaded within
|
|
16
|
+
* the window, or when `sessionStorage` is unavailable and a repeat therefore
|
|
17
|
+
* cannot be ruled out. The caller then keeps the page usable and offers a
|
|
18
|
+
* user-initiated reload instead. Reloads after an explicit user action do not
|
|
19
|
+
* need this guard.
|
|
20
|
+
*/
|
|
21
|
+
export const reloadOnce = (key: string, options: { windowMs?: number } = {}): boolean => {
|
|
22
|
+
const windowMs = options.windowMs ?? AUTOMATIC_RELOAD_WINDOW_MS;
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
try {
|
|
25
|
+
const storage = window.sessionStorage;
|
|
26
|
+
const last = Number(storage.getItem(storageKey(key)));
|
|
27
|
+
if (last > 0 && now >= last && now - last < windowMs) return false;
|
|
28
|
+
storage.setItem(storageKey(key), String(now));
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
window.location.reload();
|
|
33
|
+
return true;
|
|
34
|
+
};
|
package/src/contracts/shared.ts
CHANGED
|
@@ -85,12 +85,22 @@ export type LocalUser = z.infer<typeof LocalUserSchema>;
|
|
|
85
85
|
export const UserSchema = z.discriminatedUnion("provider", [IpaUserSchema, LocalUserSchema]);
|
|
86
86
|
export type User = z.infer<typeof UserSchema>;
|
|
87
87
|
|
|
88
|
+
/** The user whose personal Linux group (user private group) this is. */
|
|
89
|
+
export const PersonalGroupOwnerSchema = z.object({
|
|
90
|
+
id: z.uuid(),
|
|
91
|
+
uid: z.string(),
|
|
92
|
+
displayName: z.string(),
|
|
93
|
+
});
|
|
94
|
+
export type PersonalGroupOwner = z.infer<typeof PersonalGroupOwnerSchema>;
|
|
95
|
+
|
|
88
96
|
export const BaseGroupSchema = z.object({
|
|
89
97
|
id: z.uuid(),
|
|
90
98
|
provider: UserProviderSchema,
|
|
91
99
|
name: z.string(),
|
|
92
100
|
description: z.string().nullable(),
|
|
93
101
|
gidnumber: z.number().nullable(),
|
|
102
|
+
/** Set when the group is a user's personal Linux group; `null` for ordinary groups. */
|
|
103
|
+
personalOwner: PersonalGroupOwnerSchema.nullable(),
|
|
94
104
|
});
|
|
95
105
|
export type BaseGroup = z.infer<typeof BaseGroupSchema>;
|
|
96
106
|
|
|
@@ -909,7 +909,7 @@ export const accountsAppService = {
|
|
|
909
909
|
group: {
|
|
910
910
|
list: async (config: {
|
|
911
911
|
pagination?: PageParams;
|
|
912
|
-
filter?: { search?: string };
|
|
912
|
+
filter?: { search?: string; includePersonal?: boolean };
|
|
913
913
|
scope?: { userId?: string; ids?: string[]; provider?: UserProvider; mode?: "all" | "member" | "managed" };
|
|
914
914
|
}): Promise<Paginated<BaseGroup>> => {
|
|
915
915
|
const { page, perPage } = paginate(config.pagination);
|
|
@@ -921,6 +921,7 @@ export const accountsAppService = {
|
|
|
921
921
|
scope: config.scope?.mode,
|
|
922
922
|
ids: config.scope?.ids,
|
|
923
923
|
provider: config.scope?.provider,
|
|
924
|
+
includePersonal: config.filter?.includePersonal,
|
|
924
925
|
});
|
|
925
926
|
|
|
926
927
|
return {
|
|
@@ -1202,6 +1203,7 @@ export const accountsAppService = {
|
|
|
1202
1203
|
parentGroupId?: string;
|
|
1203
1204
|
managedByUserId?: string;
|
|
1204
1205
|
recursive?: boolean;
|
|
1206
|
+
includePersonal?: boolean;
|
|
1205
1207
|
}): Promise<Paginated<EntityListItem>> => {
|
|
1206
1208
|
const canSearchDirectory = config.actor.roles.includes("user");
|
|
1207
1209
|
const usesRelationFilter = Boolean(
|
|
@@ -1232,6 +1234,7 @@ export const accountsAppService = {
|
|
|
1232
1234
|
parentGroupId: config.parentGroupId,
|
|
1233
1235
|
managedByUserId: config.managedByUserId,
|
|
1234
1236
|
recursive: config.recursive,
|
|
1237
|
+
includePersonal: config.includePersonal,
|
|
1235
1238
|
page,
|
|
1236
1239
|
perPage,
|
|
1237
1240
|
});
|
|
@@ -1,11 +1,40 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
1
2
|
import type { BaseGroup, UserProvider } from "../../contracts/shared";
|
|
2
3
|
|
|
3
4
|
type DbRow = Record<string, unknown>;
|
|
4
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Joins the owner of a personal Linux group (the group stored as a user's
|
|
8
|
+
* `auth.user_posix.primary_group_id`) onto `auth.groups g` as
|
|
9
|
+
* `personal_owner_id`, `personal_owner_uid`, and `personal_owner_display_name`.
|
|
10
|
+
* A function, so importing this module never binds Bun's default `sql` handle.
|
|
11
|
+
*/
|
|
12
|
+
export const personalOwnerJoin = () => sql`
|
|
13
|
+
LEFT JOIN LATERAL (
|
|
14
|
+
SELECT
|
|
15
|
+
owner.id AS personal_owner_id,
|
|
16
|
+
owner.uid AS personal_owner_uid,
|
|
17
|
+
COALESCE(NULLIF(owner.display_name, ''), NULLIF(owner.mail, ''), owner.uid) AS personal_owner_display_name
|
|
18
|
+
FROM auth.user_posix owner_posix
|
|
19
|
+
JOIN auth.users owner ON owner.id = owner_posix.user_id
|
|
20
|
+
WHERE owner_posix.primary_group_id = g.id
|
|
21
|
+
ORDER BY owner.id
|
|
22
|
+
LIMIT 1
|
|
23
|
+
) personal_owner ON TRUE
|
|
24
|
+
`;
|
|
25
|
+
|
|
5
26
|
export const buildBaseGroup = (row: DbRow): BaseGroup => ({
|
|
6
27
|
id: row.id as string,
|
|
7
28
|
provider: row.provider as UserProvider,
|
|
8
29
|
name: row.name as string,
|
|
9
30
|
description: (row.description as string | null | undefined) ?? null,
|
|
10
31
|
gidnumber: (row.gid_number as number | null | undefined) ?? null,
|
|
32
|
+
personalOwner:
|
|
33
|
+
typeof row.personal_owner_id === "string"
|
|
34
|
+
? {
|
|
35
|
+
id: row.personal_owner_id,
|
|
36
|
+
uid: String(row.personal_owner_uid),
|
|
37
|
+
displayName: String(row.personal_owner_display_name),
|
|
38
|
+
}
|
|
39
|
+
: null,
|
|
11
40
|
});
|
|
@@ -2,7 +2,7 @@ import { sql } from "bun";
|
|
|
2
2
|
import type { EntityKind, EntityListItem, UserProfile, UserProvider } from "../../contracts/shared";
|
|
3
3
|
import { getFreeIpaConfig } from "../freeipa-config";
|
|
4
4
|
import { escapeLikePattern, toPgTextArray, toPgUuidArray } from "../postgres";
|
|
5
|
-
import { buildBaseGroup } from "./base-group";
|
|
5
|
+
import { buildBaseGroup, personalOwnerJoin } from "./base-group";
|
|
6
6
|
import { buildBaseUser } from "./base-user";
|
|
7
7
|
import { buildManagedGroupScopeCondition, recursiveGroupIdsSubquery } from "./group-sql";
|
|
8
8
|
|
|
@@ -26,6 +26,11 @@ export type EntityListParams = {
|
|
|
26
26
|
parentGroupId?: string;
|
|
27
27
|
managedByUserId?: string;
|
|
28
28
|
recursive?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Personal Linux groups are left out of directory browsing and search by
|
|
31
|
+
* default. Exact `groupIds` lookups and relation filters always return them.
|
|
32
|
+
*/
|
|
33
|
+
includePersonal?: boolean;
|
|
29
34
|
page?: number;
|
|
30
35
|
perPage?: number;
|
|
31
36
|
};
|
|
@@ -415,6 +420,11 @@ export const list = async (
|
|
|
415
420
|
const groupsAdmin = (await getFreeIpaConfig()).groupsAdmin;
|
|
416
421
|
const groupsAdminLiteral = toPgTextArray(groupsAdmin);
|
|
417
422
|
const spec = buildQuerySpec(params);
|
|
423
|
+
const includePersonal =
|
|
424
|
+
params.includePersonal === true ||
|
|
425
|
+
params.groupIds !== undefined ||
|
|
426
|
+
Boolean(params.memberOfGroupId || params.managerOfGroupId || params.parentGroupId || params.managedByUserId);
|
|
427
|
+
const personalCondition = includePersonal ? sql`TRUE` : sql`(kind <> 'group' OR personal_owner_id IS NULL)`;
|
|
418
428
|
const visibilityCondition =
|
|
419
429
|
params.visibility.type === "directory"
|
|
420
430
|
? sql`TRUE`
|
|
@@ -465,6 +475,7 @@ export const list = async (
|
|
|
465
475
|
AND ${excludeGroupCondition}
|
|
466
476
|
AND ${excludeServiceAccountCondition}
|
|
467
477
|
AND ${userMemberOfGroupCondition}
|
|
478
|
+
AND ${personalCondition}
|
|
468
479
|
AND (
|
|
469
480
|
${pattern}::text IS NULL
|
|
470
481
|
OR (
|
|
@@ -529,6 +540,9 @@ export const list = async (
|
|
|
529
540
|
NULL::text AS resource_id,
|
|
530
541
|
NULL::uuid AS created_by,
|
|
531
542
|
NULL::timestamptz AS created_at,
|
|
543
|
+
NULL::uuid AS personal_owner_id,
|
|
544
|
+
NULL::text AS personal_owner_uid,
|
|
545
|
+
NULL::text AS personal_owner_display_name,
|
|
532
546
|
LOWER(COALESCE(NULLIF(u.display_name, ''), NULLIF(u.mail, ''), u.uid)) AS sort_label
|
|
533
547
|
${spec.userFrom}
|
|
534
548
|
WHERE ${spec.userWhere}
|
|
@@ -558,8 +572,12 @@ export const list = async (
|
|
|
558
572
|
NULL::text AS resource_id,
|
|
559
573
|
NULL::uuid AS created_by,
|
|
560
574
|
NULL::timestamptz AS created_at,
|
|
575
|
+
personal_owner.personal_owner_id,
|
|
576
|
+
personal_owner.personal_owner_uid,
|
|
577
|
+
personal_owner.personal_owner_display_name,
|
|
561
578
|
LOWER(g.name) AS sort_label
|
|
562
579
|
${spec.groupFrom}
|
|
580
|
+
${personalOwnerJoin()}
|
|
563
581
|
WHERE ${spec.groupWhere}
|
|
564
582
|
),
|
|
565
583
|
service_account_rows AS (
|
|
@@ -590,6 +608,9 @@ export const list = async (
|
|
|
590
608
|
sa.resource_id,
|
|
591
609
|
sa.created_by,
|
|
592
610
|
sa.created_at,
|
|
611
|
+
NULL::uuid AS personal_owner_id,
|
|
612
|
+
NULL::text AS personal_owner_uid,
|
|
613
|
+
NULL::text AS personal_owner_display_name,
|
|
593
614
|
LOWER(sa.name) AS sort_label
|
|
594
615
|
FROM auth.service_accounts sa
|
|
595
616
|
),
|
|
@@ -5,7 +5,7 @@ import { getServiceIpaSession } from "../ipa/service-account";
|
|
|
5
5
|
import { toPgUuidArray } from "../postgres";
|
|
6
6
|
import { providers } from "../providers";
|
|
7
7
|
import type { AccountsActor } from "./authz";
|
|
8
|
-
import { buildBaseGroup } from "./base-group";
|
|
8
|
+
import { buildBaseGroup, personalOwnerJoin } from "./base-group";
|
|
9
9
|
import { buildManagedGroupScopeCondition, buildMemberGroupScopeCondition } from "./group-sql";
|
|
10
10
|
import * as localGroups from "./local-groups";
|
|
11
11
|
import { posix } from "./posix";
|
|
@@ -16,9 +16,10 @@ type GroupListScope = "all" | "member" | "managed";
|
|
|
16
16
|
|
|
17
17
|
const getGroup = async (id: string): Promise<BaseGroup | null> => {
|
|
18
18
|
const [row] = await sql<DbRow[]>`
|
|
19
|
-
SELECT id, provider, name, description, gid_number
|
|
20
|
-
FROM auth.groups
|
|
21
|
-
|
|
19
|
+
SELECT g.id, g.provider, g.name, g.description, g.gid_number, personal_owner.*
|
|
20
|
+
FROM auth.groups g
|
|
21
|
+
${personalOwnerJoin()}
|
|
22
|
+
WHERE g.id = ${id}::uuid
|
|
22
23
|
`;
|
|
23
24
|
if (!row) return null;
|
|
24
25
|
return buildBaseGroup(row);
|
|
@@ -30,6 +31,8 @@ const listCanonical = async (params: {
|
|
|
30
31
|
scope?: GroupListScope;
|
|
31
32
|
search?: string;
|
|
32
33
|
provider?: UserProvider;
|
|
34
|
+
/** Personal Linux groups are left out of browsing and search unless requested; an explicit `ids` lookup always returns them. */
|
|
35
|
+
includePersonal?: boolean;
|
|
33
36
|
page?: number;
|
|
34
37
|
perPage?: number;
|
|
35
38
|
}): Promise<{
|
|
@@ -45,6 +48,7 @@ const listCanonical = async (params: {
|
|
|
45
48
|
const scope = params.scope ?? (params.userId ? "member" : "all");
|
|
46
49
|
const scopeUserId = params.userId ?? "00000000-0000-0000-0000-000000000000";
|
|
47
50
|
const idsCondition = ids.length === 0 ? sql`TRUE` : sql`g.id = ANY(${toPgUuidArray(ids)}::uuid[])`;
|
|
51
|
+
const includePersonal = params.includePersonal === true || params.ids !== undefined;
|
|
48
52
|
|
|
49
53
|
if (params.ids && params.ids.length === 0) {
|
|
50
54
|
return {
|
|
@@ -60,10 +64,12 @@ const listCanonical = async (params: {
|
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
const rows = await sql<DbRow[]>`
|
|
63
|
-
SELECT g.id, g.provider, g.name, g.description, g.gid_number, COUNT(*) OVER() AS total
|
|
67
|
+
SELECT g.id, g.provider, g.name, g.description, g.gid_number, personal_owner.*, COUNT(*) OVER() AS total
|
|
64
68
|
FROM auth.groups g
|
|
69
|
+
${personalOwnerJoin()}
|
|
65
70
|
WHERE (${params.provider ?? null}::text IS NULL OR g.provider = ${params.provider ?? null})
|
|
66
71
|
AND ${idsCondition}
|
|
72
|
+
AND (${includePersonal} = true OR personal_owner.personal_owner_id IS NULL)
|
|
67
73
|
AND (
|
|
68
74
|
${scope === "all"} = true
|
|
69
75
|
OR ${params.userId ?? null}::uuid IS NULL
|
|
@@ -99,6 +105,8 @@ export const list = async (params: {
|
|
|
99
105
|
scope?: GroupListScope;
|
|
100
106
|
search?: string;
|
|
101
107
|
provider?: UserProvider;
|
|
108
|
+
/** Personal Linux groups are left out of browsing and search unless requested; an explicit `ids` lookup always returns them. */
|
|
109
|
+
includePersonal?: boolean;
|
|
102
110
|
page?: number;
|
|
103
111
|
perPage?: number;
|
|
104
112
|
}) => {
|
|
@@ -16,7 +16,8 @@ export type AccountIdentityUser = {
|
|
|
16
16
|
profile: UserProfile;
|
|
17
17
|
posix: { uidNumber: number; primaryGidNumber: number } | null;
|
|
18
18
|
};
|
|
19
|
-
|
|
19
|
+
/** `personal` marks a user's personal Linux group (their stored primary group). */
|
|
20
|
+
export type AccountIdentityGroup = { id: string; provider: UserProvider; name: string; gidNumber: number | null; personal: boolean };
|
|
20
21
|
export type AccountIdentityPage<T> = { items: T[]; nextCursor: string | null };
|
|
21
22
|
export type AccountIdentityAvailability = { localLinuxEnabled: boolean; freeipaEnabled: boolean };
|
|
22
23
|
type InventoryFilter = { provider: UserProvider; after?: string; id?: string; name?: string };
|
|
@@ -119,8 +120,10 @@ export const createAccountIdentityService = (db: typeof sql = sql, upstreamIdent
|
|
|
119
120
|
return current;
|
|
120
121
|
};
|
|
121
122
|
const groupRows = async (filter: SQLQuery) => {
|
|
122
|
-
const rows = await db<{ id: string; provider: UserProvider; name: string; gid_number: number | null }[]>`
|
|
123
|
-
SELECT g.id, g.provider, g.name, g.gid_number
|
|
123
|
+
const rows = await db<{ id: string; provider: UserProvider; name: string; gid_number: number | null; personal: boolean }[]>`
|
|
124
|
+
SELECT g.id, g.provider, g.name, g.gid_number,
|
|
125
|
+
EXISTS(SELECT 1 FROM auth.user_posix p WHERE p.primary_group_id = g.id) AS personal
|
|
126
|
+
FROM auth.groups g
|
|
124
127
|
WHERE ${filter} ORDER BY g.id LIMIT ${PAGE_SIZE + 1}
|
|
125
128
|
`;
|
|
126
129
|
return rows.map(
|
|
@@ -129,6 +132,7 @@ export const createAccountIdentityService = (db: typeof sql = sql, upstreamIdent
|
|
|
129
132
|
provider: row.provider,
|
|
130
133
|
name: row.name,
|
|
131
134
|
gidNumber: positiveId(row.gid_number) ? row.gid_number : null,
|
|
135
|
+
personal: row.personal,
|
|
132
136
|
}),
|
|
133
137
|
);
|
|
134
138
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { sql } from "bun";
|
|
2
2
|
import type { BaseGroup, GroupMember, MutationResult, UserProvider } from "../../contracts/shared";
|
|
3
3
|
import { escapeLikePattern, isUniqueViolation } from "../postgres";
|
|
4
|
+
import { buildBaseGroup, personalOwnerJoin } from "./base-group";
|
|
4
5
|
|
|
5
6
|
type DbRow = Record<string, unknown>;
|
|
6
7
|
|
|
@@ -18,6 +19,7 @@ const toBaseGroup = (row: LocalGroupRow): BaseGroup => ({
|
|
|
18
19
|
name: row.name,
|
|
19
20
|
description: row.description,
|
|
20
21
|
gidnumber: row.gidNumber,
|
|
22
|
+
personalOwner: null,
|
|
21
23
|
});
|
|
22
24
|
|
|
23
25
|
const getLocalGroupById = async (id: string): Promise<LocalGroupRow | null> => {
|
|
@@ -73,8 +75,13 @@ const wouldCreateLocalGroupCycle = async (params: { parentGroupId: string; child
|
|
|
73
75
|
};
|
|
74
76
|
|
|
75
77
|
export const get = async (params: { id: string }): Promise<BaseGroup | null> => {
|
|
76
|
-
const row = await
|
|
77
|
-
|
|
78
|
+
const [row] = await sql<DbRow[]>`
|
|
79
|
+
SELECT g.id, g.provider, g.name, g.description, g.gid_number, personal_owner.*
|
|
80
|
+
FROM auth.groups g
|
|
81
|
+
${personalOwnerJoin()}
|
|
82
|
+
WHERE g.id = ${params.id}::uuid AND g.provider = 'local'
|
|
83
|
+
`;
|
|
84
|
+
return row ? buildBaseGroup(row) : null;
|
|
78
85
|
};
|
|
79
86
|
|
|
80
87
|
export const create = async (params: { name: string; description?: string }, db: typeof sql = sql): Promise<MutationResult<BaseGroup>> => {
|
|
@@ -119,8 +126,9 @@ export const list = async (params: { page?: number; perPage?: number; search?: s
|
|
|
119
126
|
`;
|
|
120
127
|
const total = Number(countRow?.count ?? 0);
|
|
121
128
|
const rows = await sql<DbRow[]>`
|
|
122
|
-
SELECT id, provider, name, description, gid_number
|
|
129
|
+
SELECT g.id, g.provider, g.name, g.description, g.gid_number, personal_owner.*
|
|
123
130
|
FROM auth.groups g
|
|
131
|
+
${personalOwnerJoin()}
|
|
124
132
|
WHERE g.provider = 'local'
|
|
125
133
|
AND (${pattern}::text IS NULL OR LOWER(g.name) LIKE ${pattern} ESCAPE '\\' OR LOWER(g.description) LIKE ${pattern} ESCAPE '\\')
|
|
126
134
|
ORDER BY g.name
|
|
@@ -128,15 +136,7 @@ export const list = async (params: { page?: number; perPage?: number; search?: s
|
|
|
128
136
|
`;
|
|
129
137
|
|
|
130
138
|
return {
|
|
131
|
-
groups: rows.map(
|
|
132
|
-
toBaseGroup({
|
|
133
|
-
id: row.id as string,
|
|
134
|
-
provider: row.provider as "local",
|
|
135
|
-
name: row.name as string,
|
|
136
|
-
description: row.description as string | null,
|
|
137
|
-
gidNumber: row.gid_number as number | null,
|
|
138
|
-
}),
|
|
139
|
-
),
|
|
139
|
+
groups: rows.map(buildBaseGroup),
|
|
140
140
|
total,
|
|
141
141
|
pagination: {
|
|
142
142
|
page,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { type SQL, sql } from "bun";
|
|
3
|
+
import { lazySync } from "../_internal/process-sync";
|
|
3
4
|
import { env } from "../config/env";
|
|
4
5
|
import { type AccountCategory, accountCategory } from "../contracts/account-categories";
|
|
5
6
|
import {
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
import { publicCloudOrigin } from "../shared/app-url";
|
|
18
19
|
import { isAccountCategoryAllowed } from "./account-category-policy";
|
|
19
20
|
import { audit } from "./audit";
|
|
21
|
+
import { logger } from "./logging";
|
|
20
22
|
import { CORE_SETTINGS } from "./settings/core-settings";
|
|
21
23
|
import { decryptValue } from "./settings/crypto";
|
|
22
24
|
|
|
@@ -136,6 +138,33 @@ type LoginRow = {
|
|
|
136
138
|
};
|
|
137
139
|
export type AppApprovalActor = { userId: string; sid: string; admin: boolean };
|
|
138
140
|
export type AppDeviceEnrollmentNotice = { deviceId: string; userId: string; name: string; assisted: boolean };
|
|
141
|
+
/** Best-effort wake-ups for browsers waiting on a login decision. Postgres stays authoritative. */
|
|
142
|
+
export type AppLoginDecisionHints = {
|
|
143
|
+
/** Call after the decision commits. Must not throw or delay the caller. */
|
|
144
|
+
publish(requestId: string): void;
|
|
145
|
+
/** Settles on the first hint for this request, or when `signal` aborts. */
|
|
146
|
+
wait(requestId: string, signal: AbortSignal): Promise<void>;
|
|
147
|
+
};
|
|
148
|
+
const log = logger("app-approval");
|
|
149
|
+
const decisionTopic = lazySync((sync) =>
|
|
150
|
+
sync.topic<null>({
|
|
151
|
+
id: "cloud:app-login-decisions",
|
|
152
|
+
owner: "cloud",
|
|
153
|
+
retention: { maxAgeMs: 120_000, maxBytes: 1_048_576 },
|
|
154
|
+
maxPayloadBytes: 2000,
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
157
|
+
/** Core NATS broadcast reaches the waiting browser on any replica. */
|
|
158
|
+
export const syncLoginDecisionHints: AppLoginDecisionHints = {
|
|
159
|
+
publish: (requestId) => {
|
|
160
|
+
void Promise.resolve()
|
|
161
|
+
.then(() => decisionTopic().publish({ tenantId: requestId, data: null }))
|
|
162
|
+
.catch((error) => log.warn("App login decision hint failed", { error: String(error) }));
|
|
163
|
+
},
|
|
164
|
+
wait: async (requestId, signal) => {
|
|
165
|
+
for await (const _event of decisionTopic().live({ tenantId: requestId, signal })) return;
|
|
166
|
+
},
|
|
167
|
+
};
|
|
139
168
|
const view = (row: DeviceRow): AppDeviceView => ({
|
|
140
169
|
id: row.id,
|
|
141
170
|
name: row.name,
|
|
@@ -163,6 +192,7 @@ const verify = async (key: AppDevicePublicKey, message: string, signature: strin
|
|
|
163
192
|
export const createAppApprovalService = (
|
|
164
193
|
db: SQL = sql,
|
|
165
194
|
configuration = (requireEnabled = true) => readAppApprovalConfig(db, requireEnabled),
|
|
195
|
+
hints: AppLoginDecisionHints = syncLoginDecisionHints,
|
|
166
196
|
) => {
|
|
167
197
|
const config = async (requireEnabled = true) => {
|
|
168
198
|
const value = await configuration(requireEnabled);
|
|
@@ -409,7 +439,7 @@ export const createAppApprovalService = (
|
|
|
409
439
|
)
|
|
410
440
|
return reject("FORBIDDEN", 403);
|
|
411
441
|
await cleanup();
|
|
412
|
-
|
|
442
|
+
const outcome = await db.begin(async (tx) => {
|
|
413
443
|
const device = await activeDevice(tx, proof.deviceId, cfg.issuer);
|
|
414
444
|
if (!(await verify(AppDevicePublicKeySchema.parse(device.public_key), appDeviceProofMessage(proof), signature)))
|
|
415
445
|
return reject("FORBIDDEN", 403);
|
|
@@ -460,12 +490,39 @@ export const createAppApprovalService = (
|
|
|
460
490
|
await record(tx, `login.${command.decision}`, device.user_id, device.id, { requestId: row.id });
|
|
461
491
|
return { state };
|
|
462
492
|
});
|
|
493
|
+
// Only after commit: the waiting browser re-reads Postgres, so a hint never grants anything.
|
|
494
|
+
if (proof.command.operation === "decide") hints.publish(proof.command.requestId);
|
|
495
|
+
return outcome;
|
|
463
496
|
},
|
|
464
|
-
|
|
497
|
+
/** With `hold`, a pending request is answered on its decision, its expiry, or after `hold.ms`, whichever comes first. */
|
|
498
|
+
browserStatus: async (id: string, token: string, hold?: { ms: number; signal?: AbortSignal }) => {
|
|
465
499
|
const cfg = await config();
|
|
466
|
-
const
|
|
467
|
-
|
|
468
|
-
|
|
500
|
+
const read = async () => {
|
|
501
|
+
const [row] = await db<LoginRow[]>`SELECT * FROM auth.app_logins WHERE id=${id}::uuid AND issuer=${cfg.issuer}`;
|
|
502
|
+
if (!row || !matches(token, row.browser_hash)) return reject("UNAVAILABLE", 404);
|
|
503
|
+
const expiresIn = new Date(row.expires_at).getTime() - Date.now();
|
|
504
|
+
return { state: expiresIn <= 0 ? "expired" : row.state, expiresIn };
|
|
505
|
+
};
|
|
506
|
+
const result = (state: string) => ({ state, pollAfterSeconds: limits.pollSeconds });
|
|
507
|
+
if (!hold) return result((await read()).state);
|
|
508
|
+
// Listen before the first read so a decision committed in between still wakes this request.
|
|
509
|
+
const waiting = new AbortController();
|
|
510
|
+
const ended = new Promise((resolve) => waiting.signal.addEventListener("abort", resolve, { once: true }));
|
|
511
|
+
const stop = () => waiting.abort();
|
|
512
|
+
hold.signal?.addEventListener("abort", stop, { once: true });
|
|
513
|
+
if (hold.signal?.aborted) stop();
|
|
514
|
+
const hint = hints.wait(id, waiting.signal).catch(() => {});
|
|
515
|
+
try {
|
|
516
|
+
const first = await read();
|
|
517
|
+
if (first.state !== "pending") return result(first.state);
|
|
518
|
+
const timer = setTimeout(stop, Math.min(hold.ms, first.expiresIn));
|
|
519
|
+
await Promise.race([hint, ended]);
|
|
520
|
+
clearTimeout(timer);
|
|
521
|
+
return result((await read()).state);
|
|
522
|
+
} finally {
|
|
523
|
+
hold.signal?.removeEventListener("abort", stop);
|
|
524
|
+
stop();
|
|
525
|
+
}
|
|
469
526
|
},
|
|
470
527
|
consumeLogin: async (id: string, token: string) => {
|
|
471
528
|
const cfg = await config();
|
|
@@ -22,6 +22,7 @@ const toBaseGroup = (row: IpaGroupRow): BaseGroup => ({
|
|
|
22
22
|
name: row.name,
|
|
23
23
|
description: row.description,
|
|
24
24
|
gidnumber: row.gidNumber,
|
|
25
|
+
personalOwner: null,
|
|
25
26
|
});
|
|
26
27
|
|
|
27
28
|
const getIpaGroupById = async (id: string): Promise<IpaGroupRow | null> => {
|
|
@@ -142,6 +143,7 @@ export const list = async (params: {
|
|
|
142
143
|
name: row.name as string,
|
|
143
144
|
description: row.description as string | null,
|
|
144
145
|
gidnumber: row.gid_number as number | null,
|
|
146
|
+
personalOwner: null,
|
|
145
147
|
})),
|
|
146
148
|
total,
|
|
147
149
|
pagination: { page, perPage, totalPages, hasNext: page < totalPages },
|
|
@@ -377,6 +379,7 @@ export const add = async (params: {
|
|
|
377
379
|
name: row.name as string,
|
|
378
380
|
description: row.description as string | null,
|
|
379
381
|
gidnumber: row.gid_number as number | null,
|
|
382
|
+
personalOwner: null,
|
|
380
383
|
},
|
|
381
384
|
};
|
|
382
385
|
};
|
package/src/services/webauthn.ts
CHANGED
|
@@ -21,7 +21,7 @@ import { coreSettings } from "./settings/api";
|
|
|
21
21
|
const CHALLENGE_TTL_SECONDS = 300;
|
|
22
22
|
const REGISTRATION_CHALLENGE_PREFIX = "webauthn:registration:";
|
|
23
23
|
const AUTHENTICATION_CHALLENGE_PREFIX = "webauthn:authentication:";
|
|
24
|
-
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{12}$/i;
|
|
24
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
25
25
|
const log = logger("auth:webauthn");
|
|
26
26
|
|
|
27
27
|
type DbPasskeyRow = {
|
|
@@ -13,3 +13,17 @@ export const getAccountTypeLabel = (user: Pick<AccountLike, "profile">): string
|
|
|
13
13
|
export const getManagementLabel = (user: Pick<AccountLike, "provider">): string => (user.provider === "ipa" ? "FreeIPA" : "Local");
|
|
14
14
|
|
|
15
15
|
export const getSupplementalRoleLabel = (role: SupplementalRole): string => (role === "group-manager" ? "Group Manager" : "Admin");
|
|
16
|
+
|
|
17
|
+
const isGerman = (locale: string | null | undefined): boolean => locale?.toLowerCase().split(/[-_]/)[0] === "de";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Display form of a group name for people. In German, the first letter of each
|
|
21
|
+
* word separated by whitespace or a hyphen is capitalised (`buchhaltung` →
|
|
22
|
+
* `Buchhaltung`); other locales get the name unchanged. Use it only for rendered
|
|
23
|
+
* text: stored names, identifiers, URLs, search input, and API or JSON output
|
|
24
|
+
* keep the original name.
|
|
25
|
+
*/
|
|
26
|
+
export const groupDisplayName = (name: string, locale: string | null | undefined): string =>
|
|
27
|
+
isGerman(locale)
|
|
28
|
+
? name.replace(/(^|[\s-])(\p{L})/gu, (_, separator: string, letter: string) => separator + letter.toLocaleUpperCase("de"))
|
|
29
|
+
: name;
|