@k2b/cloud 0.45.0 → 0.46.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 +5 -2
- package/src/_internal/app-queries.ts +73 -0
- package/src/_internal/capabilities.ts +137 -9
- package/src/_internal/define-app.ts +2 -0
- package/src/_internal/registry-validation.ts +17 -4
- package/src/_internal/registry.ts +32 -4
- package/src/_internal/resource-principals.ts +36 -0
- package/src/ai/capabilities.ts +4 -2
- package/src/ai/chat/map-messages.ts +270 -0
- package/src/ai/chat/map-notebook.browser-harness.tsx +28 -0
- package/src/ai/chat/map-notebook.tsx +301 -0
- package/src/ai/chat/map-widget.browser-harness.tsx +71 -0
- package/src/ai/chat/map-widget.css +107 -0
- package/src/ai/chat/map-widget.tsx +1279 -0
- package/src/ai/chat/message-utils.ts +1 -0
- package/src/ai/chat/tool-names.ts +2 -0
- package/src/ai/chat/turn-layout.ts +2 -1
- package/src/ai/chat/turn-view.tsx +17 -1
- package/src/ai/default-tools.ts +3 -0
- package/src/ai/executor.ts +13 -1
- package/src/ai/map-contracts.ts +209 -0
- package/src/ai/map-execution.ts +14 -0
- package/src/ai/map-notebook-save.ts +276 -0
- package/src/ai/map-notebook.ts +32 -0
- package/src/ai/map-opening-hours.ts +40 -0
- package/src/ai/map-routes.ts +84 -0
- package/src/ai/map-service.ts +288 -0
- package/src/ai/map-store.ts +137 -0
- package/src/ai/map-tool.ts +73 -0
- package/src/ai/migrate.ts +3 -0
- package/src/ai/protocol.ts +3 -1
- package/src/ai/routes.ts +2 -0
- package/src/ai/store.ts +19 -6
- package/src/ai/tools.ts +14 -0
- package/src/ai/types.ts +3 -0
- package/src/api/capabilities.ts +90 -12
- package/src/api/mcp.ts +4 -2
- package/src/browser/notifications.ts +25 -8
- package/src/capabilities/discussion-providers.ts +99 -0
- package/src/capabilities/executions.ts +2 -2
- package/src/capabilities/migrate.ts +10 -1
- package/src/capabilities/resource-readers.ts +58 -0
- package/src/capabilities/server-invoke.ts +23 -13
- package/src/capabilities/server.ts +17 -6
- package/src/capabilities/testing.ts +13 -0
- package/src/contracts/capabilities.ts +137 -4
- package/src/contracts/discussions.ts +91 -0
- package/src/contracts/geo-document.ts +60 -0
- package/src/contracts/geo-snapshot.ts +86 -0
- package/src/contracts/geo.ts +14 -0
- package/src/contracts/index.ts +2 -0
- package/src/contracts/outgoing-mail.ts +1 -1
- package/src/contracts/resource-types.ts +21 -0
- package/src/geo/AddressAutocomplete.tsx +213 -0
- package/src/geo/messages.ts +29 -0
- package/src/geo/navigation.ts +25 -0
- package/src/geo/ui.ts +2 -0
- package/src/services/accounts/app.ts +28 -6
- package/src/services/accounts/entities.ts +11 -4
- package/src/services/accounts/groups.ts +10 -4
- package/src/services/accounts/local-groups.ts +87 -38
- package/src/services/accounts/machine-group-management.ts +94 -0
- package/src/services/geo/cache.ts +2 -1
- package/src/services/geo/geoapify.ts +3 -0
- package/src/services/identity/invocation-token.ts +110 -0
- package/src/services/pdf/geo-snapshot.ts +107 -0
- package/src/services/pdf/index.ts +2 -0
- package/src/ssr/AdminArea.tsx +2 -2
- package/src/ssr/AdminLayout.tsx +1 -1
- package/src/ssr/AdminSidebar.tsx +1 -1
- package/src/styles/global.css +2 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
// Matches the geo result JSON envelope and the existing print decoder budgets.
|
|
4
|
+
export const GEO_SNAPSHOT_MAX_METADATA_BYTES = 256 * 1024;
|
|
5
|
+
export const GEO_SNAPSHOT_MAX_PIXELS = 16 * 1024 * 1024;
|
|
6
|
+
export const GEO_SNAPSHOT_MAX_BYTES = 32 * 1024 * 1024;
|
|
7
|
+
export const GeoMapSnapshotIdSchema = z
|
|
8
|
+
.string()
|
|
9
|
+
.min(1)
|
|
10
|
+
.max(100)
|
|
11
|
+
.regex(/^[A-Za-z0-9_-]+$/);
|
|
12
|
+
export const GeoMapSnapshotImageSchema = z
|
|
13
|
+
.strictObject({
|
|
14
|
+
id: GeoMapSnapshotIdSchema,
|
|
15
|
+
fingerprint: z.string().regex(/^[a-f0-9]{64}$/),
|
|
16
|
+
width: z.number().int().min(1).max(16383),
|
|
17
|
+
height: z.number().int().min(1).max(16383),
|
|
18
|
+
})
|
|
19
|
+
.refine((image) => image.width * image.height <= GEO_SNAPSHOT_MAX_PIXELS, "Map image exceeds decoded pixel budget");
|
|
20
|
+
export const GeoMapSnapshotManifestSchema = z
|
|
21
|
+
.strictObject({
|
|
22
|
+
version: z.literal(1),
|
|
23
|
+
revision: z.string().min(1).max(300),
|
|
24
|
+
// Even IDs with one character need a 64-byte SHA-256: this bounds schema work independently of input parsing.
|
|
25
|
+
images: z.array(GeoMapSnapshotImageSchema).max(GEO_SNAPSHOT_MAX_METADATA_BYTES / 64),
|
|
26
|
+
})
|
|
27
|
+
.refine((value) => new Set(value.images.map((image) => image.id)).size === value.images.length, "Duplicate map ID");
|
|
28
|
+
export type GeoMapSnapshotManifest = z.infer<typeof GeoMapSnapshotManifestSchema>;
|
|
29
|
+
export const geoMapSnapshotPart = (id: string) => `map-snapshot-${GeoMapSnapshotIdSchema.parse(id)}`;
|
|
30
|
+
|
|
31
|
+
/** Hash the validated saved GeoDocument, not a lossy geometry projection. Same algorithm in browser and server. */
|
|
32
|
+
export async function geoMapSnapshotFingerprint(value: unknown): Promise<string> {
|
|
33
|
+
// First enforce genuine JSON (and bound traversal) without invoking caller-defined toJSON/accessors.
|
|
34
|
+
let remaining = GEO_SNAPSHOT_MAX_METADATA_BYTES;
|
|
35
|
+
const ancestors = new Set<object>();
|
|
36
|
+
const canonical = (input: unknown): string => {
|
|
37
|
+
if (--remaining < 0) throw new RangeError("Map snapshot input exceeds geo JSON budget");
|
|
38
|
+
if (
|
|
39
|
+
input === null ||
|
|
40
|
+
typeof input === "boolean" ||
|
|
41
|
+
typeof input === "string" ||
|
|
42
|
+
(typeof input === "number" && Number.isFinite(input))
|
|
43
|
+
) {
|
|
44
|
+
const result = JSON.stringify(input);
|
|
45
|
+
remaining -= result.length;
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
if (typeof input !== "object" || !input || ancestors.has(input)) throw new TypeError("Map snapshot input must be JSON");
|
|
49
|
+
if (!Array.isArray(input) && Object.getPrototypeOf(input) !== Object.prototype && Object.getPrototypeOf(input) !== null)
|
|
50
|
+
throw new TypeError("Map snapshot input must be plain JSON");
|
|
51
|
+
ancestors.add(input);
|
|
52
|
+
try {
|
|
53
|
+
if (Array.isArray(input)) {
|
|
54
|
+
if (input.length > remaining) throw new RangeError("Map snapshot input exceeds geo JSON budget");
|
|
55
|
+
const items: string[] = [];
|
|
56
|
+
for (let index = 0; index < input.length; index++) {
|
|
57
|
+
const property = Object.getOwnPropertyDescriptor(input, String(index));
|
|
58
|
+
if (!property || !("value" in property)) throw new TypeError("Map snapshot input must be plain JSON");
|
|
59
|
+
items.push(canonical(property.value));
|
|
60
|
+
}
|
|
61
|
+
return `[${items.join(",")}]`;
|
|
62
|
+
}
|
|
63
|
+
return `{${Object.keys(input)
|
|
64
|
+
.sort()
|
|
65
|
+
.map((key) => {
|
|
66
|
+
const property = Object.getOwnPropertyDescriptor(input, key)!;
|
|
67
|
+
if (!("value" in property)) throw new TypeError("Map snapshot input must not contain accessors");
|
|
68
|
+
remaining -= key.length;
|
|
69
|
+
return `${JSON.stringify(key)}:${canonical(property.value)}`;
|
|
70
|
+
})
|
|
71
|
+
.join(",")}}`;
|
|
72
|
+
} finally {
|
|
73
|
+
ancestors.delete(input);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
let serialized: string;
|
|
77
|
+
try {
|
|
78
|
+
serialized = canonical(value);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (error instanceof RangeError) throw new RangeError("Map snapshot input exceeds geo JSON budget");
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
const bytes = new TextEncoder().encode(serialized);
|
|
84
|
+
if (bytes.byteLength > GEO_SNAPSHOT_MAX_METADATA_BYTES) throw new RangeError("Map snapshot input exceeds geo JSON budget");
|
|
85
|
+
return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
86
|
+
}
|
package/src/contracts/geo.ts
CHANGED
|
@@ -30,6 +30,12 @@ export const GeoAddressSchema = z
|
|
|
30
30
|
point: GeoPointSchema.describe("Resolved geographic point."),
|
|
31
31
|
formatted: z.string().max(600).describe("Formatted provider address."),
|
|
32
32
|
confidence: z.number().min(0).max(1).nullable().describe("Provider match confidence, or null when unavailable."),
|
|
33
|
+
buildingConfidence: z
|
|
34
|
+
.number()
|
|
35
|
+
.min(0)
|
|
36
|
+
.max(1)
|
|
37
|
+
.nullable()
|
|
38
|
+
.describe("Provider certainty that this point is the requested building; null without a house-number match."),
|
|
33
39
|
provenance: GeoProvenanceSchema.describe("Provider source and required attribution."),
|
|
34
40
|
})
|
|
35
41
|
.strict();
|
|
@@ -128,6 +134,13 @@ export const GEO_MAX_POLYLINE_CHARS = 102_400;
|
|
|
128
134
|
export const GeoPlaceSearchInputSchema = z
|
|
129
135
|
.object({
|
|
130
136
|
categories: z.array(GeoCategorySchema).describe("1–5 Cloud categories."),
|
|
137
|
+
name: z
|
|
138
|
+
.string()
|
|
139
|
+
.trim()
|
|
140
|
+
.min(1)
|
|
141
|
+
.max(300)
|
|
142
|
+
.optional()
|
|
143
|
+
.describe("Optional provider place-name filter, 1–300 characters after trimming; categories still apply."),
|
|
131
144
|
near: GeoPointSchema.optional().describe("Rank by distance to this point; choose near, area, or along."),
|
|
132
145
|
area: GeoAreaSchema.optional().describe("Optional geographic filter."),
|
|
133
146
|
along: z
|
|
@@ -249,6 +262,7 @@ export type GeoAutocompleteInput = z.infer<typeof GeoAutocompleteInputSchema>;
|
|
|
249
262
|
export type GeoGeocodeInput = z.infer<typeof GeoGeocodeInputSchema>;
|
|
250
263
|
export type GeoReverseInput = z.infer<typeof GeoReverseInputSchema>;
|
|
251
264
|
export type GeoPlaceSearchInput = z.infer<typeof GeoPlaceSearchInputSchema>;
|
|
265
|
+
export type GeoAvailabilityData = z.infer<typeof GeoAvailabilityDataSchema>;
|
|
252
266
|
export type GeoErrorCode = z.infer<typeof GeoErrorDetailsSchema>["code"];
|
|
253
267
|
export type GeoFunction = "autocomplete" | "geocode" | "reverse" | "places" | "route";
|
|
254
268
|
export const geoContract = {
|
package/src/contracts/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ export * from "./capabilities";
|
|
|
10
10
|
export * from "./capability-compatibility";
|
|
11
11
|
export * from "./commands";
|
|
12
12
|
export * from "./contact-directory";
|
|
13
|
+
export * from "./discussions";
|
|
13
14
|
export * from "./file-provider";
|
|
14
15
|
export * from "./geo";
|
|
15
16
|
export * from "./geo-admin";
|
|
@@ -29,6 +30,7 @@ export type { RailShortcut } from "./rail-preferences";
|
|
|
29
30
|
export * from "./registry";
|
|
30
31
|
export * from "./resource-clipboard";
|
|
31
32
|
export { type ResolvedCloudResourceUrl, ResolvedCloudResourceUrlSchema } from "./resource-resolution";
|
|
33
|
+
export * from "./resource-types";
|
|
32
34
|
export * from "./seen-hints";
|
|
33
35
|
export * from "./shared";
|
|
34
36
|
export * from "./user-notifications";
|
|
@@ -2,7 +2,7 @@ import type { PageParams, Paginated, ServiceError } from "@k2b/stdlib";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { type RequestActor, ServiceAccountSchema, UserSchema } from "./shared";
|
|
4
4
|
|
|
5
|
-
export const PlatformPermissionSchema = z.enum(["mail:send", "mail:read"]);
|
|
5
|
+
export const PlatformPermissionSchema = z.enum(["mail:send", "mail:read", "resources:readers"]);
|
|
6
6
|
export type PlatformPermission = z.infer<typeof PlatformPermissionSchema>;
|
|
7
7
|
export const MailRetentionSchema = z.object({ contentDays: z.int().min(1).max(36500), recordDays: z.int().min(1).max(36500) }).strict();
|
|
8
8
|
export type MailRetention = z.infer<typeof MailRetentionSchema> & {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type CapabilityManifest, CloudResourceReaderViewSchema } from "./capabilities";
|
|
3
|
+
import { type CapabilityContractIssue, capabilityContractIssues } from "./capability-compatibility";
|
|
4
|
+
|
|
5
|
+
/** Check the reader's guaranteed `data.view`; the type model supplies containment. */
|
|
6
|
+
export const typeViewIssues = (
|
|
7
|
+
manifest: Pick<CapabilityManifest, "types" | "queries" | "resourceTypes">,
|
|
8
|
+
localId: string,
|
|
9
|
+
): CapabilityContractIssue[] => {
|
|
10
|
+
const type = manifest.types.find((type) => type.localId === localId);
|
|
11
|
+
const reader = manifest.queries.find((query) => query.localId === type?.reader);
|
|
12
|
+
if (!type || !reader) return [{ code: "kind", path: "$", message: `Type ${localId} needs a reader` }];
|
|
13
|
+
const container = manifest.resourceTypes?.find((model) => model.type === localId)?.container;
|
|
14
|
+
const view = container ? CloudResourceReaderViewSchema.required({ container: true }) : CloudResourceReaderViewSchema;
|
|
15
|
+
const issues = capabilityContractIssues(
|
|
16
|
+
{ kind: "query", input: z.object({}).strict(), data: z.looseObject({ view }) },
|
|
17
|
+
{ kind: "query", operation: reader },
|
|
18
|
+
).filter((issue) => issue.code !== "input");
|
|
19
|
+
if (reader.appOnly) issues.push({ code: "kind", path: "$", message: "A resource reader must accept person authority" });
|
|
20
|
+
return issues;
|
|
21
|
+
};
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { Combobox, type ComboboxOption, type FieldProps, TextInput, useLocale } from "@k2b/ui";
|
|
2
|
+
import { createSignal, type JSX, onCleanup, onMount, Show } from "solid-js";
|
|
3
|
+
import { invokeCapabilityWithDataSchema } from "../capabilities/client";
|
|
4
|
+
import { type GeoAddress, type GeoAvailabilityData, geoContract } from "../contracts/geo";
|
|
5
|
+
import { geoUiMessages } from "./messages";
|
|
6
|
+
|
|
7
|
+
type Budget = { scope: "person" | "cloud"; resetsAt: string };
|
|
8
|
+
|
|
9
|
+
export type AddressAutocompleteProps = Omit<FieldProps, "label"> & {
|
|
10
|
+
label: JSX.Element;
|
|
11
|
+
/** The street line as typed; the person's own text always stays possible. */
|
|
12
|
+
value: string;
|
|
13
|
+
onValueChange: (value: string) => void;
|
|
14
|
+
/** A chosen suggestion. The host fills its fields from it and keeps point and provenance. */
|
|
15
|
+
onPick: (address: GeoAddress) => void;
|
|
16
|
+
/** The person keeps the typed text instead of a suggestion. */
|
|
17
|
+
onOwnEntry?: (value: string) => void;
|
|
18
|
+
/** From the SSR snapshot. Without a configured geo service the field is a plain text input. */
|
|
19
|
+
availability?: GeoAvailabilityData;
|
|
20
|
+
placeholder?: string;
|
|
21
|
+
/** Icon of the plain text input when suggestions are off. */
|
|
22
|
+
icon?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Suggestions cost a credit each: 3–300 characters, 250 ms pause, at most 5, one request per field in flight. */
|
|
26
|
+
const MIN_TEXT = 3;
|
|
27
|
+
const MAX_TEXT = 300;
|
|
28
|
+
const LIMIT = 5;
|
|
29
|
+
const DEBOUNCE_MS = 250;
|
|
30
|
+
const OWN = "own";
|
|
31
|
+
|
|
32
|
+
// The list only opens in the browser, so the browser's zone is the reader's zone here.
|
|
33
|
+
const budgetText = (budget: Budget, locale: string, t: ReturnType<typeof geoUiMessages.resolve>["t"]) => {
|
|
34
|
+
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
35
|
+
const at = new Date(budget.resetsAt);
|
|
36
|
+
return budget.scope === "person"
|
|
37
|
+
? t.budgetPerson({ time: new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit", timeZone }).format(at) })
|
|
38
|
+
: t.budgetCloud({ date: new Intl.DateTimeFormat(locale, { day: "numeric", month: "long", timeZone }).format(at) });
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Address field with Cloud's budgeted suggestions; picking fills nothing by itself, the host owns its fields. */
|
|
42
|
+
export function AddressAutocomplete(props: AddressAutocompleteProps): JSX.Element {
|
|
43
|
+
const locale = useLocale();
|
|
44
|
+
const t = () => geoUiMessages.resolve([locale()]).t;
|
|
45
|
+
const initialBudget = (): Budget | null => {
|
|
46
|
+
const value = props.availability;
|
|
47
|
+
if (value?.person.exhausted) return { scope: "person", ...value.person };
|
|
48
|
+
if (value?.cloud.exhausted) return { scope: "cloud", ...value.cloud };
|
|
49
|
+
return null;
|
|
50
|
+
};
|
|
51
|
+
const [budget, setBudget] = createSignal<Budget | null>(initialBudget());
|
|
52
|
+
const [phone, setPhone] = createSignal(false);
|
|
53
|
+
// The budget line names a time in the reader's browser zone; it exists only once the list can open.
|
|
54
|
+
const [mounted, setMounted] = createSignal(false);
|
|
55
|
+
onMount(() => {
|
|
56
|
+
setMounted(true);
|
|
57
|
+
const query = window.matchMedia("(max-width: 639px)");
|
|
58
|
+
const update = () => setPhone(query.matches);
|
|
59
|
+
update();
|
|
60
|
+
query.addEventListener("change", update);
|
|
61
|
+
onCleanup(() => query.removeEventListener("change", update));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Options carry the id of the answer they came from, so a pick always resolves to the address it showed.
|
|
65
|
+
let suggestions = new Map<string, GeoAddress>();
|
|
66
|
+
let responses = 0;
|
|
67
|
+
// The keyboard hint belongs to a list of choices, not to an empty, failed, or budget answer.
|
|
68
|
+
const [choices, setChoices] = createSignal(false);
|
|
69
|
+
let inflight: Promise<unknown> | undefined;
|
|
70
|
+
const ownOption = (text: string): ComboboxOption => ({
|
|
71
|
+
id: OWN,
|
|
72
|
+
label: t().ownEntry({ text: text.trim() }),
|
|
73
|
+
description: t().ownEntryDetail,
|
|
74
|
+
icon: "ti ti-keyboard",
|
|
75
|
+
});
|
|
76
|
+
const withOwn = (text: string, options: ComboboxOption[]) => (phone() ? [ownOption(text), ...options] : [...options, ownOption(text)]);
|
|
77
|
+
|
|
78
|
+
const countryName = (code: string) => {
|
|
79
|
+
if (!code) return "";
|
|
80
|
+
try {
|
|
81
|
+
return new Intl.DisplayNames([locale()], { type: "region" }).of(code) ?? code;
|
|
82
|
+
} catch {
|
|
83
|
+
return code;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const fetchData = async (text: string, signal: AbortSignal): Promise<ComboboxOption[]> => {
|
|
88
|
+
if (text.trim().length > MAX_TEXT) return withOwn(text, []);
|
|
89
|
+
// A newer query waits for the open request instead of cancelling a call Core may already have paid for.
|
|
90
|
+
while (inflight) await inflight.catch(() => {});
|
|
91
|
+
signal.throwIfAborted();
|
|
92
|
+
const request = invokeCapabilityWithDataSchema(
|
|
93
|
+
{ kind: "query", appId: "core", capabilityId: geoContract.autocomplete.id, input: { text, limit: LIMIT } },
|
|
94
|
+
geoContract.autocomplete.data,
|
|
95
|
+
);
|
|
96
|
+
inflight = request;
|
|
97
|
+
const result = await request.finally(() => {
|
|
98
|
+
if (inflight === request) inflight = undefined;
|
|
99
|
+
});
|
|
100
|
+
// A superseded answer never touches the state behind the options a newer answer shows.
|
|
101
|
+
signal.throwIfAborted();
|
|
102
|
+
setChoices(result.ok && result.data.data.suggestions.length > 0);
|
|
103
|
+
if (!result.ok) {
|
|
104
|
+
const details = result.error.details;
|
|
105
|
+
if (
|
|
106
|
+
result.error.code === "GEO_BUDGET_EXHAUSTED" &&
|
|
107
|
+
(details?.scope === "person" || details?.scope === "cloud") &&
|
|
108
|
+
typeof details.resetsAt === "string"
|
|
109
|
+
) {
|
|
110
|
+
setBudget({ scope: details.scope, resetsAt: details.resetsAt });
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
throw new Error(t().unavailable);
|
|
114
|
+
}
|
|
115
|
+
responses += 1;
|
|
116
|
+
const answer = responses;
|
|
117
|
+
const found = result.data.data.suggestions.slice(0, LIMIT);
|
|
118
|
+
suggestions = new Map(found.map((address, index) => [`${answer}:${index}`, address]));
|
|
119
|
+
if (!found.length) return [];
|
|
120
|
+
return withOwn(
|
|
121
|
+
text,
|
|
122
|
+
found.map((address, index) => ({
|
|
123
|
+
id: `${answer}:${index}`,
|
|
124
|
+
label: address.line1 || address.formatted,
|
|
125
|
+
description: [[address.postalCode, address.city].filter(Boolean).join(" "), countryName(address.countryCode)]
|
|
126
|
+
.filter(Boolean)
|
|
127
|
+
.join(", "),
|
|
128
|
+
icon: "ti ti-map-pin",
|
|
129
|
+
})),
|
|
130
|
+
);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const budgetStatus = () => {
|
|
134
|
+
const value = budget();
|
|
135
|
+
if (!value) return undefined;
|
|
136
|
+
return (
|
|
137
|
+
<span class="flex items-start gap-2">
|
|
138
|
+
<i class="ti ti-hourglass" aria-hidden="true" />
|
|
139
|
+
<span>
|
|
140
|
+
<strong class="block font-medium">{budgetText(value, locale(), t())}</strong>
|
|
141
|
+
{t().budgetDetail}
|
|
142
|
+
</span>
|
|
143
|
+
</span>
|
|
144
|
+
);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<Show
|
|
149
|
+
when={props.availability?.configured !== false}
|
|
150
|
+
fallback={
|
|
151
|
+
<TextInput
|
|
152
|
+
id={props.id}
|
|
153
|
+
class={props.class}
|
|
154
|
+
label={props.label}
|
|
155
|
+
description={props.description}
|
|
156
|
+
error={props.error}
|
|
157
|
+
required={props.required}
|
|
158
|
+
disabled={props.disabled}
|
|
159
|
+
placeholder={props.placeholder}
|
|
160
|
+
icon={props.icon}
|
|
161
|
+
value={() => props.value}
|
|
162
|
+
onValueChange={props.onValueChange}
|
|
163
|
+
/>
|
|
164
|
+
}
|
|
165
|
+
>
|
|
166
|
+
<Combobox
|
|
167
|
+
id={props.id}
|
|
168
|
+
class={props.class}
|
|
169
|
+
label={props.label}
|
|
170
|
+
description={props.description}
|
|
171
|
+
error={props.error}
|
|
172
|
+
required={props.required}
|
|
173
|
+
disabled={props.disabled}
|
|
174
|
+
aria-label={props["aria-label"]}
|
|
175
|
+
aria-describedby={props["aria-describedby"]}
|
|
176
|
+
placeholder={props.placeholder}
|
|
177
|
+
freeText
|
|
178
|
+
autoHighlight={phone() ? 1 : 0}
|
|
179
|
+
minQueryLength={MIN_TEXT}
|
|
180
|
+
debounceMs={DEBOUNCE_MS}
|
|
181
|
+
query={() => props.value}
|
|
182
|
+
onQueryChange={props.onValueChange}
|
|
183
|
+
status={mounted() ? budgetStatus() : undefined}
|
|
184
|
+
empty={
|
|
185
|
+
<span>
|
|
186
|
+
<strong class="block font-medium">{t().noMatch}</strong>
|
|
187
|
+
{t().noMatchDetail}
|
|
188
|
+
</span>
|
|
189
|
+
}
|
|
190
|
+
footer={
|
|
191
|
+
<>
|
|
192
|
+
<span>{phone() || budget() || !choices() ? "" : t().keyboardHint}</span>
|
|
193
|
+
<span>
|
|
194
|
+
<a href="https://www.geoapify.com/" target="_blank" rel="noopener noreferrer">
|
|
195
|
+
Geoapify
|
|
196
|
+
</a>
|
|
197
|
+
{" · "}
|
|
198
|
+
<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">
|
|
199
|
+
OpenStreetMap
|
|
200
|
+
</a>
|
|
201
|
+
</span>
|
|
202
|
+
</>
|
|
203
|
+
}
|
|
204
|
+
fetchData={fetchData}
|
|
205
|
+
onSelect={(option) => {
|
|
206
|
+
if (option.id === OWN) return props.onOwnEntry?.(props.value);
|
|
207
|
+
const address = suggestions.get(option.id);
|
|
208
|
+
if (address) props.onPick(address);
|
|
209
|
+
}}
|
|
210
|
+
/>
|
|
211
|
+
</Show>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { i18n } from "@k2b/stdlib";
|
|
2
|
+
|
|
3
|
+
export const geoUiMessages = i18n.define({
|
|
4
|
+
baseLocale: "en",
|
|
5
|
+
messages: {
|
|
6
|
+
en: {
|
|
7
|
+
ownEntry: ({ text }: { text: string }) => `Use “${text}” as typed`,
|
|
8
|
+
ownEntryDetail: "You fill in street, postal code, and city yourself",
|
|
9
|
+
noMatch: "No matching address found.",
|
|
10
|
+
noMatchDetail: "Enter it yourself. It is saved exactly as you write it.",
|
|
11
|
+
unavailable: "Suggestions are unavailable right now. You can enter the address yourself.",
|
|
12
|
+
budgetPerson: ({ time }: { time: string }) => `Suggestions are used up for today, back at ${time}.`,
|
|
13
|
+
budgetCloud: ({ date }: { date: string }) => `Suggestions are used up until ${date}.`,
|
|
14
|
+
budgetDetail: "You can enter the address yourself.",
|
|
15
|
+
keyboardHint: "↑↓ choose · Enter accepts",
|
|
16
|
+
},
|
|
17
|
+
de: {
|
|
18
|
+
ownEntry: ({ text }) => `„${text}“ so übernehmen`,
|
|
19
|
+
ownEntryDetail: "Straße, PLZ und Ort trägst du selbst ein",
|
|
20
|
+
noMatch: "Keine passende Adresse gefunden.",
|
|
21
|
+
noMatchDetail: "Trag sie selbst ein. Sie wird so gespeichert, wie du sie schreibst.",
|
|
22
|
+
unavailable: "Vorschläge gerade nicht verfügbar. Du kannst die Adresse selbst eintragen.",
|
|
23
|
+
budgetPerson: ({ time }) => `Vorschläge heute aufgebraucht, ab ${time} Uhr wieder.`,
|
|
24
|
+
budgetCloud: ({ date }) => `Vorschläge bis ${date} aufgebraucht.`,
|
|
25
|
+
budgetDetail: "Du kannst die Adresse selbst eintragen.",
|
|
26
|
+
keyboardHint: "↑↓ wählen · Enter übernehmen",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { GeoPoint } from "../contracts/geo";
|
|
2
|
+
|
|
3
|
+
export type GeoNavigationPlatform = "android" | "ios" | "desktop";
|
|
4
|
+
|
|
5
|
+
/** Phones open their own maps app; everything else gets OpenStreetMap. */
|
|
6
|
+
export const geoNavigationPlatform = (userAgent: string): GeoNavigationPlatform =>
|
|
7
|
+
/android/i.test(userAgent) ? "android" : /iphone|ipad|ipod/i.test(userAgent) ? "ios" : "desktop";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A link into a navigation app. Cloud does no live navigation; the link leaves Cloud only on click and carries
|
|
11
|
+
* just the destination and an optional name. Without a point it navigates to the address text.
|
|
12
|
+
*/
|
|
13
|
+
export const geoNavigationHref = (
|
|
14
|
+
target: { point?: GeoPoint | null; text: string; name?: string },
|
|
15
|
+
platform: GeoNavigationPlatform,
|
|
16
|
+
): string => {
|
|
17
|
+
const point = target.point ? `${target.point.lat},${target.point.lon}` : null;
|
|
18
|
+
const text = encodeURIComponent(target.text);
|
|
19
|
+
if (platform === "android")
|
|
20
|
+
return point ? `geo:0,0?q=${point}${target.name ? `(${encodeURIComponent(target.name)})` : ""}` : `geo:0,0?q=${text}`;
|
|
21
|
+
if (platform === "ios") return `https://maps.apple.com/?daddr=${point ? encodeURIComponent(point) : text}`;
|
|
22
|
+
return point
|
|
23
|
+
? `https://www.openstreetmap.org/directions?route=%3B${encodeURIComponent(point)}`
|
|
24
|
+
: `https://www.openstreetmap.org/search?query=${text}`;
|
|
25
|
+
};
|
package/src/geo/ui.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
UserProfile,
|
|
15
15
|
UserProvider,
|
|
16
16
|
} from "../../contracts/shared";
|
|
17
|
+
import type { ServiceAccountRequestActor } from "../../server/middleware/auth";
|
|
17
18
|
import {
|
|
18
19
|
err,
|
|
19
20
|
fail,
|
|
@@ -41,6 +42,7 @@ import type { AvatarChange, AvatarState } from "./avatar";
|
|
|
41
42
|
import { listDuplicateEmails } from "./duplicate-emails";
|
|
42
43
|
import * as entities from "./entities";
|
|
43
44
|
import * as groups from "./groups";
|
|
45
|
+
import { mutateMachineGroupMember } from "./machine-group-management";
|
|
44
46
|
import type { AccountsNotificationSender } from "./notification-sender";
|
|
45
47
|
import { PosixError } from "./posix";
|
|
46
48
|
import { accountRequestsEnabled } from "./request-policy";
|
|
@@ -1018,13 +1020,15 @@ export const accountsAppService = {
|
|
|
1018
1020
|
return paginateItems(filtered, config.pagination);
|
|
1019
1021
|
},
|
|
1020
1022
|
add: async (config: {
|
|
1021
|
-
actor: AccountsActor;
|
|
1023
|
+
actor: AccountsActor | ServiceAccountRequestActor;
|
|
1024
|
+
requestId?: string;
|
|
1022
1025
|
id: string;
|
|
1023
1026
|
provider?: UserProvider;
|
|
1024
1027
|
userId?: string;
|
|
1025
1028
|
groupId?: string;
|
|
1026
1029
|
serviceAccountId?: string;
|
|
1027
1030
|
}) => {
|
|
1031
|
+
if ("kind" in config.actor) return mutateMachineGroupMember({ ...config, actor: config.actor, operation: "add" });
|
|
1028
1032
|
const group = await groups.get({ id: config.id });
|
|
1029
1033
|
if (!group) {
|
|
1030
1034
|
const result = fail(err.notFound("Group not found"));
|
|
@@ -1055,13 +1059,15 @@ export const accountsAppService = {
|
|
|
1055
1059
|
});
|
|
1056
1060
|
},
|
|
1057
1061
|
remove: async (config: {
|
|
1058
|
-
actor: AccountsActor;
|
|
1062
|
+
actor: AccountsActor | ServiceAccountRequestActor;
|
|
1063
|
+
requestId?: string;
|
|
1059
1064
|
id: string;
|
|
1060
1065
|
provider?: UserProvider;
|
|
1061
1066
|
userId?: string;
|
|
1062
1067
|
groupId?: string;
|
|
1063
1068
|
serviceAccountId?: string;
|
|
1064
1069
|
}) => {
|
|
1070
|
+
if ("kind" in config.actor) return mutateMachineGroupMember({ ...config, actor: config.actor, operation: "remove" });
|
|
1065
1071
|
const group = await groups.get({ id: config.id });
|
|
1066
1072
|
if (!group) {
|
|
1067
1073
|
const result = fail(err.notFound("Group not found"));
|
|
@@ -1110,7 +1116,14 @@ export const accountsAppService = {
|
|
|
1110
1116
|
});
|
|
1111
1117
|
return paginateItems(filtered, config.pagination);
|
|
1112
1118
|
},
|
|
1113
|
-
add: async (config: {
|
|
1119
|
+
add: async (config: {
|
|
1120
|
+
actor: AccountsActor;
|
|
1121
|
+
id: string;
|
|
1122
|
+
provider?: UserProvider;
|
|
1123
|
+
userId?: string;
|
|
1124
|
+
groupId?: string;
|
|
1125
|
+
serviceAccountId?: string;
|
|
1126
|
+
}) => {
|
|
1114
1127
|
const group = await groups.get({ id: config.id });
|
|
1115
1128
|
if (!group) {
|
|
1116
1129
|
const result = fail(err.notFound("Group not found"));
|
|
@@ -1133,17 +1146,25 @@ export const accountsAppService = {
|
|
|
1133
1146
|
provider: config.provider,
|
|
1134
1147
|
user: config.userId,
|
|
1135
1148
|
group: config.groupId,
|
|
1149
|
+
serviceAccount: config.serviceAccountId,
|
|
1136
1150
|
}),
|
|
1137
1151
|
);
|
|
1138
1152
|
return recordCompletedMutation({
|
|
1139
1153
|
action: "accounts.group.manager.add",
|
|
1140
1154
|
actor: auditActor(config.actor),
|
|
1141
1155
|
target: groupTarget(group),
|
|
1142
|
-
metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null },
|
|
1156
|
+
metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null, serviceAccountId: config.serviceAccountId ?? null },
|
|
1143
1157
|
result,
|
|
1144
1158
|
});
|
|
1145
1159
|
},
|
|
1146
|
-
remove: async (config: {
|
|
1160
|
+
remove: async (config: {
|
|
1161
|
+
actor: AccountsActor;
|
|
1162
|
+
id: string;
|
|
1163
|
+
provider?: UserProvider;
|
|
1164
|
+
userId?: string;
|
|
1165
|
+
groupId?: string;
|
|
1166
|
+
serviceAccountId?: string;
|
|
1167
|
+
}) => {
|
|
1147
1168
|
const group = await groups.get({ id: config.id });
|
|
1148
1169
|
if (!group) {
|
|
1149
1170
|
const result = fail(err.notFound("Group not found"));
|
|
@@ -1166,13 +1187,14 @@ export const accountsAppService = {
|
|
|
1166
1187
|
provider: config.provider,
|
|
1167
1188
|
user: config.userId,
|
|
1168
1189
|
group: config.groupId,
|
|
1190
|
+
serviceAccount: config.serviceAccountId,
|
|
1169
1191
|
}),
|
|
1170
1192
|
);
|
|
1171
1193
|
return recordCompletedMutation({
|
|
1172
1194
|
action: "accounts.group.manager.remove",
|
|
1173
1195
|
actor: auditActor(config.actor),
|
|
1174
1196
|
target: groupTarget(group),
|
|
1175
|
-
metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null },
|
|
1197
|
+
metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null, serviceAccountId: config.serviceAccountId ?? null },
|
|
1176
1198
|
result,
|
|
1177
1199
|
});
|
|
1178
1200
|
},
|
|
@@ -484,8 +484,8 @@ export const list = async (
|
|
|
484
484
|
AND ug.group_id = ANY(${toPgUuidArray(params.userMemberOfGroupIds ?? [])}::uuid[])
|
|
485
485
|
))`;
|
|
486
486
|
|
|
487
|
-
// Machine
|
|
488
|
-
const otherRelation = Boolean(params.
|
|
487
|
+
// Machine management is direct only; membership never implies management.
|
|
488
|
+
const otherRelation = Boolean(params.parentGroupId || params.managedByUserId);
|
|
489
489
|
const machineRelation = params.memberOfGroupId
|
|
490
490
|
? sql`sa.kind IN ('standalone', 'agent') AND EXISTS (
|
|
491
491
|
SELECT 1 FROM auth.groups target
|
|
@@ -497,11 +497,18 @@ export const list = async (
|
|
|
497
497
|
WHERE sg.service_account_id = sa.id AND sg.group_id = target.id)`
|
|
498
498
|
}
|
|
499
499
|
)`
|
|
500
|
-
:
|
|
500
|
+
: params.managerOfGroupId
|
|
501
|
+
? sql`sa.kind IN ('standalone', 'agent') AND EXISTS (
|
|
502
|
+
SELECT 1 FROM auth.group_manager_service_accounts gm JOIN auth.groups g ON g.id = gm.group_id
|
|
503
|
+
WHERE gm.service_account_id = sa.id AND gm.group_id = ${params.managerOfGroupId}::uuid AND g.provider = 'local'
|
|
504
|
+
)`
|
|
505
|
+
: sql`TRUE`;
|
|
501
506
|
const machineDirect = params.memberOfGroupId
|
|
502
507
|
? sql`EXISTS (SELECT 1 FROM auth.service_account_groups sg
|
|
503
508
|
WHERE sg.service_account_id = sa.id AND sg.group_id = ${params.memberOfGroupId}::uuid)`
|
|
504
|
-
:
|
|
509
|
+
: params.managerOfGroupId
|
|
510
|
+
? sql`TRUE`
|
|
511
|
+
: sql`NULL::boolean`;
|
|
505
512
|
const where = sql`
|
|
506
513
|
${visibilityCondition}
|
|
507
514
|
AND ${kindsCondition}
|
|
@@ -264,9 +264,12 @@ export const addManager = async (params: {
|
|
|
264
264
|
provider?: UserProvider;
|
|
265
265
|
user?: string;
|
|
266
266
|
group?: string;
|
|
267
|
+
serviceAccount?: string;
|
|
267
268
|
}): Promise<MutationResult<void>> => {
|
|
268
|
-
const provider =
|
|
269
|
-
if (provider === "local")
|
|
269
|
+
const provider = (await getGroup(params.id))?.provider;
|
|
270
|
+
if (provider === "local")
|
|
271
|
+
return localGroups.addManager({ id: params.id, user: params.user, group: params.group, serviceAccount: params.serviceAccount });
|
|
272
|
+
if (params.serviceAccount) return { ok: false, error: "Machine management requires a local group", status: 400 };
|
|
270
273
|
const serviceSession = await getServiceIpaSession();
|
|
271
274
|
if (!serviceSession.ok) return serviceSession;
|
|
272
275
|
return providers.ipa.groups.addManager({
|
|
@@ -282,9 +285,12 @@ export const removeManager = async (params: {
|
|
|
282
285
|
provider?: UserProvider;
|
|
283
286
|
user?: string;
|
|
284
287
|
group?: string;
|
|
288
|
+
serviceAccount?: string;
|
|
285
289
|
}): Promise<MutationResult<void>> => {
|
|
286
|
-
const provider =
|
|
287
|
-
if (provider === "local")
|
|
290
|
+
const provider = (await getGroup(params.id))?.provider;
|
|
291
|
+
if (provider === "local")
|
|
292
|
+
return localGroups.removeManager({ id: params.id, user: params.user, group: params.group, serviceAccount: params.serviceAccount });
|
|
293
|
+
if (params.serviceAccount) return { ok: false, error: "Machine management requires a local group", status: 400 };
|
|
288
294
|
const serviceSession = await getServiceIpaSession();
|
|
289
295
|
if (!serviceSession.ok) return serviceSession;
|
|
290
296
|
return providers.ipa.groups.removeManager({
|