@flowdular/sdk 0.3.1 → 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 +2 -0
- 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/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/module.json +1 -1
- package/modules/approvals/spec/module.yaml +1 -1
- 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/module.json +1 -1
- package/modules/connectors/spec/module.yaml +1 -1
- package/modules/directory/module.json +1 -1
- package/modules/directory/spec/module.yaml +1 -1
- package/modules/documents/module.json +1 -1
- package/modules/documents/spec/module.yaml +1 -1
- package/modules/exports/module.json +1 -1
- package/modules/exports/spec/module.yaml +1 -1
- 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/server/src/endpoint.ts +17 -1
- package/packages/server/src/index.ts +10 -0
- package/packages/server/src/module-activation.ts +69 -0
- package/assets/flowdular-banner.png +0 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { DatabaseHandle } from '@flowdular/sdk/database';
|
|
2
|
+
import { runDatabaseMigrations } from '@flowdular/sdk/database';
|
|
3
|
+
import { databaseMigrations } from './migration.ts';
|
|
4
|
+
import type {
|
|
5
|
+
ModuleActivationRecord,
|
|
6
|
+
ModuleActivationRepository,
|
|
7
|
+
} from './repository.ts';
|
|
8
|
+
|
|
9
|
+
interface ActivationRow {
|
|
10
|
+
tenant_id: string;
|
|
11
|
+
module_id: string;
|
|
12
|
+
active: number | bigint | string;
|
|
13
|
+
changed_by: string;
|
|
14
|
+
changed_at: number | bigint | string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const SQL = {
|
|
18
|
+
list: `SELECT tenant_id, module_id, active, changed_by, changed_at
|
|
19
|
+
FROM system_module_activations
|
|
20
|
+
WHERE tenant_id = $1
|
|
21
|
+
ORDER BY module_id`,
|
|
22
|
+
set: `INSERT INTO system_module_activations
|
|
23
|
+
(tenant_id, module_id, active, changed_by, changed_at)
|
|
24
|
+
VALUES ($1, $2, $3, $4, $5)
|
|
25
|
+
ON CONFLICT (tenant_id, module_id) DO UPDATE
|
|
26
|
+
SET active = EXCLUDED.active,
|
|
27
|
+
changed_by = EXCLUDED.changed_by,
|
|
28
|
+
changed_at = EXCLUDED.changed_at
|
|
29
|
+
WHERE system_module_activations.tenant_id = $1`,
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
/* PostgreSQL returns BIGINT as a string, and a flag column as a number this
|
|
33
|
+
module compares only after normalizing. */
|
|
34
|
+
function integer(value: number | bigint | string, field: string): number {
|
|
35
|
+
const normalized = Number(value);
|
|
36
|
+
if (!Number.isSafeInteger(normalized) || normalized < 0) {
|
|
37
|
+
throw new Error(`The system database returned an invalid ${field}.`);
|
|
38
|
+
}
|
|
39
|
+
return normalized;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function fromRow(row: ActivationRow): ModuleActivationRecord {
|
|
43
|
+
return {
|
|
44
|
+
tenantId: row.tenant_id,
|
|
45
|
+
moduleId: row.module_id,
|
|
46
|
+
active: integer(row.active, 'flag') === 1,
|
|
47
|
+
changedBy: row.changed_by,
|
|
48
|
+
changedAt: integer(row.changed_at, 'timestamp'),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class DatabaseModuleActivationRepository
|
|
53
|
+
implements ModuleActivationRepository
|
|
54
|
+
{
|
|
55
|
+
constructor(private readonly database: DatabaseHandle) {}
|
|
56
|
+
|
|
57
|
+
async list(tenantId: string): Promise<readonly ModuleActivationRecord[]> {
|
|
58
|
+
const result = await this.database.transaction(
|
|
59
|
+
(transaction) =>
|
|
60
|
+
transaction.query<ActivationRow>({
|
|
61
|
+
text: SQL.list,
|
|
62
|
+
parameters: [tenantId],
|
|
63
|
+
}),
|
|
64
|
+
{ access: 'read', tenantId },
|
|
65
|
+
);
|
|
66
|
+
return result.rows.map(fromRow);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async set(record: ModuleActivationRecord): Promise<void> {
|
|
70
|
+
await this.database.transaction(
|
|
71
|
+
(transaction) =>
|
|
72
|
+
transaction.execute({
|
|
73
|
+
text: SQL.set,
|
|
74
|
+
parameters: [
|
|
75
|
+
record.tenantId,
|
|
76
|
+
record.moduleId,
|
|
77
|
+
record.active ? 1 : 0,
|
|
78
|
+
record.changedBy,
|
|
79
|
+
record.changedAt,
|
|
80
|
+
],
|
|
81
|
+
}),
|
|
82
|
+
{ access: 'write', tenantId: record.tenantId },
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function migrateSystemDatabase(
|
|
88
|
+
database: DatabaseHandle,
|
|
89
|
+
): Promise<void> {
|
|
90
|
+
await runDatabaseMigrations(database, 'system.core', databaseMigrations);
|
|
91
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { DatabaseMigration } from '@flowdular/sdk/database';
|
|
2
|
+
import { postgresTenantTableState } from '@flowdular/sdk/database';
|
|
3
|
+
|
|
4
|
+
/* Mirrors migrations/0001_system_module_activations.up.sql byte for byte;
|
|
5
|
+
tests/migrations.test.ts fails on drift. */
|
|
6
|
+
export const SYSTEM_MIGRATION_001 = `-- One row per module whose activation an owner changed for a workspace. A
|
|
7
|
+
-- composed module without a row is active; the row keeps the last decision and
|
|
8
|
+
-- who made it. The primary key is the only lookup: the snapshot reads every row
|
|
9
|
+
-- of one tenant at once.
|
|
10
|
+
CREATE TABLE IF NOT EXISTS system_module_activations (
|
|
11
|
+
tenant_id TEXT NOT NULL,
|
|
12
|
+
module_id TEXT NOT NULL CHECK (length(module_id) BETWEEN 1 AND 128),
|
|
13
|
+
active SMALLINT NOT NULL CHECK (active IN (0, 1)),
|
|
14
|
+
changed_by TEXT NOT NULL CHECK (length(changed_by) BETWEEN 1 AND 64),
|
|
15
|
+
changed_at BIGINT NOT NULL,
|
|
16
|
+
PRIMARY KEY (tenant_id, module_id)
|
|
17
|
+
);
|
|
18
|
+
ALTER TABLE system_module_activations ENABLE ROW LEVEL SECURITY;
|
|
19
|
+
ALTER TABLE system_module_activations FORCE ROW LEVEL SECURITY;
|
|
20
|
+
CREATE POLICY system_module_activations_tenant_policy ON system_module_activations
|
|
21
|
+
USING (tenant_id = current_setting('coreloom.tenant_id', true))
|
|
22
|
+
WITH CHECK (tenant_id = current_setting('coreloom.tenant_id', true));
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
/** Every tenant table of this module, with the policy each one must carry. */
|
|
26
|
+
export const SYSTEM_TENANT_TABLES = ['system_module_activations'] as const;
|
|
27
|
+
|
|
28
|
+
export const databaseMigrations: readonly DatabaseMigration[] = [
|
|
29
|
+
{
|
|
30
|
+
id: '0001_system_module_activations',
|
|
31
|
+
sql: { postgresql: SYSTEM_MIGRATION_001 },
|
|
32
|
+
inspectExisting: (database) =>
|
|
33
|
+
postgresTenantTableState(
|
|
34
|
+
database,
|
|
35
|
+
'system_module_activations',
|
|
36
|
+
'system_module_activations_tenant_policy',
|
|
37
|
+
),
|
|
38
|
+
},
|
|
39
|
+
];
|
|
@@ -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')}
|