@jskit-ai/users-core 0.1.4
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.descriptor.mjs +464 -0
- package/package.json +35 -0
- package/src/server/UsersCoreServiceProvider.js +74 -0
- package/src/server/accountNotifications/accountNotificationsActions.js +39 -0
- package/src/server/accountNotifications/accountNotificationsService.js +41 -0
- package/src/server/accountNotifications/bootAccountNotificationsRoutes.js +41 -0
- package/src/server/accountNotifications/registerAccountNotifications.js +39 -0
- package/src/server/accountPreferences/accountPreferencesActions.js +39 -0
- package/src/server/accountPreferences/accountPreferencesService.js +41 -0
- package/src/server/accountPreferences/bootAccountPreferencesRoutes.js +41 -0
- package/src/server/accountPreferences/registerAccountPreferences.js +39 -0
- package/src/server/accountProfile/accountProfileActions.js +137 -0
- package/src/server/accountProfile/accountProfileService.js +124 -0
- package/src/server/accountProfile/avatarService.js +141 -0
- package/src/server/accountProfile/avatarStorageService.js +132 -0
- package/src/server/accountProfile/bootAccountProfileRoutes.js +166 -0
- package/src/server/accountProfile/registerAccountProfile.js +62 -0
- package/src/server/accountProfile/registerAvatarMultipartSupport.js +43 -0
- package/src/server/accountSecurity/accountSecurityActions.js +144 -0
- package/src/server/accountSecurity/accountSecurityService.js +103 -0
- package/src/server/accountSecurity/bootAccountSecurityRoutes.js +183 -0
- package/src/server/accountSecurity/registerAccountSecurity.js +31 -0
- package/src/server/common/README.md +21 -0
- package/src/server/common/contributors/README.md +11 -0
- package/src/server/common/contributors/workspaceActionContextContributor.js +79 -0
- package/src/server/common/contributors/workspaceAuthPolicyContextResolver.js +34 -0
- package/src/server/common/contributors/workspaceRouteVisibilityResolver.js +79 -0
- package/src/server/common/diTokens.js +21 -0
- package/src/server/common/formatters/README.md +11 -0
- package/src/server/common/formatters/accountAvatarFormatter.js +42 -0
- package/src/server/common/formatters/accountSecurityStatusFormatter.js +71 -0
- package/src/server/common/formatters/accountSettingsResponseFormatter.js +62 -0
- package/src/server/common/formatters/workspaceFormatter.js +46 -0
- package/src/server/common/registerCommonRepositories.js +45 -0
- package/src/server/common/registerSharedApi.js +9 -0
- package/src/server/common/repositories/README.md +24 -0
- package/src/server/common/repositories/repositoryUtils.js +50 -0
- package/src/server/common/repositories/userProfilesRepository.js +251 -0
- package/src/server/common/repositories/userSettingsRepository.js +179 -0
- package/src/server/common/repositories/workspaceInvitesRepository.js +172 -0
- package/src/server/common/repositories/workspaceMembershipsRepository.js +157 -0
- package/src/server/common/repositories/workspacesRepository.js +183 -0
- package/src/server/common/routes/README.md +11 -0
- package/src/server/common/services/README.md +12 -0
- package/src/server/common/services/accountContextService.js +31 -0
- package/src/server/common/services/authProfileSyncService.js +128 -0
- package/src/server/common/services/workspaceContextService.js +270 -0
- package/src/server/common/support/deepFreeze.js +17 -0
- package/src/server/common/support/realtimeServiceEvents.js +94 -0
- package/src/server/common/support/resolveActionUser.js +11 -0
- package/src/server/common/support/workspaceRoutePaths.js +17 -0
- package/src/server/common/validators/README.md +11 -0
- package/src/server/common/validators/authenticatedUserValidator.js +42 -0
- package/src/server/common/validators/routeParamsValidator.js +62 -0
- package/src/server/consoleSettings/bootConsoleSettingsRoutes.js +64 -0
- package/src/server/consoleSettings/consoleService.js +36 -0
- package/src/server/consoleSettings/consoleSettingsActions.js +55 -0
- package/src/server/consoleSettings/consoleSettingsRepository.js +111 -0
- package/src/server/consoleSettings/consoleSettingsService.js +40 -0
- package/src/server/consoleSettings/registerConsoleSettings.js +57 -0
- package/src/server/registerWorkspaceBootstrap.js +36 -0
- package/src/server/registerWorkspaceCore.js +95 -0
- package/src/server/support/resolveWorkspace.js +16 -0
- package/src/server/support/workspaceActionSurfaces.js +135 -0
- package/src/server/support/workspaceInvitationsPolicy.js +45 -0
- package/src/server/support/workspaceRouteInput.js +22 -0
- package/src/server/workspaceBootstrapContributor.js +401 -0
- package/src/server/workspaceDirectory/bootWorkspaceDirectoryRoutes.js +73 -0
- package/src/server/workspaceDirectory/registerWorkspaceDirectory.js +19 -0
- package/src/server/workspaceDirectory/workspaceDirectoryActions.js +65 -0
- package/src/server/workspaceMembers/bootWorkspaceMembers.js +238 -0
- package/src/server/workspaceMembers/registerWorkspaceMembers.js +112 -0
- package/src/server/workspaceMembers/workspaceMembersActions.js +186 -0
- package/src/server/workspaceMembers/workspaceMembersService.js +210 -0
- package/src/server/workspacePendingInvitations/bootWorkspacePendingInvitations.js +63 -0
- package/src/server/workspacePendingInvitations/registerWorkspacePendingInvitations.js +128 -0
- package/src/server/workspacePendingInvitations/workspacePendingInvitationsActions.js +74 -0
- package/src/server/workspacePendingInvitations/workspacePendingInvitationsService.js +137 -0
- package/src/server/workspaceSettings/bootWorkspaceSettings.js +77 -0
- package/src/server/workspaceSettings/registerWorkspaceSettings.js +67 -0
- package/src/server/workspaceSettings/workspaceSettingsActions.js +72 -0
- package/src/server/workspaceSettings/workspaceSettingsRepository.js +135 -0
- package/src/server/workspaceSettings/workspaceSettingsService.js +65 -0
- package/src/shared/events/usersEvents.js +19 -0
- package/src/shared/index.js +91 -0
- package/src/shared/operationMessages.js +16 -0
- package/src/shared/resources/consoleSettingsFields.js +55 -0
- package/src/shared/resources/consoleSettingsResource.js +139 -0
- package/src/shared/resources/resolveGlobalArrayRegistry.js +6 -0
- package/src/shared/resources/userProfileResource.js +148 -0
- package/src/shared/resources/userSettingsFields.js +71 -0
- package/src/shared/resources/userSettingsResource.js +416 -0
- package/src/shared/resources/workspaceMembersResource.js +352 -0
- package/src/shared/resources/workspacePendingInvitationsResource.js +87 -0
- package/src/shared/resources/workspaceResource.js +149 -0
- package/src/shared/resources/workspaceSettingsFields.js +60 -0
- package/src/shared/resources/workspaceSettingsResource.js +178 -0
- package/src/shared/roles.js +136 -0
- package/src/shared/settings.js +31 -0
- package/src/shared/support/usersApiPaths.js +34 -0
- package/src/shared/support/usersVisibility.js +45 -0
- package/src/shared/support/workspacePathModel.js +145 -0
- package/src/shared/tenancyMode.js +35 -0
- package/src/shared/tenancyProfile.js +73 -0
- package/templates/config/workspaceRoles.js +30 -0
- package/templates/migrations/users_core_console_owner.cjs +39 -0
- package/templates/migrations/users_core_initial.cjs +118 -0
- package/templates/migrations/users_core_profile_username.cjs +98 -0
- package/templates/packages/main/src/shared/resources/consoleSettingsFields.js +11 -0
- package/templates/packages/main/src/shared/resources/userSettingsFields.js +138 -0
- package/templates/packages/main/src/shared/resources/workspaceSettingsFields.js +105 -0
- package/test/authProfileSyncService.test.js +119 -0
- package/test/avatarService.test.js +114 -0
- package/test/avatarStorageService.test.js +61 -0
- package/test/consoleService.test.js +57 -0
- package/test/consoleSettingsService.test.js +86 -0
- package/test/exportsContract.test.js +38 -0
- package/test/registerAvatarMultipartSupport.test.js +64 -0
- package/test/registerServiceRealtimeEvents.test.js +160 -0
- package/test/registerWorkspaceDirectory.test.js +26 -0
- package/test/registerWorkspaceSettings.test.js +44 -0
- package/test/resourcesCanonical.test.js +90 -0
- package/test/roles.test.js +74 -0
- package/test/settingsFieldRegistriesSingleton.test.js +24 -0
- package/test/tenancyProfile.test.js +67 -0
- package/test/userSettingsResource.test.js +31 -0
- package/test/usersApiPaths.test.js +31 -0
- package/test/usersRouteRequestInputValidator.test.js +556 -0
- package/test/usersRouteResources.test.js +113 -0
- package/test/usersRouteValidators.test.js +49 -0
- package/test/usersVisibility.test.js +22 -0
- package/test/workspaceActionContextContributor.test.js +251 -0
- package/test/workspaceActionSurfaces.test.js +105 -0
- package/test/workspaceAuthPolicyContextResolver.test.js +119 -0
- package/test/workspaceBootstrapContributor.test.js +466 -0
- package/test/workspaceInvitationsPolicy.test.js +71 -0
- package/test/workspaceInvitesRepository.test.js +111 -0
- package/test/workspaceMembersService.test.js +400 -0
- package/test/workspacePathModel.test.js +93 -0
- package/test/workspacePendingInvitationsResource.test.js +38 -0
- package/test/workspacePendingInvitationsService.test.js +151 -0
- package/test/workspaceRouteVisibilityResolver.test.js +83 -0
- package/test/workspaceService.test.js +480 -0
- package/test/workspaceSettingsActions.test.js +42 -0
- package/test/workspaceSettingsRepository.test.js +156 -0
- package/test/workspaceSettingsResource.test.js +156 -0
- package/test/workspaceSettingsService.test.js +120 -0
- package/test-support/registerDefaultSettingsFields.js +3 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export const workspaceRoles = {};
|
|
2
|
+
|
|
3
|
+
workspaceRoles.defaultInviteRole = "member";
|
|
4
|
+
workspaceRoles.roles = {};
|
|
5
|
+
|
|
6
|
+
workspaceRoles.roles.owner = {
|
|
7
|
+
assignable: false,
|
|
8
|
+
permissions: []
|
|
9
|
+
};
|
|
10
|
+
workspaceRoles.roles.owner.permissions.push("*");
|
|
11
|
+
|
|
12
|
+
workspaceRoles.roles.admin = {
|
|
13
|
+
assignable: true,
|
|
14
|
+
permissions: []
|
|
15
|
+
};
|
|
16
|
+
workspaceRoles.roles.admin.permissions.push(
|
|
17
|
+
"workspace.roles.view",
|
|
18
|
+
"workspace.settings.view",
|
|
19
|
+
"workspace.settings.update",
|
|
20
|
+
"workspace.members.view",
|
|
21
|
+
"workspace.members.invite",
|
|
22
|
+
"workspace.members.manage",
|
|
23
|
+
"workspace.invites.revoke"
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
workspaceRoles.roles.member = {
|
|
27
|
+
assignable: true,
|
|
28
|
+
permissions: []
|
|
29
|
+
};
|
|
30
|
+
workspaceRoles.roles.member.permissions.push("workspace.settings.view");
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// JSKIT_MIGRATION_ID: users-core-console-owner
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {import('knex').Knex} knex
|
|
5
|
+
*/
|
|
6
|
+
exports.up = async function up(knex) {
|
|
7
|
+
const hasConsoleSettingsTable = await knex.schema.hasTable("console_settings");
|
|
8
|
+
if (!hasConsoleSettingsTable) {
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const hasOwnerUserId = await knex.schema.hasColumn("console_settings", "owner_user_id");
|
|
13
|
+
if (hasOwnerUserId) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
await knex.schema.alterTable("console_settings", (table) => {
|
|
18
|
+
table.integer("owner_user_id").unsigned().nullable();
|
|
19
|
+
});
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {import('knex').Knex} knex
|
|
24
|
+
*/
|
|
25
|
+
exports.down = async function down(knex) {
|
|
26
|
+
const hasConsoleSettingsTable = await knex.schema.hasTable("console_settings");
|
|
27
|
+
if (!hasConsoleSettingsTable) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const hasOwnerUserId = await knex.schema.hasColumn("console_settings", "owner_user_id");
|
|
32
|
+
if (!hasOwnerUserId) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
await knex.schema.alterTable("console_settings", (table) => {
|
|
37
|
+
table.dropColumn("owner_user_id");
|
|
38
|
+
});
|
|
39
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// JSKIT_MIGRATION_ID: users-core-initial-schema
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {import('knex').Knex} knex
|
|
5
|
+
*/
|
|
6
|
+
exports.up = async function up(knex) {
|
|
7
|
+
await knex.schema.createTable("user_profiles", (table) => {
|
|
8
|
+
table.increments("id").primary();
|
|
9
|
+
table.string("auth_provider", 64).notNullable();
|
|
10
|
+
table.string("auth_provider_user_id", 191).notNullable();
|
|
11
|
+
table.string("email", 255).notNullable();
|
|
12
|
+
table.string("username", 120).notNullable();
|
|
13
|
+
table.string("display_name", 160).notNullable();
|
|
14
|
+
table.string("avatar_storage_key", 512).nullable();
|
|
15
|
+
table.string("avatar_version", 64).nullable();
|
|
16
|
+
table.timestamp("avatar_updated_at", { useTz: false }).nullable();
|
|
17
|
+
table.timestamp("created_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
|
|
18
|
+
table.unique(["auth_provider", "auth_provider_user_id"], "uq_user_profiles_identity");
|
|
19
|
+
table.unique(["email"], "uq_user_profiles_email");
|
|
20
|
+
table.unique(["username"], "uq_user_profiles_username");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
await knex.schema.createTable("workspaces", (table) => {
|
|
24
|
+
table.increments("id").primary();
|
|
25
|
+
table.string("slug", 120).notNullable().unique();
|
|
26
|
+
table.string("name", 160).notNullable();
|
|
27
|
+
table.integer("owner_user_id").unsigned().notNullable().references("id").inTable("user_profiles").onDelete("CASCADE");
|
|
28
|
+
table.boolean("is_personal").notNullable().defaultTo(true);
|
|
29
|
+
table.string("avatar_url", 512).notNullable().defaultTo("");
|
|
30
|
+
table.string("color", 7).notNullable().defaultTo("#0F6B54");
|
|
31
|
+
table.timestamp("created_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
|
|
32
|
+
table.timestamp("updated_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
|
|
33
|
+
table.timestamp("deleted_at", { useTz: false }).nullable();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
await knex.schema.createTable("workspace_memberships", (table) => {
|
|
37
|
+
table.increments("id").primary();
|
|
38
|
+
table.integer("workspace_id").unsigned().notNullable().references("id").inTable("workspaces").onDelete("CASCADE");
|
|
39
|
+
table.integer("user_id").unsigned().notNullable().references("id").inTable("user_profiles").onDelete("CASCADE");
|
|
40
|
+
table.string("role_id", 64).notNullable().defaultTo("member");
|
|
41
|
+
table.string("status", 32).notNullable().defaultTo("active");
|
|
42
|
+
table.timestamp("created_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
|
|
43
|
+
table.timestamp("updated_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
|
|
44
|
+
table.unique(["workspace_id", "user_id"], "uq_workspace_memberships_workspace_user");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
await knex.schema.createTable("workspace_settings", (table) => {
|
|
48
|
+
table.integer("workspace_id").unsigned().primary().references("id").inTable("workspaces").onDelete("CASCADE");
|
|
49
|
+
table.string("name", 160).notNullable().defaultTo("Workspace");
|
|
50
|
+
table.string("avatar_url", 512).notNullable().defaultTo("");
|
|
51
|
+
table.string("color", 7).notNullable().defaultTo("#0F6B54");
|
|
52
|
+
table.boolean("invites_enabled").notNullable().defaultTo(true);
|
|
53
|
+
table.timestamp("created_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
|
|
54
|
+
table.timestamp("updated_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
await knex.schema.createTable("workspace_invites", (table) => {
|
|
58
|
+
table.increments("id").primary();
|
|
59
|
+
table.integer("workspace_id").unsigned().notNullable().references("id").inTable("workspaces").onDelete("CASCADE");
|
|
60
|
+
table.string("email", 255).notNullable();
|
|
61
|
+
table.string("role_id", 64).notNullable().defaultTo("member");
|
|
62
|
+
table.string("status", 32).notNullable().defaultTo("pending");
|
|
63
|
+
table.string("token_hash", 191).notNullable();
|
|
64
|
+
table.integer("invited_by_user_id").unsigned().nullable().references("id").inTable("user_profiles").onDelete("SET NULL");
|
|
65
|
+
table.timestamp("expires_at", { useTz: false }).nullable();
|
|
66
|
+
table.timestamp("accepted_at", { useTz: false }).nullable();
|
|
67
|
+
table.timestamp("revoked_at", { useTz: false }).nullable();
|
|
68
|
+
table.timestamp("created_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
|
|
69
|
+
table.timestamp("updated_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
|
|
70
|
+
table.unique(["token_hash"], "uq_workspace_invites_token_hash");
|
|
71
|
+
table.index(["workspace_id", "status"], "idx_workspace_invites_workspace_status");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
await knex.schema.createTable("user_settings", (table) => {
|
|
75
|
+
table.integer("user_id").unsigned().primary().references("id").inTable("user_profiles").onDelete("CASCADE");
|
|
76
|
+
table.integer("last_active_workspace_id").unsigned().nullable().references("id").inTable("workspaces").onDelete("SET NULL");
|
|
77
|
+
table.string("theme", 32).notNullable().defaultTo("system");
|
|
78
|
+
table.string("locale", 24).notNullable().defaultTo("en");
|
|
79
|
+
table.string("time_zone", 64).notNullable().defaultTo("UTC");
|
|
80
|
+
table.string("date_format", 32).notNullable().defaultTo("yyyy-mm-dd");
|
|
81
|
+
table.string("number_format", 32).notNullable().defaultTo("1,234.56");
|
|
82
|
+
table.string("currency_code", 3).notNullable().defaultTo("USD");
|
|
83
|
+
table.integer("avatar_size").notNullable().defaultTo(64);
|
|
84
|
+
table.boolean("password_sign_in_enabled").notNullable().defaultTo(true);
|
|
85
|
+
table.boolean("password_setup_required").notNullable().defaultTo(false);
|
|
86
|
+
table.boolean("notify_product_updates").notNullable().defaultTo(true);
|
|
87
|
+
table.boolean("notify_account_activity").notNullable().defaultTo(true);
|
|
88
|
+
table.boolean("notify_security_alerts").notNullable().defaultTo(true);
|
|
89
|
+
table.timestamp("created_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
|
|
90
|
+
table.timestamp("updated_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
await knex.schema.createTable("console_settings", (table) => {
|
|
94
|
+
table.integer("id").primary();
|
|
95
|
+
table.integer("owner_user_id").unsigned().nullable();
|
|
96
|
+
table.timestamp("created_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
|
|
97
|
+
table.timestamp("updated_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
await knex("console_settings").insert({
|
|
101
|
+
id: 1,
|
|
102
|
+
created_at: knex.fn.now(),
|
|
103
|
+
updated_at: knex.fn.now()
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @param {import('knex').Knex} knex
|
|
109
|
+
*/
|
|
110
|
+
exports.down = async function down(knex) {
|
|
111
|
+
await knex.schema.dropTableIfExists("console_settings");
|
|
112
|
+
await knex.schema.dropTableIfExists("user_settings");
|
|
113
|
+
await knex.schema.dropTableIfExists("workspace_invites");
|
|
114
|
+
await knex.schema.dropTableIfExists("workspace_settings");
|
|
115
|
+
await knex.schema.dropTableIfExists("workspace_memberships");
|
|
116
|
+
await knex.schema.dropTableIfExists("workspaces");
|
|
117
|
+
await knex.schema.dropTableIfExists("user_profiles");
|
|
118
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// JSKIT_MIGRATION_ID: users-core-profile-username
|
|
2
|
+
|
|
3
|
+
const USERNAME_MAX_LENGTH = 120;
|
|
4
|
+
|
|
5
|
+
function normalizeUsername(value) {
|
|
6
|
+
return String(value || "")
|
|
7
|
+
.trim()
|
|
8
|
+
.toLowerCase()
|
|
9
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
10
|
+
.replace(/^-+|-+$/g, "")
|
|
11
|
+
.slice(0, USERNAME_MAX_LENGTH);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function usernameBaseFromEmail(email) {
|
|
15
|
+
const normalizedEmail = String(email || "").trim().toLowerCase();
|
|
16
|
+
const localPart = normalizedEmail.includes("@") ? normalizedEmail.split("@")[0] : normalizedEmail;
|
|
17
|
+
const username = normalizeUsername(localPart);
|
|
18
|
+
return username || "user";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function buildUsernameCandidate(baseUsername, suffix) {
|
|
22
|
+
const normalizedBase = normalizeUsername(baseUsername) || "user";
|
|
23
|
+
if (suffix < 1) {
|
|
24
|
+
return normalizedBase;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const suffixText = `-${suffix + 1}`;
|
|
28
|
+
const allowedBaseLength = USERNAME_MAX_LENGTH - suffixText.length;
|
|
29
|
+
const trimmedBase = normalizedBase.slice(0, allowedBaseLength);
|
|
30
|
+
return `${trimmedBase}${suffixText}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function resolveUniqueUsername(baseUsername, usedUsernames) {
|
|
34
|
+
for (let suffix = 0; suffix < 1000; suffix += 1) {
|
|
35
|
+
const candidate = buildUsernameCandidate(baseUsername, suffix);
|
|
36
|
+
if (!usedUsernames.has(candidate)) {
|
|
37
|
+
return candidate;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
throw new Error("Unable to generate unique username for migration.");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {import('knex').Knex} knex
|
|
45
|
+
*/
|
|
46
|
+
exports.up = async function up(knex) {
|
|
47
|
+
const hasUserProfilesTable = await knex.schema.hasTable("user_profiles");
|
|
48
|
+
if (!hasUserProfilesTable) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const hasUsername = await knex.schema.hasColumn("user_profiles", "username");
|
|
53
|
+
if (hasUsername) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
await knex.schema.alterTable("user_profiles", (table) => {
|
|
58
|
+
table.string("username", USERNAME_MAX_LENGTH).nullable();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const profiles = await knex("user_profiles").select(["id", "email"]).orderBy("id", "asc");
|
|
62
|
+
const usedUsernames = new Set();
|
|
63
|
+
|
|
64
|
+
for (const profile of profiles) {
|
|
65
|
+
const nextUsername = resolveUniqueUsername(usernameBaseFromEmail(profile.email), usedUsernames);
|
|
66
|
+
usedUsernames.add(nextUsername);
|
|
67
|
+
await knex("user_profiles").where({ id: Number(profile.id) }).update({
|
|
68
|
+
username: nextUsername
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
await knex.schema.alterTable("user_profiles", (table) => {
|
|
73
|
+
table.string("username", USERNAME_MAX_LENGTH).notNullable().alter();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
await knex.schema.alterTable("user_profiles", (table) => {
|
|
77
|
+
table.unique(["username"], "uq_user_profiles_username");
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {import('knex').Knex} knex
|
|
83
|
+
*/
|
|
84
|
+
exports.down = async function down(knex) {
|
|
85
|
+
const hasUserProfilesTable = await knex.schema.hasTable("user_profiles");
|
|
86
|
+
if (!hasUserProfilesTable) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const hasUsername = await knex.schema.hasColumn("user_profiles", "username");
|
|
91
|
+
if (!hasUsername) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
await knex.schema.alterTable("user_profiles", (table) => {
|
|
96
|
+
table.dropColumn("username");
|
|
97
|
+
});
|
|
98
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// @jskit-contract users.settings-fields.console.v1
|
|
2
|
+
// Append-only settings field registrations for console settings.
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
defineField,
|
|
6
|
+
resetConsoleSettingsFields
|
|
7
|
+
} from "@jskit-ai/users-core/shared/resources/consoleSettingsFields";
|
|
8
|
+
|
|
9
|
+
resetConsoleSettingsFields();
|
|
10
|
+
|
|
11
|
+
void defineField;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import { normalizeLowerText, normalizeText } from "@jskit-ai/kernel/shared/actions/textNormalization";
|
|
3
|
+
import { DEFAULT_USER_SETTINGS } from "@jskit-ai/users-core/shared/settings";
|
|
4
|
+
import {
|
|
5
|
+
USER_SETTINGS_SECTIONS,
|
|
6
|
+
defineField,
|
|
7
|
+
resetUserSettingsFields
|
|
8
|
+
} from "@jskit-ai/users-core/shared/resources/userSettingsFields";
|
|
9
|
+
|
|
10
|
+
function normalizePositiveInteger(value, fallback) {
|
|
11
|
+
const numericValue = Number(value);
|
|
12
|
+
if (Number.isInteger(numericValue) && numericValue > 0) {
|
|
13
|
+
return numericValue;
|
|
14
|
+
}
|
|
15
|
+
return Number(fallback);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
resetUserSettingsFields();
|
|
19
|
+
|
|
20
|
+
defineField({
|
|
21
|
+
key: "theme",
|
|
22
|
+
section: USER_SETTINGS_SECTIONS.PREFERENCES,
|
|
23
|
+
dbColumn: "theme",
|
|
24
|
+
required: true,
|
|
25
|
+
inputSchema: Type.String({ minLength: 1, maxLength: 32 }),
|
|
26
|
+
outputSchema: Type.String({ minLength: 1, maxLength: 32 }),
|
|
27
|
+
normalizeInput: (value) => normalizeText(value),
|
|
28
|
+
normalizeOutput: (value) => normalizeText(value) || DEFAULT_USER_SETTINGS.theme,
|
|
29
|
+
resolveDefault: () => DEFAULT_USER_SETTINGS.theme
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
defineField({
|
|
33
|
+
key: "locale",
|
|
34
|
+
section: USER_SETTINGS_SECTIONS.PREFERENCES,
|
|
35
|
+
dbColumn: "locale",
|
|
36
|
+
required: true,
|
|
37
|
+
inputSchema: Type.String({ minLength: 1, maxLength: 24 }),
|
|
38
|
+
outputSchema: Type.String({ minLength: 1, maxLength: 24 }),
|
|
39
|
+
normalizeInput: (value) => normalizeLowerText(value),
|
|
40
|
+
normalizeOutput: (value) => normalizeLowerText(value) || DEFAULT_USER_SETTINGS.locale,
|
|
41
|
+
resolveDefault: () => DEFAULT_USER_SETTINGS.locale
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
defineField({
|
|
45
|
+
key: "timeZone",
|
|
46
|
+
section: USER_SETTINGS_SECTIONS.PREFERENCES,
|
|
47
|
+
dbColumn: "time_zone",
|
|
48
|
+
required: true,
|
|
49
|
+
inputSchema: Type.String({ minLength: 1, maxLength: 64 }),
|
|
50
|
+
outputSchema: Type.String({ minLength: 1, maxLength: 64 }),
|
|
51
|
+
normalizeInput: (value) => normalizeText(value),
|
|
52
|
+
normalizeOutput: (value) => normalizeText(value) || DEFAULT_USER_SETTINGS.timeZone,
|
|
53
|
+
resolveDefault: () => DEFAULT_USER_SETTINGS.timeZone
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
defineField({
|
|
57
|
+
key: "dateFormat",
|
|
58
|
+
section: USER_SETTINGS_SECTIONS.PREFERENCES,
|
|
59
|
+
dbColumn: "date_format",
|
|
60
|
+
required: true,
|
|
61
|
+
inputSchema: Type.String({ minLength: 1, maxLength: 32 }),
|
|
62
|
+
outputSchema: Type.String({ minLength: 1, maxLength: 32 }),
|
|
63
|
+
normalizeInput: (value) => normalizeText(value),
|
|
64
|
+
normalizeOutput: (value) => normalizeText(value) || DEFAULT_USER_SETTINGS.dateFormat,
|
|
65
|
+
resolveDefault: () => DEFAULT_USER_SETTINGS.dateFormat
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
defineField({
|
|
69
|
+
key: "numberFormat",
|
|
70
|
+
section: USER_SETTINGS_SECTIONS.PREFERENCES,
|
|
71
|
+
dbColumn: "number_format",
|
|
72
|
+
required: true,
|
|
73
|
+
inputSchema: Type.String({ minLength: 1, maxLength: 32 }),
|
|
74
|
+
outputSchema: Type.String({ minLength: 1, maxLength: 32 }),
|
|
75
|
+
normalizeInput: (value) => normalizeText(value),
|
|
76
|
+
normalizeOutput: (value) => normalizeText(value) || DEFAULT_USER_SETTINGS.numberFormat,
|
|
77
|
+
resolveDefault: () => DEFAULT_USER_SETTINGS.numberFormat
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
defineField({
|
|
81
|
+
key: "currencyCode",
|
|
82
|
+
section: USER_SETTINGS_SECTIONS.PREFERENCES,
|
|
83
|
+
dbColumn: "currency_code",
|
|
84
|
+
required: true,
|
|
85
|
+
inputSchema: Type.String({ minLength: 3, maxLength: 3, pattern: "^[A-Za-z]{3}$" }),
|
|
86
|
+
outputSchema: Type.String({ minLength: 3, maxLength: 3, pattern: "^[A-Z]{3}$" }),
|
|
87
|
+
normalizeInput: (value) => normalizeText(value).toUpperCase(),
|
|
88
|
+
normalizeOutput: (value) => normalizeText(value).toUpperCase() || DEFAULT_USER_SETTINGS.currencyCode,
|
|
89
|
+
resolveDefault: () => DEFAULT_USER_SETTINGS.currencyCode
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
defineField({
|
|
93
|
+
key: "avatarSize",
|
|
94
|
+
section: USER_SETTINGS_SECTIONS.PREFERENCES,
|
|
95
|
+
dbColumn: "avatar_size",
|
|
96
|
+
required: true,
|
|
97
|
+
inputSchema: Type.Integer({ minimum: 1 }),
|
|
98
|
+
outputSchema: Type.Integer({ minimum: 1 }),
|
|
99
|
+
normalizeInput: (value) => Number(value),
|
|
100
|
+
normalizeOutput: (value) => normalizePositiveInteger(value, DEFAULT_USER_SETTINGS.avatarSize),
|
|
101
|
+
resolveDefault: () => DEFAULT_USER_SETTINGS.avatarSize
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
defineField({
|
|
105
|
+
key: "productUpdates",
|
|
106
|
+
section: USER_SETTINGS_SECTIONS.NOTIFICATIONS,
|
|
107
|
+
dbColumn: "notify_product_updates",
|
|
108
|
+
required: true,
|
|
109
|
+
inputSchema: Type.Boolean(),
|
|
110
|
+
outputSchema: Type.Boolean(),
|
|
111
|
+
normalizeInput: (value) => value,
|
|
112
|
+
normalizeOutput: (value) => Boolean(value),
|
|
113
|
+
resolveDefault: () => DEFAULT_USER_SETTINGS.productUpdates
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
defineField({
|
|
117
|
+
key: "accountActivity",
|
|
118
|
+
section: USER_SETTINGS_SECTIONS.NOTIFICATIONS,
|
|
119
|
+
dbColumn: "notify_account_activity",
|
|
120
|
+
required: true,
|
|
121
|
+
inputSchema: Type.Boolean(),
|
|
122
|
+
outputSchema: Type.Boolean(),
|
|
123
|
+
normalizeInput: (value) => value,
|
|
124
|
+
normalizeOutput: (value) => Boolean(value),
|
|
125
|
+
resolveDefault: () => DEFAULT_USER_SETTINGS.accountActivity
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
defineField({
|
|
129
|
+
key: "securityAlerts",
|
|
130
|
+
section: USER_SETTINGS_SECTIONS.NOTIFICATIONS,
|
|
131
|
+
dbColumn: "notify_security_alerts",
|
|
132
|
+
required: true,
|
|
133
|
+
inputSchema: Type.Boolean(),
|
|
134
|
+
outputSchema: Type.Boolean(),
|
|
135
|
+
normalizeInput: (value) => value,
|
|
136
|
+
normalizeOutput: (value) => Boolean(value),
|
|
137
|
+
resolveDefault: () => DEFAULT_USER_SETTINGS.securityAlerts
|
|
138
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// @jskit-contract users.settings-fields.workspace.v1
|
|
2
|
+
// Append-only settings field registrations for workspace settings.
|
|
3
|
+
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import { normalizeText } from "@jskit-ai/kernel/shared/actions/textNormalization";
|
|
6
|
+
import { coerceWorkspaceColor } from "@jskit-ai/users-core/shared/settings";
|
|
7
|
+
import {
|
|
8
|
+
defineField,
|
|
9
|
+
resetWorkspaceSettingsFields
|
|
10
|
+
} from "@jskit-ai/users-core/shared/resources/workspaceSettingsFields";
|
|
11
|
+
|
|
12
|
+
function normalizeAvatarUrl(value) {
|
|
13
|
+
const avatarUrl = normalizeText(value);
|
|
14
|
+
if (!avatarUrl) {
|
|
15
|
+
return "";
|
|
16
|
+
}
|
|
17
|
+
if (!avatarUrl.startsWith("http://") && !avatarUrl.startsWith("https://")) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
return new URL(avatarUrl).toString();
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function normalizeHexColor(value) {
|
|
28
|
+
const color = normalizeText(value);
|
|
29
|
+
return /^#[0-9A-Fa-f]{6}$/.test(color) ? color.toUpperCase() : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
resetWorkspaceSettingsFields();
|
|
33
|
+
|
|
34
|
+
defineField({
|
|
35
|
+
key: "name",
|
|
36
|
+
dbColumn: "name",
|
|
37
|
+
required: true,
|
|
38
|
+
inputSchema: Type.String({
|
|
39
|
+
minLength: 1,
|
|
40
|
+
maxLength: 160,
|
|
41
|
+
messages: {
|
|
42
|
+
required: "Workspace name is required.",
|
|
43
|
+
minLength: "Workspace name is required.",
|
|
44
|
+
maxLength: "Workspace name must be at most 160 characters.",
|
|
45
|
+
default: "Workspace name is required."
|
|
46
|
+
}
|
|
47
|
+
}),
|
|
48
|
+
outputSchema: Type.String({ minLength: 1, maxLength: 160 }),
|
|
49
|
+
normalizeInput: (value) => normalizeText(value),
|
|
50
|
+
normalizeOutput: (value) => normalizeText(value),
|
|
51
|
+
resolveDefault: ({ workspace = {} } = {}) => normalizeText(workspace.name) || "Workspace"
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
defineField({
|
|
55
|
+
key: "avatarUrl",
|
|
56
|
+
dbColumn: "avatar_url",
|
|
57
|
+
required: false,
|
|
58
|
+
inputSchema: Type.String({
|
|
59
|
+
pattern: "^(https?://.+)?$",
|
|
60
|
+
messages: {
|
|
61
|
+
pattern: "Workspace avatar URL must be a valid absolute URL (http:// or https://).",
|
|
62
|
+
default: "Workspace avatar URL must be a valid absolute URL (http:// or https://)."
|
|
63
|
+
}
|
|
64
|
+
}),
|
|
65
|
+
outputSchema: Type.String(),
|
|
66
|
+
normalizeInput: normalizeAvatarUrl,
|
|
67
|
+
normalizeOutput: (value) => normalizeText(value),
|
|
68
|
+
resolveDefault: ({ workspace = {} } = {}) => normalizeText(workspace.avatarUrl)
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
defineField({
|
|
72
|
+
key: "color",
|
|
73
|
+
dbColumn: "color",
|
|
74
|
+
required: true,
|
|
75
|
+
inputSchema: Type.String({
|
|
76
|
+
minLength: 7,
|
|
77
|
+
maxLength: 7,
|
|
78
|
+
pattern: "^#[0-9A-Fa-f]{6}$",
|
|
79
|
+
messages: {
|
|
80
|
+
required: "Workspace color is required.",
|
|
81
|
+
pattern: "Workspace color must be a hex color like #0F6B54.",
|
|
82
|
+
default: "Workspace color must be a hex color like #0F6B54."
|
|
83
|
+
}
|
|
84
|
+
}),
|
|
85
|
+
outputSchema: Type.String({ minLength: 7, maxLength: 7, pattern: "^#[0-9A-Fa-f]{6}$" }),
|
|
86
|
+
normalizeInput: normalizeHexColor,
|
|
87
|
+
normalizeOutput: (value) => coerceWorkspaceColor(value),
|
|
88
|
+
resolveDefault: ({ workspace = {} } = {}) => coerceWorkspaceColor(workspace.color)
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
defineField({
|
|
92
|
+
key: "invitesEnabled",
|
|
93
|
+
dbColumn: "invites_enabled",
|
|
94
|
+
required: true,
|
|
95
|
+
inputSchema: Type.Boolean({
|
|
96
|
+
messages: {
|
|
97
|
+
required: "invitesEnabled is required.",
|
|
98
|
+
default: "invitesEnabled must be a boolean."
|
|
99
|
+
}
|
|
100
|
+
}),
|
|
101
|
+
outputSchema: Type.Boolean(),
|
|
102
|
+
normalizeInput: (value) => value === true,
|
|
103
|
+
normalizeOutput: (value) => value !== false,
|
|
104
|
+
resolveDefault: ({ defaultInvitesEnabled } = {}) => defaultInvitesEnabled !== false
|
|
105
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createService } from "../src/server/common/services/authProfileSyncService.js";
|
|
4
|
+
|
|
5
|
+
test("authProfileSyncService.syncIdentityProfile uses shared transaction for profile upsert and provisioning", async () => {
|
|
6
|
+
const calls = [];
|
|
7
|
+
const transaction = { trxId: "tx-1" };
|
|
8
|
+
|
|
9
|
+
const service = createService({
|
|
10
|
+
userProfilesRepository: {
|
|
11
|
+
async findByIdentity(_identity, options = {}) {
|
|
12
|
+
calls.push({ step: "find", trx: options.trx || null });
|
|
13
|
+
return null;
|
|
14
|
+
},
|
|
15
|
+
async upsert(payload, options = {}) {
|
|
16
|
+
calls.push({ step: "upsert", trx: options.trx || null });
|
|
17
|
+
return {
|
|
18
|
+
id: 13,
|
|
19
|
+
authProvider: payload.authProvider,
|
|
20
|
+
authProviderUserId: payload.authProviderUserId,
|
|
21
|
+
email: payload.email,
|
|
22
|
+
displayName: payload.displayName
|
|
23
|
+
};
|
|
24
|
+
},
|
|
25
|
+
async withTransaction(work) {
|
|
26
|
+
calls.push({ step: "withTransaction", trx: transaction });
|
|
27
|
+
return work(transaction);
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
workspaceProvisioningService: {
|
|
31
|
+
async provisionWorkspaceForNewUser(_profile, options = {}) {
|
|
32
|
+
calls.push({ step: "provision", trx: options.trx || null });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const profile = await service.syncIdentityProfile({
|
|
38
|
+
authProvider: "supabase",
|
|
39
|
+
authProviderUserId: "abc-1",
|
|
40
|
+
email: "tony@example.com",
|
|
41
|
+
displayName: "Tony"
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
assert.equal(Number(profile.id), 13);
|
|
45
|
+
assert.equal(calls[0].step, "withTransaction");
|
|
46
|
+
assert.equal(calls[1].step, "find");
|
|
47
|
+
assert.equal(calls[2].step, "upsert");
|
|
48
|
+
assert.equal(calls[3].step, "provision");
|
|
49
|
+
assert.equal(calls[1].trx, transaction);
|
|
50
|
+
assert.equal(calls[2].trx, transaction);
|
|
51
|
+
assert.equal(calls[3].trx, transaction);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("authProfileSyncService.syncIdentityProfile skips write path when profile is unchanged", async () => {
|
|
55
|
+
let upsertCalls = 0;
|
|
56
|
+
let provisionCalls = 0;
|
|
57
|
+
|
|
58
|
+
const service = createService({
|
|
59
|
+
userProfilesRepository: {
|
|
60
|
+
async findByIdentity() {
|
|
61
|
+
return {
|
|
62
|
+
id: 7,
|
|
63
|
+
authProvider: "supabase",
|
|
64
|
+
authProviderUserId: "abc-7",
|
|
65
|
+
email: "tony@example.com",
|
|
66
|
+
displayName: "Tony"
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
async upsert() {
|
|
70
|
+
upsertCalls += 1;
|
|
71
|
+
return null;
|
|
72
|
+
},
|
|
73
|
+
async withTransaction(work) {
|
|
74
|
+
return work({ trxId: "tx-2" });
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
workspaceProvisioningService: {
|
|
78
|
+
async provisionWorkspaceForNewUser() {
|
|
79
|
+
provisionCalls += 1;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const profile = await service.syncIdentityProfile({
|
|
85
|
+
authProvider: "supabase",
|
|
86
|
+
authProviderUserId: "abc-7",
|
|
87
|
+
email: "tony@example.com",
|
|
88
|
+
displayName: "Tony"
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
assert.equal(Number(profile.id), 7);
|
|
92
|
+
assert.equal(upsertCalls, 0);
|
|
93
|
+
assert.equal(provisionCalls, 0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("authProfileSyncService.findByIdentity normalizes provider identity input", async () => {
|
|
97
|
+
let capturedIdentity = null;
|
|
98
|
+
const service = createService({
|
|
99
|
+
userProfilesRepository: {
|
|
100
|
+
async findByIdentity(identity) {
|
|
101
|
+
capturedIdentity = identity;
|
|
102
|
+
return null;
|
|
103
|
+
},
|
|
104
|
+
async upsert() {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await service.findByIdentity({
|
|
111
|
+
authProvider: " SUPABASE ",
|
|
112
|
+
authProviderUserId: " user-1 "
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
assert.deepEqual(capturedIdentity, {
|
|
116
|
+
provider: "supabase",
|
|
117
|
+
providerUserId: "user-1"
|
|
118
|
+
});
|
|
119
|
+
});
|