@flowdular/sdk 0.3.0 → 0.3.2
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/.ai/platform-capabilities.md +4 -2
- package/.ai/policies/capabilities.yaml +30 -3
- package/.ai/references/catalog/migrations/0005_catalog_list_indexes.down.sql +3 -0
- package/.ai/references/catalog/migrations/0005_catalog_list_indexes.up.sql +11 -0
- package/.ai/references/catalog/module.json +11 -1
- package/.ai/references/catalog/package.json +2 -2
- package/.ai/references/catalog/spec/module.yaml +25 -4
- package/.ai/references/catalog/src/agent/tools.ts +19 -10
- package/.ai/references/catalog/src/api/endpoints.ts +150 -10
- package/.ai/references/catalog/src/api/list-cursor.ts +83 -0
- package/.ai/references/catalog/src/client/CatalogView.tsrx +505 -159
- package/.ai/references/catalog/src/client/api.ts +124 -36
- package/.ai/references/catalog/src/client/contribution.tsrx +5 -0
- package/.ai/references/catalog/src/client/state.ts +169 -3
- package/.ai/references/catalog/src/domain/lists.ts +7 -0
- package/.ai/references/catalog/src/domain/types.ts +20 -0
- package/.ai/references/catalog/src/platform.ts +20 -0
- package/.ai/references/catalog/src/services/catalog-service.ts +143 -8
- package/.ai/references/catalog/src/services/database-repository.ts +104 -17
- package/.ai/references/catalog/src/services/item-export.ts +81 -0
- package/.ai/references/catalog/src/services/migration.ts +27 -1
- package/.ai/references/catalog/src/services/repository.ts +31 -2
- package/.ai/references/catalog/tests/agent-tools.test.ts +6 -5
- package/.ai/references/catalog/tests/client-state.test.ts +124 -0
- package/.ai/references/catalog/tests/endpoints.test.ts +269 -0
- package/.ai/references/catalog/tests/export.test.ts +134 -0
- package/.ai/references/catalog/tests/idempotency.test.ts +15 -14
- package/.ai/references/catalog/tests/list.test.ts +217 -0
- package/.ai/references/catalog/tests/migrations.test.ts +58 -2
- package/.ai/references/catalog/tests/module.test.ts +2 -1
- package/.ai/references/catalog/tests/support/database.ts +14 -0
- package/.ai/references/catalog/translations/en.json +35 -4
- package/.ai/references/catalog/translations/pl.json +35 -4
- package/.ai/references/catalog.provenance.json +34 -26
- package/.ai/skills/cli-extension/SKILL.md +1 -1
- package/.ai/skills/deploy-operate/SKILL.md +7 -2
- package/README.md +1 -1
- package/assets/flowdular-banner.webp +0 -0
- package/modules/access/module.json +1 -1
- package/modules/access/spec/module.yaml +1 -1
- package/modules/agents/module.json +1 -1
- package/modules/agents/spec/module.yaml +1 -1
- package/modules/approvals/migrations/0005_approvals_grant_audit.down.sql +4 -0
- package/modules/approvals/migrations/0005_approvals_grant_audit.up.sql +20 -0
- package/modules/approvals/module.json +2 -2
- package/modules/approvals/package.json +1 -1
- package/modules/approvals/spec/module.yaml +12 -3
- package/modules/approvals/src/domain/capability.ts +12 -0
- package/modules/approvals/src/domain/grant.ts +69 -0
- package/modules/approvals/src/domain/types.ts +14 -0
- package/modules/approvals/src/index.ts +9 -0
- package/modules/approvals/src/platform.ts +8 -0
- package/modules/approvals/src/server/runtime.ts +4 -0
- package/modules/approvals/src/services/approvals-service.ts +80 -0
- package/modules/approvals/src/services/database-repository.ts +65 -6
- package/modules/approvals/src/services/migration.ts +34 -0
- package/modules/approvals/src/services/repository.ts +8 -0
- package/modules/audit/module.json +1 -1
- package/modules/audit/spec/module.yaml +1 -1
- package/modules/auth/module.json +2 -2
- package/modules/auth/package.json +1 -1
- package/modules/auth/spec/module.yaml +2 -2
- package/modules/auth/src/middleware/authentication.ts +5 -1
- package/modules/auth/src/services/auth-service.ts +26 -0
- package/modules/auth/src/services/database-repository.ts +9 -0
- package/modules/auth/src/services/repository.ts +2 -0
- package/modules/automations/module.json +1 -1
- package/modules/automations/spec/module.yaml +1 -1
- package/modules/connectors/migrations/0003_connectors_rotation_inventory.down.sql +2 -0
- package/modules/connectors/migrations/0003_connectors_rotation_inventory.up.sql +19 -0
- package/modules/connectors/module.json +8 -4
- package/modules/connectors/package.json +2 -1
- package/modules/connectors/spec/module.yaml +3 -2
- package/modules/connectors/src/cli/commands.json +17 -0
- package/modules/connectors/src/cli/index.ts +126 -0
- package/modules/connectors/src/services/credential-rotation.ts +221 -0
- package/modules/connectors/src/services/credential-vault.ts +6 -0
- package/modules/connectors/src/services/migration.ts +36 -0
- package/modules/directory/module.json +1 -1
- package/modules/directory/spec/module.yaml +1 -1
- package/modules/documents/migrations/0003_documents_rotation_inventory.down.sql +2 -0
- package/modules/documents/migrations/0003_documents_rotation_inventory.up.sql +18 -0
- package/modules/documents/module.json +8 -4
- package/modules/documents/package.json +2 -1
- package/modules/documents/spec/module.yaml +3 -2
- package/modules/documents/src/cli/commands.json +17 -0
- package/modules/documents/src/cli/index.ts +145 -0
- package/modules/documents/src/services/database-repository.ts +15 -4
- package/modules/documents/src/services/documents-service.ts +13 -9
- package/modules/documents/src/services/migration.ts +35 -0
- package/modules/documents/src/services/repository.ts +12 -2
- package/modules/documents/src/services/storage-rotation.ts +157 -0
- package/modules/exports/migrations/0003_exports_rotation_inventory.down.sql +1 -0
- package/modules/exports/migrations/0003_exports_rotation_inventory.up.sql +9 -0
- package/modules/exports/module.json +8 -4
- package/modules/exports/package.json +2 -1
- package/modules/exports/spec/module.yaml +3 -2
- package/modules/exports/src/cli/commands.json +17 -0
- package/modules/exports/src/cli/index.ts +145 -0
- package/modules/exports/src/server/index.ts +0 -1
- package/modules/exports/src/services/data-classes.ts +16 -13
- package/modules/exports/src/services/database-repository.ts +30 -32
- package/modules/exports/src/services/migration.ts +27 -0
- package/modules/exports/src/services/repository.ts +9 -10
- package/modules/exports/src/services/storage-rotation.ts +138 -0
- package/modules/import/module.json +1 -1
- package/modules/import/spec/module.yaml +1 -1
- package/modules/metering/module.json +1 -1
- package/modules/metering/spec/module.yaml +1 -1
- package/modules/notifications/module.json +1 -1
- package/modules/notifications/spec/module.yaml +1 -1
- package/modules/reports/module.json +1 -1
- package/modules/reports/spec/module.yaml +1 -1
- package/modules/sandbox/module.json +1 -1
- package/modules/sandbox/spec/module.yaml +1 -1
- package/modules/search/module.json +1 -1
- package/modules/search/spec/module.yaml +1 -1
- package/modules/system/migrations/0001_system_module_activations.down.sql +2 -0
- package/modules/system/migrations/0001_system_module_activations.up.sql +17 -0
- package/modules/system/module.json +3 -2
- package/modules/system/package.json +2 -2
- package/modules/system/spec/module.yaml +98 -1
- package/modules/system/src/client/ModulesView.tsrx +101 -8
- package/modules/system/src/client/api.ts +52 -11
- package/modules/system/src/client/module-columns.tsrx +32 -6
- package/modules/system/src/client/state.ts +26 -0
- package/modules/system/src/domain/modules.ts +93 -0
- package/modules/system/src/index.ts +13 -0
- package/modules/system/src/platform.ts +41 -1
- package/modules/system/src/server/capability.ts +6 -0
- package/modules/system/src/server/endpoints.ts +125 -5
- package/modules/system/src/server/index.ts +26 -0
- package/modules/system/src/server/module-catalog.ts +19 -0
- package/modules/system/src/server/runtime.ts +102 -0
- package/modules/system/src/services/database-repository.ts +91 -0
- package/modules/system/src/services/migration.ts +39 -0
- package/modules/system/src/services/module-activation-service.ts +245 -0
- package/modules/system/src/services/repository.ts +17 -0
- package/modules/system/translations/en.json +17 -1
- package/modules/system/translations/pl.json +17 -1
- package/modules/users/module.json +2 -2
- package/modules/users/package.json +1 -1
- package/modules/users/spec/module.yaml +6 -9
- package/modules/users/src/client/UsersView.tsrx +27 -13
- package/modules/users/src/client/member-columns.tsrx +13 -4
- package/modules/users/src/client/state.ts +16 -0
- package/modules/users/src/services/users-service.ts +3 -0
- package/modules/users/translations/en.json +2 -1
- package/modules/users/translations/pl.json +2 -1
- package/modules/workflows/module.json +2 -2
- package/modules/workflows/package.json +1 -1
- package/modules/workflows/spec/module.yaml +2 -2
- package/modules/workflows/src/services/database-repository.ts +93 -53
- package/package.json +1 -1
- package/packages/client/package.json +1 -0
- package/packages/client/src/ApplicationShell.tsrx +46 -5
- package/packages/client/src/index.ts +5 -0
- package/packages/client/src/shell/modules.ts +49 -0
- package/packages/client/src/shell/types.ts +3 -0
- package/packages/contracts/src/index.ts +11 -1
- package/packages/database/src/backup.ts +1 -0
- package/packages/database/src/migrations.ts +7 -0
- package/packages/harness/src/runtime.ts +169 -10
- package/packages/harness/src/tool-adapters.ts +6 -13
- package/packages/kernel/src/approval-grant.ts +310 -0
- package/packages/kernel/src/index.ts +20 -0
- package/packages/server/src/endpoint.ts +17 -1
- package/packages/server/src/index.ts +10 -0
- package/packages/server/src/module-activation.ts +69 -0
- package/packages/storage/src/envelope.ts +70 -21
- package/packages/storage/src/index.ts +7 -1
- package/packages/storage/src/port.ts +12 -1
- package/packages/storage/src/reseal.ts +128 -0
- package/assets/flowdular-banner.png +0 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import type { AuthActor } from '@flowdular/sdk/modules/auth';
|
|
2
|
+
import {
|
|
3
|
+
activeModuleIds,
|
|
4
|
+
dependentsOf,
|
|
5
|
+
isRequiredModule,
|
|
6
|
+
type ComposedModule,
|
|
7
|
+
type ModuleActivationChange,
|
|
8
|
+
type ModuleActivationEntry,
|
|
9
|
+
} from '../domain/modules.ts';
|
|
10
|
+
import type { ModuleActivationRepository } from './repository.ts';
|
|
11
|
+
|
|
12
|
+
export type ModuleActivationErrorCode =
|
|
13
|
+
| 'MODULE_UNKNOWN'
|
|
14
|
+
| 'MODULE_REQUIRED'
|
|
15
|
+
| 'MODULE_HAS_ACTIVE_DEPENDENTS'
|
|
16
|
+
| 'MODULE_DEPENDENCY_INACTIVE';
|
|
17
|
+
|
|
18
|
+
export class ModuleActivationError extends Error {
|
|
19
|
+
readonly code: ModuleActivationErrorCode;
|
|
20
|
+
readonly status: number;
|
|
21
|
+
/** The modules a refusal names: active dependents or inactive dependencies. */
|
|
22
|
+
readonly modules: readonly string[];
|
|
23
|
+
|
|
24
|
+
constructor(
|
|
25
|
+
code: ModuleActivationErrorCode,
|
|
26
|
+
message: string,
|
|
27
|
+
status: number,
|
|
28
|
+
modules: readonly string[] = [],
|
|
29
|
+
) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = 'ModuleActivationError';
|
|
32
|
+
this.code = code;
|
|
33
|
+
this.status = status;
|
|
34
|
+
this.modules = modules;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ModuleActivationServiceOptions {
|
|
39
|
+
readonly repository: ModuleActivationRepository;
|
|
40
|
+
/** The modules the application composes; read once per call, never cached here. */
|
|
41
|
+
readonly modules: () => readonly ComposedModule[];
|
|
42
|
+
readonly audit?: (
|
|
43
|
+
actor: AuthActor,
|
|
44
|
+
change: ModuleActivationChange,
|
|
45
|
+
) => Promise<void>;
|
|
46
|
+
readonly now?: () => number;
|
|
47
|
+
/** How long a workspace snapshot answers before it is read again. */
|
|
48
|
+
readonly snapshotTtlMs?: number;
|
|
49
|
+
readonly maxTenants?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface Snapshot {
|
|
53
|
+
readonly inactive: ReadonlySet<string>;
|
|
54
|
+
readonly expiresAt: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const DEFAULT_SNAPSHOT_TTL_MS = 30_000;
|
|
58
|
+
export const DEFAULT_SNAPSHOT_TENANTS = 1024;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Per-workspace activation of the modules the application composes. Every
|
|
62
|
+
* composed module is active until an owner deactivates it; a required module
|
|
63
|
+
* and a module another active module depends on stay active. Reads come from
|
|
64
|
+
* a memoised per-tenant snapshot so a request costs no query of its own.
|
|
65
|
+
*/
|
|
66
|
+
export class ModuleActivationService {
|
|
67
|
+
readonly #repository: ModuleActivationRepository;
|
|
68
|
+
readonly #modules: () => readonly ComposedModule[];
|
|
69
|
+
readonly #audit: ModuleActivationServiceOptions['audit'];
|
|
70
|
+
readonly #now: () => number;
|
|
71
|
+
readonly #ttlMs: number;
|
|
72
|
+
readonly #maxTenants: number;
|
|
73
|
+
readonly #snapshots = new Map<string, Promise<Snapshot>>();
|
|
74
|
+
|
|
75
|
+
constructor(options: ModuleActivationServiceOptions) {
|
|
76
|
+
this.#repository = options.repository;
|
|
77
|
+
this.#modules = options.modules;
|
|
78
|
+
this.#audit = options.audit;
|
|
79
|
+
this.#now = options.now ?? Date.now;
|
|
80
|
+
this.#ttlMs = options.snapshotTtlMs ?? DEFAULT_SNAPSHOT_TTL_MS;
|
|
81
|
+
this.#maxTenants = options.maxTenants ?? DEFAULT_SNAPSHOT_TENANTS;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Composed optional module ids an owner deactivated for the workspace. */
|
|
85
|
+
async inactive(tenantId: string): Promise<ReadonlySet<string>> {
|
|
86
|
+
const cached = this.#snapshots.get(tenantId);
|
|
87
|
+
if (cached) {
|
|
88
|
+
const snapshot = await cached;
|
|
89
|
+
if (snapshot.expiresAt > this.#now()) return snapshot.inactive;
|
|
90
|
+
}
|
|
91
|
+
const loading = this.#load(tenantId);
|
|
92
|
+
this.#snapshots.delete(tenantId);
|
|
93
|
+
this.#snapshots.set(tenantId, loading);
|
|
94
|
+
while (this.#snapshots.size > this.#maxTenants) {
|
|
95
|
+
const oldest = this.#snapshots.keys().next().value;
|
|
96
|
+
if (oldest === undefined) break;
|
|
97
|
+
this.#snapshots.delete(oldest);
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
return (await loading).inactive;
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (this.#snapshots.get(tenantId) === loading) {
|
|
103
|
+
this.#snapshots.delete(tenantId);
|
|
104
|
+
}
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
invalidate(tenantId: string): void {
|
|
110
|
+
this.#snapshots.delete(tenantId);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async isActive(tenantId: string, moduleId: string): Promise<boolean> {
|
|
114
|
+
if (isRequiredModule(moduleId)) return true;
|
|
115
|
+
return !(await this.inactive(tenantId)).has(moduleId);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async activeIds(tenantId: string): Promise<readonly string[]> {
|
|
119
|
+
return activeModuleIds(this.#modules(), await this.inactive(tenantId));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async list(tenantId: string): Promise<readonly ModuleActivationEntry[]> {
|
|
123
|
+
const modules = this.#modules();
|
|
124
|
+
const inactive = await this.inactive(tenantId);
|
|
125
|
+
return modules.map((module) => this.#entry(modules, module, inactive));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async activate(
|
|
129
|
+
actor: AuthActor,
|
|
130
|
+
moduleId: string,
|
|
131
|
+
): Promise<ModuleActivationEntry> {
|
|
132
|
+
const modules = this.#modules();
|
|
133
|
+
const module = this.#composed(modules, moduleId);
|
|
134
|
+
const inactive = await this.inactive(actor.tenantId);
|
|
135
|
+
if (!inactive.has(moduleId)) {
|
|
136
|
+
return this.#entry(modules, module, inactive);
|
|
137
|
+
}
|
|
138
|
+
const missing = module.dependencies.filter(
|
|
139
|
+
(dependency) => !isRequiredModule(dependency) && inactive.has(dependency),
|
|
140
|
+
);
|
|
141
|
+
if (missing.length > 0) {
|
|
142
|
+
throw new ModuleActivationError(
|
|
143
|
+
'MODULE_DEPENDENCY_INACTIVE',
|
|
144
|
+
`${moduleId} needs ${missing.join(', ')} active first.`,
|
|
145
|
+
409,
|
|
146
|
+
missing,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
return this.#write(actor, modules, module, true);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async deactivate(
|
|
153
|
+
actor: AuthActor,
|
|
154
|
+
moduleId: string,
|
|
155
|
+
): Promise<ModuleActivationEntry> {
|
|
156
|
+
const modules = this.#modules();
|
|
157
|
+
const module = this.#composed(modules, moduleId);
|
|
158
|
+
if (isRequiredModule(moduleId)) {
|
|
159
|
+
throw new ModuleActivationError(
|
|
160
|
+
'MODULE_REQUIRED',
|
|
161
|
+
`${moduleId} is required by every workspace and cannot be deactivated.`,
|
|
162
|
+
409,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
const inactive = await this.inactive(actor.tenantId);
|
|
166
|
+
if (inactive.has(moduleId)) {
|
|
167
|
+
return this.#entry(modules, module, inactive);
|
|
168
|
+
}
|
|
169
|
+
const dependents = dependentsOf(modules, moduleId).filter(
|
|
170
|
+
(dependent) => isRequiredModule(dependent) || !inactive.has(dependent),
|
|
171
|
+
);
|
|
172
|
+
if (dependents.length > 0) {
|
|
173
|
+
throw new ModuleActivationError(
|
|
174
|
+
'MODULE_HAS_ACTIVE_DEPENDENTS',
|
|
175
|
+
`${moduleId} is a dependency of ${dependents.join(', ')}; deactivate those first.`,
|
|
176
|
+
409,
|
|
177
|
+
dependents,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
return this.#write(actor, modules, module, false);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
#composed(
|
|
184
|
+
modules: readonly ComposedModule[],
|
|
185
|
+
moduleId: string,
|
|
186
|
+
): ComposedModule {
|
|
187
|
+
const module = modules.find((candidate) => candidate.id === moduleId);
|
|
188
|
+
if (!module) {
|
|
189
|
+
throw new ModuleActivationError(
|
|
190
|
+
'MODULE_UNKNOWN',
|
|
191
|
+
`${moduleId} is not composed in this application.`,
|
|
192
|
+
404,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
return module;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
#entry(
|
|
199
|
+
modules: readonly ComposedModule[],
|
|
200
|
+
module: ComposedModule,
|
|
201
|
+
inactive: ReadonlySet<string>,
|
|
202
|
+
): ModuleActivationEntry {
|
|
203
|
+
const optional = !isRequiredModule(module.id);
|
|
204
|
+
return {
|
|
205
|
+
id: module.id,
|
|
206
|
+
version: module.version,
|
|
207
|
+
active: !optional || !inactive.has(module.id),
|
|
208
|
+
optional,
|
|
209
|
+
dependents: dependentsOf(modules, module.id),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async #write(
|
|
214
|
+
actor: AuthActor,
|
|
215
|
+
modules: readonly ComposedModule[],
|
|
216
|
+
module: ComposedModule,
|
|
217
|
+
active: boolean,
|
|
218
|
+
): Promise<ModuleActivationEntry> {
|
|
219
|
+
await this.#repository.set({
|
|
220
|
+
tenantId: actor.tenantId,
|
|
221
|
+
moduleId: module.id,
|
|
222
|
+
active,
|
|
223
|
+
changedBy: actor.accountId,
|
|
224
|
+
changedAt: this.#now(),
|
|
225
|
+
});
|
|
226
|
+
this.invalidate(actor.tenantId);
|
|
227
|
+
await this.#audit?.(actor, { moduleId: module.id, active });
|
|
228
|
+
return this.#entry(modules, module, await this.inactive(actor.tenantId));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async #load(tenantId: string): Promise<Snapshot> {
|
|
232
|
+
const composed = new Set(this.#modules().map((module) => module.id));
|
|
233
|
+
const inactive = new Set<string>();
|
|
234
|
+
for (const record of await this.#repository.list(tenantId)) {
|
|
235
|
+
if (
|
|
236
|
+
!record.active &&
|
|
237
|
+
composed.has(record.moduleId) &&
|
|
238
|
+
!isRequiredModule(record.moduleId)
|
|
239
|
+
) {
|
|
240
|
+
inactive.add(record.moduleId);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return { inactive, expiresAt: this.#now() + this.#ttlMs };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface ModuleActivationRecord {
|
|
2
|
+
readonly tenantId: string;
|
|
3
|
+
readonly moduleId: string;
|
|
4
|
+
readonly active: boolean;
|
|
5
|
+
readonly changedBy: string;
|
|
6
|
+
readonly changedAt: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The per-workspace activation overrides, and nothing else: which modules are
|
|
11
|
+
* composed comes from the workspace manifests, and a module without a row is
|
|
12
|
+
* active.
|
|
13
|
+
*/
|
|
14
|
+
export interface ModuleActivationRepository {
|
|
15
|
+
list(tenantId: string): Promise<readonly ModuleActivationRecord[]>;
|
|
16
|
+
set(record: ModuleActivationRecord): Promise<void>;
|
|
17
|
+
}
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
"action.clear": "Clear",
|
|
13
13
|
"action.save": "Save",
|
|
14
14
|
"action.reset": "Reset",
|
|
15
|
+
"action.activate": "Activate",
|
|
16
|
+
"action.deactivate": "Deactivate",
|
|
17
|
+
"action.cancel": "Cancel",
|
|
15
18
|
"denied.title": "Access denied",
|
|
16
19
|
"denied.hint": "Reading the module catalog needs the system.modules.read scope.",
|
|
17
20
|
"tab.label": "Administration views",
|
|
@@ -33,12 +36,13 @@
|
|
|
33
36
|
"table.title": "Module catalog",
|
|
34
37
|
"table.caption": "Modules",
|
|
35
38
|
"table.actions": "Module actions",
|
|
36
|
-
"table.count": "{enabled} enabled · {total} in workspace",
|
|
39
|
+
"table.count": "{active} active · {enabled} enabled · {total} in workspace",
|
|
37
40
|
"table.module": "Module",
|
|
38
41
|
"table.version": "Version",
|
|
39
42
|
"table.specVersion": "spec {version}",
|
|
40
43
|
"table.platform": "Platform",
|
|
41
44
|
"table.permissions": "Permissions",
|
|
45
|
+
"table.dependents": "Dependents",
|
|
42
46
|
"table.status": "Status",
|
|
43
47
|
"table.loading": "Loading modules…",
|
|
44
48
|
"table.emptyTitle": "No modules found",
|
|
@@ -48,6 +52,9 @@
|
|
|
48
52
|
"table.cliNote": "Change the enabled set with the CLI:",
|
|
49
53
|
"status.enabled": "Enabled",
|
|
50
54
|
"status.available": "Available",
|
|
55
|
+
"status.active": "Active",
|
|
56
|
+
"status.inactive": "Inactive",
|
|
57
|
+
"status.required": "Required",
|
|
51
58
|
"drawer.module": "Module",
|
|
52
59
|
"detail.overview": "Overview",
|
|
53
60
|
"detail.specVersion": "Spec version",
|
|
@@ -77,5 +84,14 @@
|
|
|
77
84
|
"settings.mfaKeyRequired": "Required multi-factor authentication needs a deployment MFA encryption key.",
|
|
78
85
|
"settings.timeZone.label": "Workspace time zone",
|
|
79
86
|
"settings.timeZone.description": "IANA zone name such as Europe/Warsaw. Modules that show or schedule local times read it; the default is UTC.",
|
|
87
|
+
"activation.notComposed": "This module is not enabled in the application; enable it with the CLI first.",
|
|
88
|
+
"activation.deniedManage": "Changing module activation needs the system.settings.manage scope.",
|
|
89
|
+
"activation.required": "A required module is active in every workspace.",
|
|
90
|
+
"activation.dependents": "Active modules depend on it: {modules}.",
|
|
91
|
+
"activation.confirmActivateTitle": "Activate {module}?",
|
|
92
|
+
"activation.confirmActivate": "Its screens, API and agent tools become available to this workspace again.",
|
|
93
|
+
"activation.confirmDeactivateTitle": "Deactivate {module}?",
|
|
94
|
+
"activation.confirmDeactivate": "Its screens disappear from this workspace and its API answers 403 MODULE_INACTIVE until it is activated again. Stored data is kept.",
|
|
95
|
+
"error.activation": "Could not change the module activation.",
|
|
80
96
|
"error.catalog": "Could not load the module catalog."
|
|
81
97
|
}
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
"action.clear": "Wyczyść",
|
|
13
13
|
"action.save": "Zapisz",
|
|
14
14
|
"action.reset": "Przywróć domyślne",
|
|
15
|
+
"action.activate": "Aktywuj",
|
|
16
|
+
"action.deactivate": "Dezaktywuj",
|
|
17
|
+
"action.cancel": "Anuluj",
|
|
15
18
|
"denied.title": "Brak dostępu",
|
|
16
19
|
"denied.hint": "Odczyt katalogu modułów wymaga uprawnienia system.modules.read.",
|
|
17
20
|
"tab.label": "Widoki administracyjne",
|
|
@@ -33,12 +36,13 @@
|
|
|
33
36
|
"table.title": "Katalog modułów",
|
|
34
37
|
"table.caption": "Moduły",
|
|
35
38
|
"table.actions": "Akcje modułu",
|
|
36
|
-
"table.count": "
|
|
39
|
+
"table.count": "{active} aktywnych · {enabled} włączonych · {total} w przestrzeni",
|
|
37
40
|
"table.module": "Moduł",
|
|
38
41
|
"table.version": "Wersja",
|
|
39
42
|
"table.specVersion": "spec {version}",
|
|
40
43
|
"table.platform": "Platforma",
|
|
41
44
|
"table.permissions": "Uprawnienia",
|
|
45
|
+
"table.dependents": "Zależne",
|
|
42
46
|
"table.status": "Status",
|
|
43
47
|
"table.loading": "Wczytywanie modułów…",
|
|
44
48
|
"table.emptyTitle": "Nie znaleziono modułów",
|
|
@@ -48,6 +52,9 @@
|
|
|
48
52
|
"table.cliNote": "Zmieniaj zestaw włączonych modułów przez CLI:",
|
|
49
53
|
"status.enabled": "Włączony",
|
|
50
54
|
"status.available": "Dostępny",
|
|
55
|
+
"status.active": "Aktywny",
|
|
56
|
+
"status.inactive": "Nieaktywny",
|
|
57
|
+
"status.required": "Wymagany",
|
|
51
58
|
"drawer.module": "Moduł",
|
|
52
59
|
"detail.overview": "Przegląd",
|
|
53
60
|
"detail.specVersion": "Wersja specyfikacji",
|
|
@@ -77,5 +84,14 @@
|
|
|
77
84
|
"settings.mfaKeyRequired": "Wymagane uwierzytelnianie dwuskładnikowe potrzebuje klucza szyfrującego MFA skonfigurowanego we wdrożeniu.",
|
|
78
85
|
"settings.timeZone.label": "Strefa czasowa obszaru roboczego",
|
|
79
86
|
"settings.timeZone.description": "Nazwa strefy IANA, na przykład Europe/Warsaw. Moduły, które pokazują lub planują czas lokalny, odczytują tę wartość; domyślnie UTC.",
|
|
87
|
+
"activation.notComposed": "Ten moduł nie jest włączony w aplikacji; najpierw włącz go przez CLI.",
|
|
88
|
+
"activation.deniedManage": "Zmiana aktywacji modułu wymaga zakresu system.settings.manage.",
|
|
89
|
+
"activation.required": "Moduł wymagany jest aktywny w każdej przestrzeni.",
|
|
90
|
+
"activation.dependents": "Zależą od niego aktywne moduły: {modules}.",
|
|
91
|
+
"activation.confirmActivateTitle": "Aktywować {module}?",
|
|
92
|
+
"activation.confirmActivate": "Jego ekrany, API i narzędzia agentów znów będą dostępne w tej przestrzeni.",
|
|
93
|
+
"activation.confirmDeactivateTitle": "Dezaktywować {module}?",
|
|
94
|
+
"activation.confirmDeactivate": "Jego ekrany znikną z tej przestrzeni, a API będzie odpowiadać 403 MODULE_INACTIVE do ponownej aktywacji. Zapisane dane zostają.",
|
|
95
|
+
"error.activation": "Nie udało się zmienić aktywacji modułu.",
|
|
80
96
|
"error.catalog": "Nie udało się wczytać katalogu modułów."
|
|
81
97
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"schemaVersion": 1,
|
|
4
4
|
"id": "users.core",
|
|
5
5
|
"package": "@flowdular/module-users",
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.11.0",
|
|
7
7
|
"profile": "full",
|
|
8
8
|
"capabilities": ["api", "client", "translations"],
|
|
9
9
|
"platform": {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"dependencies": [
|
|
14
14
|
{
|
|
15
15
|
"id": "system.core",
|
|
16
|
-
"range": "^0.
|
|
16
|
+
"range": "^0.8.0"
|
|
17
17
|
},
|
|
18
18
|
{
|
|
19
19
|
"id": "auth.core",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
schemaVersion: 2
|
|
2
2
|
id: users.core
|
|
3
|
-
specVersion: 0.
|
|
3
|
+
specVersion: 0.11.0
|
|
4
4
|
status: approved
|
|
5
5
|
name: User Management Core
|
|
6
6
|
description: Provides the tenant-scoped user directory and member administration (creation, display name, role assignment, per-member scopes, account status, password reset, session revocation, removal) through the public auth administration port.
|
|
@@ -11,7 +11,7 @@ capabilities:
|
|
|
11
11
|
- translations
|
|
12
12
|
dependencies:
|
|
13
13
|
- id: system.core
|
|
14
|
-
range: ^0.
|
|
14
|
+
range: ^0.8.0
|
|
15
15
|
- id: auth.core
|
|
16
16
|
range: ^0.13.0
|
|
17
17
|
- id: search.core
|
|
@@ -56,7 +56,7 @@ dataOwnership:
|
|
|
56
56
|
entities:
|
|
57
57
|
- id: member
|
|
58
58
|
name: Workspace member
|
|
59
|
-
description: One membership of the active workspace as the auth administration port answers it; auth.core owns the row and users.core reads and writes it only through the port, never through a table of its own.
|
|
59
|
+
description: One membership of the active workspace as the auth administration port answers it; auth.core owns the row and users.core reads and writes it only through the port, never through a table of its own. The joined-at instant the Members screen and the export show is the createdAt of the membership row, which auth.core owns and answers with every member.
|
|
60
60
|
fields:
|
|
61
61
|
- id: accountId
|
|
62
62
|
type: string
|
|
@@ -146,6 +146,7 @@ screens:
|
|
|
146
146
|
- status
|
|
147
147
|
- passwordChangeRequired
|
|
148
148
|
- scopes
|
|
149
|
+
- createdAt
|
|
149
150
|
filters:
|
|
150
151
|
- displayName
|
|
151
152
|
- email
|
|
@@ -200,7 +201,7 @@ actions:
|
|
|
200
201
|
kind: custom
|
|
201
202
|
risk: read
|
|
202
203
|
idempotent: true
|
|
203
|
-
description: Answer what the Members screen needs beside the page, namely the workspace roles, the grantable scopes, the acting principal's account id and role, the shared password minimum length and the
|
|
204
|
+
description: Answer what the Members screen needs beside the page, namely the workspace roles, the grantable scopes, the acting principal's account id and role, the shared password minimum length, the member count and the owner count, both counted in the database.
|
|
204
205
|
- id: create-member
|
|
205
206
|
entity: member
|
|
206
207
|
permission: users.members.manage
|
|
@@ -287,15 +288,11 @@ decisions:
|
|
|
287
288
|
question: How are the two bulk actions of the Members screen declared?
|
|
288
289
|
answer: As the actions set-status-many and assign-role-many, each behind users.members.manage and each applied one id at a time through the single-row path, with the USERS-STATUS-MANY and USERS-ROLE-MANY scenarios as their evidence.
|
|
289
290
|
decidedBy: default
|
|
290
|
-
- id: D-USERS-JOINED-AT
|
|
291
|
-
question: Why does the member entity not declare the joined-at instant the export carries?
|
|
292
|
-
answer: The instant is auth.core's createdAt column, and createdAt is a field id every tenant table owns, so the specification cannot declare it; the export list still carries it as documented in the invariants.
|
|
293
|
-
decidedBy: default
|
|
294
291
|
acceptanceScenarios:
|
|
295
292
|
- id: USERS-LIST
|
|
296
293
|
given: An authenticated principal has users.members.read in an active tenant.
|
|
297
294
|
when: The principal requests the user directory.
|
|
298
|
-
then: Only memberships from the active tenant are returned without credential or session material, one page at a time, and the roles, grantable scopes, acting principal and
|
|
295
|
+
then: Only memberships from the active tenant are returned without credential or session material, one page at a time, and the roles, grantable scopes, acting principal, member count and owner count the screen needs come from the separate context read.
|
|
299
296
|
- id: USERS-PAGE
|
|
300
297
|
given: A workspace holding more members than one page carries, and another workspace with members of its own.
|
|
301
298
|
when: A screen lists the members with a page limit of at most 200, follows the cursor it receives to the end sorted by display name or by e-mail in either direction, narrows by a name or address prefix and by membership status, and presents a cursor this server did not sign, one of the other workspace, or one issued for another sort or filter.
|
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
loadedListing,
|
|
50
50
|
memberSort,
|
|
51
51
|
membershipStatusMessage,
|
|
52
|
+
ownerCountAfter,
|
|
52
53
|
pageCursor,
|
|
53
54
|
replaceMember,
|
|
54
55
|
resetPageCursors,
|
|
@@ -88,6 +89,7 @@ export function UsersView(props: UsersViewProps) @{
|
|
|
88
89
|
const [actor] = useValue(usersState.state.actor);
|
|
89
90
|
const [passwordMinLength] = useValue(usersState.state.passwordMinLength);
|
|
90
91
|
const [memberCount, setMemberCount] = useValue(usersState.state.memberCount);
|
|
92
|
+
const [ownerCount, setOwnerCount] = useValue(usersState.state.ownerCount);
|
|
91
93
|
const actorIsOwner = actor?.role === 'owner';
|
|
92
94
|
const [status, setStatus] = useValue(usersState.state.status);
|
|
93
95
|
const [error, setError] = useValue(usersState.state.error);
|
|
@@ -217,6 +219,7 @@ export function UsersView(props: UsersViewProps) @{
|
|
|
217
219
|
context.passwordMinLength,
|
|
218
220
|
);
|
|
219
221
|
transaction.set(usersState.state.memberCount, context.memberCount);
|
|
222
|
+
transaction.set(usersState.state.ownerCount, context.ownerCount);
|
|
220
223
|
transaction.set(usersState.state.exportStarted, false);
|
|
221
224
|
}, 'users/context-loaded');
|
|
222
225
|
return true;
|
|
@@ -290,13 +293,17 @@ export function UsersView(props: UsersViewProps) @{
|
|
|
290
293
|
try {
|
|
291
294
|
const member = await run();
|
|
292
295
|
if (member) {
|
|
293
|
-
usersState.store.act(
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
296
|
+
usersState.store.act((transaction) => {
|
|
297
|
+
transaction.set(usersState.state.users, replaceMember(users, member));
|
|
298
|
+
transaction.set(
|
|
299
|
+
usersState.state.ownerCount,
|
|
300
|
+
ownerCountAfter(
|
|
301
|
+
ownerCount,
|
|
302
|
+
users.find((user) => user.accountId === member.accountId),
|
|
303
|
+
member,
|
|
304
|
+
),
|
|
305
|
+
);
|
|
306
|
+
}, 'users/member-updated');
|
|
300
307
|
}
|
|
301
308
|
setNotice(done);
|
|
302
309
|
setStatus('idle');
|
|
@@ -350,6 +357,7 @@ export function UsersView(props: UsersViewProps) @{
|
|
|
350
357
|
setFormSession((current) => current + 1);
|
|
351
358
|
setFormOpen(false);
|
|
352
359
|
setMemberCount(memberCount + 1);
|
|
360
|
+
if (user.role === 'owner') setOwnerCount(ownerCount + 1);
|
|
353
361
|
/* The new row belongs wherever the order puts it; the first page of
|
|
354
362
|
the listing shows it or the page it is on does. */
|
|
355
363
|
await load(firstPage({}));
|
|
@@ -436,11 +444,13 @@ export function UsersView(props: UsersViewProps) @{
|
|
|
436
444
|
const role = bulkRole || (assignableRoles[0]?.key ?? '');
|
|
437
445
|
setRoleDialogOpen(false);
|
|
438
446
|
if (role === '') return;
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
447
|
+
/* A role handed to several rows moves the owner count by an amount only
|
|
448
|
+
the server knows, so the context is read again with the page. */
|
|
449
|
+
void applyMany(async (ids) => {
|
|
450
|
+
const outcomes = await assignRoleMany(ids, role, props.csrfToken);
|
|
451
|
+
await loadContext();
|
|
452
|
+
return outcomes;
|
|
453
|
+
}, 'users.notice.roleAssignedMany', 'users.error.roleMany');
|
|
444
454
|
};
|
|
445
455
|
|
|
446
456
|
const openMember = (accountId: string) => {
|
|
@@ -466,6 +476,7 @@ export function UsersView(props: UsersViewProps) @{
|
|
|
466
476
|
await removeMember(accountId, props.csrfToken);
|
|
467
477
|
setSelectedAccountId(null);
|
|
468
478
|
setMemberCount(memberCount - 1);
|
|
479
|
+
if (selected.role === 'owner') setOwnerCount(ownerCount - 1);
|
|
469
480
|
await load(listing());
|
|
470
481
|
} catch (removeError) {
|
|
471
482
|
setError(message(removeError, t('users.error.remove')));
|
|
@@ -545,7 +556,10 @@ export function UsersView(props: UsersViewProps) @{
|
|
|
545
556
|
}
|
|
546
557
|
<TableCard
|
|
547
558
|
title={t('users.table.title')}
|
|
548
|
-
count={t('users.table.count', {
|
|
559
|
+
count={t('users.table.count', {
|
|
560
|
+
accounts: memberCount,
|
|
561
|
+
owners: ownerCount,
|
|
562
|
+
})}
|
|
549
563
|
search={<SearchField
|
|
550
564
|
value={query}
|
|
551
565
|
placeholder={t('users.search.placeholder')}
|
|
@@ -11,14 +11,14 @@ export function memberColumns(
|
|
|
11
11
|
{
|
|
12
12
|
key: 'displayName',
|
|
13
13
|
header: t('users.table.user'),
|
|
14
|
-
width: '
|
|
14
|
+
width: '20%',
|
|
15
15
|
value: (user) => user.displayName,
|
|
16
16
|
cell: (user) => <b>{user.displayName}</b>,
|
|
17
17
|
},
|
|
18
18
|
{
|
|
19
19
|
key: 'email',
|
|
20
20
|
header: t('users.table.email'),
|
|
21
|
-
width: '
|
|
21
|
+
width: '22%',
|
|
22
22
|
value: (user) => user.email,
|
|
23
23
|
cell: (user) => <span>{user.email}</span>,
|
|
24
24
|
},
|
|
@@ -34,7 +34,7 @@ export function memberColumns(
|
|
|
34
34
|
{
|
|
35
35
|
key: 'status',
|
|
36
36
|
header: t('users.table.status'),
|
|
37
|
-
width: '
|
|
37
|
+
width: '22%',
|
|
38
38
|
cell: (user) => <span class="ui-table__state">
|
|
39
39
|
<Tag
|
|
40
40
|
tone={user.membershipStatus === 'active' ? 'success' : 'neutral'}
|
|
@@ -55,11 +55,20 @@ export function memberColumns(
|
|
|
55
55
|
{
|
|
56
56
|
key: 'scopes',
|
|
57
57
|
header: t('users.table.scopes'),
|
|
58
|
-
width: '
|
|
58
|
+
width: '10%',
|
|
59
59
|
numeric: true,
|
|
60
60
|
cell: (user) => new Intl.NumberFormat(activeLocale()).format(
|
|
61
61
|
user.scopes.length,
|
|
62
62
|
),
|
|
63
63
|
},
|
|
64
|
+
/* No `value`: the server's sorted read has no keyset over created_at. */
|
|
65
|
+
{
|
|
66
|
+
key: 'createdAt',
|
|
67
|
+
header: t('users.table.joined'),
|
|
68
|
+
width: '12%',
|
|
69
|
+
cell: (user) => new Intl.DateTimeFormat(activeLocale(), {
|
|
70
|
+
dateStyle: 'medium',
|
|
71
|
+
}).format(user.createdAt),
|
|
72
|
+
},
|
|
64
73
|
];
|
|
65
74
|
}
|
|
@@ -46,6 +46,7 @@ export function createUsersClientState() {
|
|
|
46
46
|
actor: cell<UsersContext['actor'] | null>(null),
|
|
47
47
|
passwordMinLength: 12,
|
|
48
48
|
memberCount: 0,
|
|
49
|
+
ownerCount: 0,
|
|
49
50
|
query: '',
|
|
50
51
|
/** The term the rows on screen answer; typing moves `query` ahead of it. */
|
|
51
52
|
appliedQuery: '',
|
|
@@ -192,6 +193,21 @@ export function replaceMember(
|
|
|
192
193
|
);
|
|
193
194
|
}
|
|
194
195
|
|
|
196
|
+
/**
|
|
197
|
+
* The owner count after one row changed on screen: the count is read whole
|
|
198
|
+
* from the server, so a role change moves it by the difference between the row
|
|
199
|
+
* the page held and the row the server answered.
|
|
200
|
+
*/
|
|
201
|
+
export function ownerCountAfter(
|
|
202
|
+
ownerCount: number,
|
|
203
|
+
previous: TenantMember | undefined,
|
|
204
|
+
next: TenantMember,
|
|
205
|
+
): number {
|
|
206
|
+
const before = previous?.role === 'owner' ? 1 : 0;
|
|
207
|
+
const after = next.role === 'owner' ? 1 : 0;
|
|
208
|
+
return ownerCount - before + after;
|
|
209
|
+
}
|
|
210
|
+
|
|
195
211
|
/* The membership route answers with stable codes; each one gets the sentence
|
|
196
212
|
that says what to do instead, and anything else keeps the server message. */
|
|
197
213
|
export function membershipStatusMessage(error: unknown): string {
|
|
@@ -54,6 +54,8 @@ export interface UsersContext {
|
|
|
54
54
|
readonly passwordMinLength: number;
|
|
55
55
|
/** Every membership of the workspace, counted in the database. */
|
|
56
56
|
readonly memberCount: number;
|
|
57
|
+
/** The memberships holding the owner role, counted the same way. */
|
|
58
|
+
readonly ownerCount: number;
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
function actor(principal: AuthPrincipal): AuthActor {
|
|
@@ -105,6 +107,7 @@ export class UsersService {
|
|
|
105
107
|
actor: { accountId: principal.accountId, role: principal.role },
|
|
106
108
|
passwordMinLength: this.#auth.settings.passwordMinLength,
|
|
107
109
|
memberCount: await service.countTenantMembers(principal.tenantId),
|
|
110
|
+
ownerCount: await service.countTenantOwners(principal.tenantId),
|
|
108
111
|
};
|
|
109
112
|
}
|
|
110
113
|
|
|
@@ -35,12 +35,13 @@
|
|
|
35
35
|
"denied.members": "Reading the user directory needs the users.members.read scope.",
|
|
36
36
|
"table.title": "Members",
|
|
37
37
|
"table.caption": "Workspace users",
|
|
38
|
-
"table.count": "{accounts} accounts",
|
|
38
|
+
"table.count": "{accounts} accounts, {owners} owners",
|
|
39
39
|
"table.user": "User",
|
|
40
40
|
"table.email": "Email",
|
|
41
41
|
"table.role": "Role",
|
|
42
42
|
"table.status": "Workspace access",
|
|
43
43
|
"table.scopes": "Scopes",
|
|
44
|
+
"table.joined": "Joined",
|
|
44
45
|
"table.actions": "User actions",
|
|
45
46
|
"table.loading": "Loading workspace accounts…",
|
|
46
47
|
"table.emptyTitle": "No accounts yet",
|
|
@@ -35,12 +35,13 @@
|
|
|
35
35
|
"denied.members": "Odczyt katalogu użytkowników wymaga uprawnienia users.members.read.",
|
|
36
36
|
"table.title": "Członkowie",
|
|
37
37
|
"table.caption": "Użytkownicy przestrzeni roboczej",
|
|
38
|
-
"table.count": "Konta: {accounts}",
|
|
38
|
+
"table.count": "Konta: {accounts}, właściciele: {owners}",
|
|
39
39
|
"table.user": "Użytkownik",
|
|
40
40
|
"table.email": "E-mail",
|
|
41
41
|
"table.role": "Rola",
|
|
42
42
|
"table.status": "Dostęp do przestrzeni",
|
|
43
43
|
"table.scopes": "Zakresy",
|
|
44
|
+
"table.joined": "Dołączył(a)",
|
|
44
45
|
"table.actions": "Akcje użytkownika",
|
|
45
46
|
"table.loading": "Wczytywanie kont…",
|
|
46
47
|
"table.emptyTitle": "Brak kont",
|