diff --git a/apps/admin/src/helpers/index.ts b/apps/admin/src/helpers/index.ts deleted file mode 100644 index 879bbb65..00000000 --- a/apps/admin/src/helpers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './gen-random-password'; diff --git a/apps/admin/src/modules/users/form/create/fields/user-create-auth-methods-form-field.tsx b/apps/admin/src/modules/users/form/create/fields/user-create-auth-methods-form-field.tsx index b167e313..f2876fbb 100644 --- a/apps/admin/src/modules/users/form/create/fields/user-create-auth-methods-form-field.tsx +++ b/apps/admin/src/modules/users/form/create/fields/user-create-auth-methods-form-field.tsx @@ -6,8 +6,8 @@ import { import type { SdkCreateUserAuthMethodsT } from '@llm/sdk'; +import { genRandomPassword } from '@llm/commons'; import { Checkbox, FormField, Input } from '@llm/ui'; -import { genRandomPassword } from '~/helpers'; import { useI18n } from '~/i18n'; type Props = ValidationErrorsListProps; diff --git a/apps/admin/src/modules/users/form/update/fields/index.ts b/apps/admin/src/modules/users/form/update/fields/index.ts index e4de1b74..4244f6db 100644 --- a/apps/admin/src/modules/users/form/update/fields/index.ts +++ b/apps/admin/src/modules/users/form/update/fields/index.ts @@ -1,2 +1,2 @@ -export * from './user-organization-info-form-field'; +export * from './user-organization-settings-form-field'; export * from './user-update-auth-methods-form-field'; diff --git a/apps/admin/src/modules/users/form/update/fields/user-organization-info-form-field.tsx b/apps/admin/src/modules/users/form/update/fields/user-organization-info-form-field.tsx deleted file mode 100644 index 1e0edf27..00000000 --- a/apps/admin/src/modules/users/form/update/fields/user-organization-info-form-field.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import type { SdkExtractUserT } from '@llm/sdk'; - -import { FormField } from '@llm/ui'; -import { useI18n } from '~/i18n'; -import { OrganizationsSearchSelect, UserOrganizationRoleSelect } from '~/modules/organizations'; - -type Props = { - user: SdkExtractUserT<'user'>; -}; - -export function UserOrganizationInfoField({ user }: Props) { - const t = useI18n().pack.modules.users.form.fields.organization; - - return ( - <> - - - - - - - - -
- - ); -} diff --git a/apps/admin/src/modules/users/form/update/fields/user-organization-settings-form-field.tsx b/apps/admin/src/modules/users/form/update/fields/user-organization-settings-form-field.tsx new file mode 100644 index 00000000..3e8c66de --- /dev/null +++ b/apps/admin/src/modules/users/form/update/fields/user-organization-settings-form-field.tsx @@ -0,0 +1,50 @@ +import { + controlled, + useFormValidatorMessages, + type ValidationErrorsListProps, +} from '@under-control/forms'; + +import type { SdkTableRowWithIdNameT, SdkUpdateUserOrganizationInputT } from '@llm/sdk'; + +import { FormField } from '@llm/ui'; +import { useI18n } from '~/i18n'; +import { OrganizationsSearchSelect, UserOrganizationRoleSelect } from '~/modules/organizations'; + +type Props = ValidationErrorsListProps & { + organization: SdkTableRowWithIdNameT; +}; + +export const UserOrganizationSettingsFormField = controlled(( + { + organization, + errors, + control: { bind }, + }, +) => { + const t = useI18n().pack.modules.users.form.fields.organization; + const validation = useFormValidatorMessages({ errors }); + + return ( + <> + + + + + + + + +
+ + ); +}); diff --git a/apps/admin/src/modules/users/form/update/fields/user-update-auth-methods-form-field.tsx b/apps/admin/src/modules/users/form/update/fields/user-update-auth-methods-form-field.tsx index 11586142..69d3df2c 100644 --- a/apps/admin/src/modules/users/form/update/fields/user-update-auth-methods-form-field.tsx +++ b/apps/admin/src/modules/users/form/update/fields/user-update-auth-methods-form-field.tsx @@ -7,8 +7,8 @@ import { useMemo } from 'react'; import type { SdkUpdateUserAuthMethodsT } from '@llm/sdk'; +import { genRandomPassword } from '@llm/commons'; import { Checkbox, FormField, Input } from '@llm/ui'; -import { genRandomPassword } from '~/helpers'; import { useI18n } from '~/i18n'; type Props = ValidationErrorsListProps; diff --git a/apps/admin/src/modules/users/form/update/types.ts b/apps/admin/src/modules/users/form/update/types.ts new file mode 100644 index 00000000..50222338 --- /dev/null +++ b/apps/admin/src/modules/users/form/update/types.ts @@ -0,0 +1,8 @@ +import type { + SdkTableRowWithIdT, + SdkUpdateUserInputT, +} from '@llm/sdk'; + +export type UpdateUserFormValue = + SdkTableRowWithIdT & + SdkUpdateUserInputT; diff --git a/apps/admin/src/modules/users/form/update/use-user-update-form.tsx b/apps/admin/src/modules/users/form/update/use-user-update-form.tsx index 8155649e..98cd2e40 100644 --- a/apps/admin/src/modules/users/form/update/use-user-update-form.tsx +++ b/apps/admin/src/modules/users/form/update/use-user-update-form.tsx @@ -2,14 +2,16 @@ import { type FormHookAttrs, useForm } from '@under-control/forms'; import { flow } from 'fp-ts/lib/function'; import { runTask, tapTaskEither } from '@llm/commons'; -import { type SdkTableRowWithIdT, type SdkUpdateUserInputT, useSdkForLoggedIn } from '@llm/sdk'; +import { useSdkForLoggedIn } from '@llm/sdk'; import { usePredefinedFormValidators, useSaveTaskEitherNotification } from '@llm/ui'; +import type { UpdateUserFormValue } from './types'; + import { useUseAuthFormValidator } from '../shared'; type UpdateUserFormHookAttrs = & Omit< - FormHookAttrs, + FormHookAttrs, 'validation' | 'onSubmit' > & { @@ -23,9 +25,9 @@ export function useUserUpdateForm( }: UpdateUserFormHookAttrs, ) { const { sdks } = useSdkForLoggedIn(); - const { emailFormatValidator } = usePredefinedFormValidators(); + const { emailFormatValidator } = usePredefinedFormValidators(); const saveNotifications = useSaveTaskEitherNotification(); - const authValidator = useUseAuthFormValidator(); + const authValidator = useUseAuthFormValidator(); return useForm({ resetAfterSubmit: false, diff --git a/apps/admin/src/modules/users/form/update/user-update-form-modal.tsx b/apps/admin/src/modules/users/form/update/user-update-form-modal.tsx index 2ff321ec..52090f4f 100644 --- a/apps/admin/src/modules/users/form/update/user-update-form-modal.tsx +++ b/apps/admin/src/modules/users/form/update/user-update-form-modal.tsx @@ -1,3 +1,5 @@ +import { useMemo } from 'react'; + import type { SdkUserT } from '@llm/sdk'; import { @@ -10,8 +12,10 @@ import { } from '@llm/ui'; import { useI18n } from '~/i18n'; +import type { UpdateUserFormValue } from './types'; + import { UserSharedFormFields } from '../shared'; -import { UserOrganizationInfoField, UserUpdateAuthMethodsFormField } from './fields'; +import { UserOrganizationSettingsFormField, UserUpdateAuthMethodsFormField } from './fields'; import { useUserUpdateForm } from './use-user-update-form'; export type UserUpdateFormModalProps = @@ -30,14 +34,32 @@ export function UserUpdateFormModal( }: UserUpdateFormModalProps, ) { const t = useI18n().pack.modules.users.form; - const { handleSubmitEvent, validator, submitState, bind } = useUserUpdateForm({ - defaultValue: { + + const defaultValue = useMemo(() => { + const attrs = { id: user.id, active: user.active, archiveProtection: user.archiveProtection, auth: user.auth, email: user.email, - }, + }; + + return ( + user.role === 'user' + ? { + ...attrs, + role: 'user', + organization: user.organization, + } + : { + ...attrs, + role: 'root', + } + ); + }, [user]); + + const { handleSubmitEvent, validator, submitState, bind } = useUserUpdateForm({ + defaultValue, onAfterSubmit, }); @@ -61,9 +83,12 @@ export function UserUpdateFormModal( )} > {user.role === 'user' && ( - + )} - ): Promise { + await db.schema + .createTable('users_groups') + .$call(addIdColumn) + .$call(addTimestampColumns) + .$call(addArchivedAtColumns) + .addColumn('organization_id', 'integer', col => col.notNull().references('organizations.id').onDelete('restrict')) + .addColumn('creator_user_id', 'integer', col => col.notNull().references('users.id').onDelete('restrict')) + .addColumn('name', 'text', col => col.notNull()) + .execute(); + + await db.schema + .createTable('users_groups_users') + .addColumn('user_id', 'integer', col => col.notNull().references('users.id').onDelete('cascade')) + .addColumn('group_id', 'integer', col => col.notNull().references('users_groups.id').onDelete('cascade')) + .addUniqueConstraint('user_id_group_id_unique', ['user_id', 'group_id']) + .execute(); + + await db.schema + .createIndex('users_groups_organization_id_index') + .on('users_groups') + .column('organization_id') + .execute(); + + await sql` + CREATE OR REPLACE FUNCTION users_groups_users_check_organization() RETURNS TRIGGER AS $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM organizations_users ou + JOIN users_groups ug ON ug.organization_id = ou.organization_id + WHERE ou.user_id = NEW.user_id + AND ug.id = NEW.group_id + ) THEN + RAISE EXCEPTION 'User must belong to the same organization as the group'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + + CREATE TRIGGER users_groups_users_organization_check + BEFORE INSERT OR UPDATE ON users_groups_users + FOR EACH ROW + EXECUTE FUNCTION users_groups_users_check_organization(); + `.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql` + DROP TRIGGER IF EXISTS users_groups_users_organization_check ON users_groups_users; + DROP FUNCTION IF EXISTS users_groups_users_check_organization(); + `.execute(db); + + await db.schema.dropTable('users_groups_users').execute(); + await db.schema.dropTable('users_groups').execute(); +} diff --git a/apps/backend/src/migrations/0027-add-projects-policies.ts b/apps/backend/src/migrations/0027-add-projects-policies.ts new file mode 100644 index 00000000..27f98975 --- /dev/null +++ b/apps/backend/src/migrations/0027-add-projects-policies.ts @@ -0,0 +1,106 @@ +import { type Kysely, sql } from 'kysely'; + +import { addArchivedAtColumns, addIdColumn, addTimestampColumns } from './utils'; + +export async function up(db: Kysely): Promise { + // Create projects_policies table + await db.schema + .createType('project_access_level') + .asEnum(['read', 'write', 'admin']) + .execute(); + + await db.schema + .createTable('projects_policies') + .$call(addIdColumn) + .$call(addTimestampColumns) + .$call(addArchivedAtColumns) + .addColumn('project_id', 'integer', col => col.notNull().references('projects.id').onDelete('restrict')) + .addColumn('user_id', 'integer', col => col.references('users.id').onDelete('restrict')) + .addColumn('group_id', 'integer', col => col.references('users_groups.id').onDelete('restrict')) + .addColumn('level', sql`project_access_level`, col => col.notNull()) + .addCheckConstraint( + 'user_xor_group_check', + sql`COALESCE((user_id IS NOT NULL)::int, 0) + COALESCE((group_id IS NOT NULL)::int, 0) = 1`, + ) + .addUniqueConstraint('project_id_user_id_group_id_unique', ['project_id', 'user_id', 'group_id']) + .execute(); + + // Create validation triggers + await sql` + CREATE OR REPLACE FUNCTION projects_policies_check_user_organization() RETURNS TRIGGER AS $$ + BEGIN + IF NEW.user_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 + FROM organizations_users ou + JOIN projects p ON p.id = NEW.project_id + WHERE ou.user_id = NEW.user_id + AND ou.organization_id = p.organization_id + ) THEN + RAISE EXCEPTION 'User must belong to the project organization'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + + CREATE TRIGGER projects_policies_user_organization_check + BEFORE INSERT OR UPDATE ON projects_policies + FOR EACH ROW + EXECUTE FUNCTION projects_policies_check_user_organization(); + + CREATE OR REPLACE FUNCTION projects_policies_check_group_organization() RETURNS TRIGGER AS $$ + BEGIN + IF NEW.group_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 + FROM users_groups ug + JOIN projects p ON p.id = NEW.project_id + WHERE ug.id = NEW.group_id + AND ug.organization_id = p.organization_id + ) THEN + RAISE EXCEPTION 'Group must belong to the project organization'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + + CREATE TRIGGER projects_policies_group_organization_check + BEFORE INSERT OR UPDATE ON projects_policies + FOR EACH ROW + EXECUTE FUNCTION projects_policies_check_group_organization(); + `.execute(db); + + // Alter projects table to add creator_user_id column + await db.schema + .alterTable('projects') + .addColumn('creator_user_id', 'integer', col => col.references('users.id').onDelete('restrict')) + .execute(); + + const rootUser = await db + .selectFrom('users') + .select(['id']) + .where('role', '=', 'root') + .executeTakeFirstOrThrow(); + + await db + .updateTable('projects') + .set({ creator_user_id: rootUser.id }) + .execute(); + + await db.schema + .alterTable('projects') + .alterColumn('creator_user_id', col => col.setNotNull()) + .execute(); +} + +export async function down(db: Kysely): Promise { + await sql` + DROP TRIGGER IF EXISTS projects_policies_user_organization_check ON projects_policies; + DROP FUNCTION IF EXISTS projects_policies_check_user_organization(); + + DROP TRIGGER IF EXISTS projects_policies_group_organization_check ON projects_policies; + DROP FUNCTION IF EXISTS projects_policies_check_group_organization(); + `.execute(db); + + await db.schema.alterTable('projects').dropColumn('creator_user_id').execute(); + await db.schema.dropTable('projects_policies').execute(); + await db.schema.dropType('project_access_level').execute(); +} diff --git a/apps/backend/src/migrations/index.ts b/apps/backend/src/migrations/index.ts index c2071745..a9424d45 100644 --- a/apps/backend/src/migrations/index.ts +++ b/apps/backend/src/migrations/index.ts @@ -24,6 +24,8 @@ import * as addIdToProjectFilesTable from './0022-add-id-to-project-files-table' import * as addProjectsEmbeddingsTable from './0023-add-projects-embeddings-table'; import * as dropUnusedImagesTable from './0024-drop-unused-images-table'; import * as addInternalProjectsFields from './0025-add-internal-projects-fields'; +import * as addUsersGroupTable from './0026-add-users-groups-table'; +import * as addProjectsPoliciesTable from './0027-add-projects-policies'; export const DB_MIGRATIONS = { '0000-add-users-tables': addUsersTables, @@ -52,4 +54,6 @@ export const DB_MIGRATIONS = { '0023-add-projects-embeddings-table': addProjectsEmbeddingsTable, '0024-drop-unused-images-table': dropUnusedImagesTable, '0025-add-internal-projects-fields': addInternalProjectsFields, + '0026-add-users-groups-table': addUsersGroupTable, + '0027-add-projects-policies': addProjectsPoliciesTable, }; diff --git a/apps/backend/src/modules/api/controllers/dashboard/dashboard.controller.ts b/apps/backend/src/modules/api/controllers/dashboard/dashboard.controller.ts index 1562b7e5..3bb62ea7 100644 --- a/apps/backend/src/modules/api/controllers/dashboard/dashboard.controller.ts +++ b/apps/backend/src/modules/api/controllers/dashboard/dashboard.controller.ts @@ -8,6 +8,7 @@ import { ChatsController } from './chats.controller'; import { OrganizationsController } from './organizations.controller'; import { ProjectsController } from './projects.controller'; import { S3BucketsController } from './s3-buckets.controller'; +import { UsersGroupsController } from './users-groups.controller'; import { UsersController } from './users.controller'; @injectable() @@ -15,6 +16,7 @@ export class DashboardController extends BaseController { constructor( @inject(OrganizationsController) organizations: OrganizationsController, @inject(UsersController) users: UsersController, + @inject(UsersGroupsController) usersGroups: UsersGroupsController, @inject(ProjectsController) projects: ProjectsController, @inject(AppsController) apps: AppsController, @inject(AppsCategoriesController) appsCategories: AppsCategoriesController, @@ -26,6 +28,7 @@ export class DashboardController extends BaseController { this.router .route('/organizations', organizations.router) + .route('/users/groups', usersGroups.router) .route('/users', users.router) .route('/projects', projects.router) .route('/apps/categories', appsCategories.router) diff --git a/apps/backend/src/modules/api/controllers/dashboard/index.ts b/apps/backend/src/modules/api/controllers/dashboard/index.ts index cfae65db..04e98890 100644 --- a/apps/backend/src/modules/api/controllers/dashboard/index.ts +++ b/apps/backend/src/modules/api/controllers/dashboard/index.ts @@ -1,7 +1,10 @@ +export * from './ai-models.controller'; export * from './apps.controller'; +export * from './apps-categories.controller'; export * from './chats.controller'; export * from './dashboard.controller'; export * from './organizations.controller'; export * from './projects.controller'; export * from './s3-buckets.controller'; export * from './users.controller'; +export * from './users-groups.controller'; diff --git a/apps/backend/src/modules/api/controllers/dashboard/users-groups.controller.ts b/apps/backend/src/modules/api/controllers/dashboard/users-groups.controller.ts new file mode 100644 index 00000000..a7729c75 --- /dev/null +++ b/apps/backend/src/modules/api/controllers/dashboard/users-groups.controller.ts @@ -0,0 +1,90 @@ +import { pipe } from 'fp-ts/lib/function'; +import { inject, injectable } from 'tsyringe'; + +import { + SdkCreateUsersGroupInputV, + SdkSearchUsersGroupsInputV, + SdkUpdateUsersGroupInputV, + type UsersGroupsSdk, +} from '@llm/sdk'; +import { ConfigService } from '~/modules/config'; +import { UsersGroupsService } from '~/modules/users-groups'; + +import { + mapDbRecordAlreadyExistsToSdkError, + mapDbRecordNotFoundToSdkError, + rejectUnsafeSdkErrors, + sdkSchemaValidator, + serializeSdkResponseTE, +} from '../../helpers'; +import { AuthorizedController } from '../shared/authorized.controller'; + +@injectable() +export class UsersGroupsController extends AuthorizedController { + constructor( + @inject(ConfigService) configService: ConfigService, + @inject(UsersGroupsService) groupsService: UsersGroupsService, + ) { + super(configService); + + this.router + .get( + '/search', + sdkSchemaValidator('query', SdkSearchUsersGroupsInputV), + async context => pipe( + context.req.valid('query'), + groupsService.asUser(context.var.jwt).search, + rejectUnsafeSdkErrors, + serializeSdkResponseTE>(context), + ), + ) + .patch( + '/archive/:id', + async context => pipe( + Number(context.req.param().id), + groupsService.asUser(context.var.jwt).archive, + mapDbRecordNotFoundToSdkError, + mapDbRecordAlreadyExistsToSdkError, + rejectUnsafeSdkErrors, + serializeSdkResponseTE>(context), + ), + ) + .patch( + '/unarchive/:id', + async context => pipe( + Number(context.req.param().id), + groupsService.asUser(context.var.jwt).unarchive, + mapDbRecordNotFoundToSdkError, + mapDbRecordAlreadyExistsToSdkError, + rejectUnsafeSdkErrors, + serializeSdkResponseTE>(context), + ), + ) + .post( + '/', + sdkSchemaValidator('json', SdkCreateUsersGroupInputV), + async context => pipe( + context.req.valid('json'), + groupsService.asUser(context.var.jwt).create, + mapDbRecordAlreadyExistsToSdkError, + rejectUnsafeSdkErrors, + serializeSdkResponseTE>(context), + ), + ) + .put( + '/:id', + sdkSchemaValidator('json', SdkUpdateUsersGroupInputV), + async context => pipe( + { + id: Number(context.req.param().id), + ...context.req.valid('json'), + }, + groupsService.asUser(context.var.jwt).update, + mapDbRecordAlreadyExistsToSdkError, + mapDbRecordNotFoundToSdkError, + rejectUnsafeSdkErrors, + serializeSdkResponseTE>(context), + ), + ); + } +} diff --git a/apps/backend/src/modules/api/controllers/dashboard/users.controller.ts b/apps/backend/src/modules/api/controllers/dashboard/users.controller.ts index bf083096..d04f8daa 100644 --- a/apps/backend/src/modules/api/controllers/dashboard/users.controller.ts +++ b/apps/backend/src/modules/api/controllers/dashboard/users.controller.ts @@ -3,7 +3,7 @@ import { inject, injectable } from 'tsyringe'; import { SdkCreateUserInputV, - SdKSearchUsersInputV, + SdkSearchUsersInputV, SdkUpdateUserInputV, type UsersSdk, } from '@llm/sdk'; @@ -30,7 +30,7 @@ export class UsersController extends AuthorizedController { this.router .get( '/search', - sdkSchemaValidator('query', SdKSearchUsersInputV), + sdkSchemaValidator('query', SdkSearchUsersInputV), async context => pipe( context.req.valid('query'), usersService.asUser(context.var.jwt).search, diff --git a/apps/backend/src/modules/auth/repo/auth.repo.ts b/apps/backend/src/modules/auth/repo/auth.repo.ts index 808b3933..f9f2977e 100644 --- a/apps/backend/src/modules/auth/repo/auth.repo.ts +++ b/apps/backend/src/modules/auth/repo/auth.repo.ts @@ -49,7 +49,7 @@ export class AuthRepo { }, ), TE.chainW(() => { - if (!email) { + if (!email.enabled) { return TE.of(undefined); } diff --git a/apps/backend/src/modules/database/database.tables.ts b/apps/backend/src/modules/database/database.tables.ts index 30e8e6aa..c2241fe4 100644 --- a/apps/backend/src/modules/database/database.tables.ts +++ b/apps/backend/src/modules/database/database.tables.ts @@ -23,11 +23,13 @@ import type { } from '../projects'; import type { ProjectsEmbeddingsTable } from '../projects-embeddings'; import type { ProjectsFilesTable } from '../projects-files'; +import type { ProjectsPoliciesTable } from '../projects-policies'; import type { S3ResourcesBucketsTable, S3ResourcesTable, } from '../s3'; import type { UsersTable } from '../users'; +import type { UsersGroupsTable, UsersGroupsUsersTable } from '../users-groups'; import type { TableWithArchivedAtColumn, TableWithIdColumn, @@ -41,6 +43,8 @@ export type DatabaseTables = { // Users users: UsersTable; + users_groups: UsersGroupsTable; + users_groups_users: UsersGroupsUsersTable; // Auth auth_emails: AuthEmailsTable; @@ -56,6 +60,7 @@ export type DatabaseTables = { projects: ProjectsTable; projects_files: ProjectsFilesTable; projects_embeddings: ProjectsEmbeddingsTable; + projects_policies: ProjectsPoliciesTable; // Apps apps: AppsTable; diff --git a/apps/backend/src/modules/elasticsearch/boot/elasticsearch-registry.boot.ts b/apps/backend/src/modules/elasticsearch/boot/elasticsearch-registry.boot.ts index e0b6cf59..2890c516 100644 --- a/apps/backend/src/modules/elasticsearch/boot/elasticsearch-registry.boot.ts +++ b/apps/backend/src/modules/elasticsearch/boot/elasticsearch-registry.boot.ts @@ -13,7 +13,9 @@ import { OrganizationsEsIndexRepo } from '~/modules/organizations/elasticsearch' import { OrganizationsS3BucketsEsIndexRepo } from '~/modules/organizations/s3-buckets/elasticsearch/organizations-s3-buckets-es-index.repo'; import { ProjectsEmbeddingsEsIndexRepo } from '~/modules/projects-embeddings/elasticsearch/projects-embeddings-es-index.repo'; import { ProjectsFilesEsIndexRepo } from '~/modules/projects-files/elasticsearch/projects-files-es-index.repo'; +import { ProjectsPoliciesEsIndexRepo } from '~/modules/projects-policies/elasticsearch/projects-policies-es-index.repo'; import { ProjectsEsIndexRepo } from '~/modules/projects/elasticsearch/projects-es-index.repo'; +import { UsersGroupsEsIndexRepo } from '~/modules/users-groups/elasticsearch/users-groups-es-index.repo'; import { UsersEsIndexRepo } from '~/modules/users/elasticsearch/users-es-index.repo'; import { ElasticsearchIndicesRegistryRepo } from '../repo/elasticsearch-indices-registry.repo'; @@ -25,6 +27,7 @@ export class ElasticsearchRegistryBootService { constructor( @inject(ElasticsearchIndicesRegistryRepo) private readonly indicesRegistryRepo: ElasticsearchIndicesRegistryRepo, @inject(UsersEsIndexRepo) private readonly usersEsIndexRepo: UsersEsIndexRepo, + @inject(UsersGroupsEsIndexRepo) private readonly usersGroupsEsIndexRepo: UsersGroupsEsIndexRepo, @inject(OrganizationsEsIndexRepo) private readonly organizationsEsIndexRepo: OrganizationsEsIndexRepo, @inject(ProjectsEsIndexRepo) private readonly projectsEsIndexRepo: ProjectsEsIndexRepo, @inject(AppsEsIndexRepo) private readonly appsEsIndexRepo: AppsEsIndexRepo, @@ -35,11 +38,13 @@ export class ElasticsearchRegistryBootService { @inject(AppsCategoriesEsIndexRepo) private readonly appsCategoriesEsIndexRepo: AppsCategoriesEsIndexRepo, @inject(ProjectsFilesEsIndexRepo) private readonly projectsFilesEsIndexRepo: ProjectsFilesEsIndexRepo, @inject(ProjectsEmbeddingsEsIndexRepo) private readonly projectsEmbeddingsEsIndexRepo: ProjectsEmbeddingsEsIndexRepo, + @inject(ProjectsPoliciesEsIndexRepo) private readonly projectsPoliciesEsIndexRepo: ProjectsPoliciesEsIndexRepo, ) {} register = TE.fromIO(() => { this.indicesRegistryRepo.registerIndexRepos([ this.usersEsIndexRepo, + this.usersGroupsEsIndexRepo, this.organizationsEsIndexRepo, this.projectsEsIndexRepo, this.appsEsIndexRepo, @@ -50,6 +55,7 @@ export class ElasticsearchRegistryBootService { this.appsCategoriesEsIndexRepo, this.projectsFilesEsIndexRepo, this.projectsEmbeddingsEsIndexRepo, + this.projectsPoliciesEsIndexRepo, ]); this.logger.info('Registered elasticsearch repos!'); diff --git a/apps/backend/src/modules/messages/messages.repo.ts b/apps/backend/src/modules/messages/messages.repo.ts index 46772742..3b400d69 100644 --- a/apps/backend/src/modules/messages/messages.repo.ts +++ b/apps/backend/src/modules/messages/messages.repo.ts @@ -56,28 +56,28 @@ export class MessagesRepo extends createDatabaseRepo('messages') { .where('projects_files.message_id', '=', eb.ref('messages.id')) .select(eb => [ eb.fn.coalesce( - jsonBuildObject({ - files: eb.fn.jsonAgg( - jsonBuildObject({ - id: eb.ref('projects_files.id'), - resource: jsonBuildObject({ - id: eb.ref('s3_resources.id').$notNull(), - name: eb.ref('s3_resources.name').$notNull(), - type: eb.ref('s3_resources.type').$notNull(), - s3Key: eb.ref('s3_resources.s3_key').$notNull(), - createdAt: eb.ref('s3_resources.created_at').$notNull(), - updatedAt: eb.ref('s3_resources.updated_at').$notNull(), - publicUrl: sql`${eb.ref('s3_resources_buckets.public_base_url')} || '/' || ${eb.ref('s3_resources.s3_key')}`, - bucket: jsonBuildObject({ - id: eb.ref('s3_resources_buckets.id').$notNull(), - name: eb.ref('s3_resources_buckets.name').$notNull(), - }), + eb.fn.jsonAgg( + jsonBuildObject({ + id: eb.ref('projects_files.id'), + resource: jsonBuildObject({ + id: eb.ref('s3_resources.id').$notNull(), + name: eb.ref('s3_resources.name').$notNull(), + type: eb.ref('s3_resources.type').$notNull(), + s3Key: eb.ref('s3_resources.s3_key').$notNull(), + createdAt: eb.ref('s3_resources.created_at').$notNull(), + updatedAt: eb.ref('s3_resources.updated_at').$notNull(), + publicUrl: sql`${eb.ref('s3_resources_buckets.public_base_url')} || '/' || ${eb.ref('s3_resources.s3_key')}`, + bucket: jsonBuildObject({ + id: eb.ref('s3_resources_buckets.id').$notNull(), + name: eb.ref('s3_resources_buckets.name').$notNull(), }), }), - ), - }), + }), + ), sql`'[]'`, - ).as('files'), + ) + .$notNull() + .as('files'), ]) .as('files_json'), ]) @@ -145,7 +145,7 @@ export class MessagesRepo extends createDatabaseRepo('messages') { name: appName, } : null, - files: filesJson?.files ?? [], + files: filesJson ?? [], })), ), ); diff --git a/apps/backend/src/modules/messages/messages.service.ts b/apps/backend/src/modules/messages/messages.service.ts index 555f4c4d..ea04310a 100644 --- a/apps/backend/src/modules/messages/messages.service.ts +++ b/apps/backend/src/modules/messages/messages.service.ts @@ -79,7 +79,7 @@ export class MessagesService implements WithAuthFirewall { } return pipe( - this.projectsService.ensureChatHasProjectOrCreateInternal(chat.id), + this.projectsService.ensureChatHasProjectOrCreateInternal({ creator, chat }), TE.chainW(project => pipe( files, TE.traverseArray(file => this.projectsFilesService.uploadFile({ diff --git a/apps/backend/src/modules/organizations/users/organizations-users.repo.ts b/apps/backend/src/modules/organizations/users/organizations-users.repo.ts index a7b3dcac..91368de9 100644 --- a/apps/backend/src/modules/organizations/users/organizations-users.repo.ts +++ b/apps/backend/src/modules/organizations/users/organizations-users.repo.ts @@ -1,8 +1,10 @@ import { flow, pipe } from 'fp-ts/lib/function'; import { injectable } from 'tsyringe'; +import type { SdkOrganizationUserRoleT } from '@llm/sdk'; + import { mapAsyncIterator, pluck } from '@llm/commons'; -import { createDatabaseRepo, TableId } from '~/modules/database'; +import { createDatabaseRepo, type TableId, type TransactionalAttrs } from '~/modules/database'; type OrganizationUsersIteratorAttrs = { organizationId: TableId; @@ -31,6 +33,27 @@ export class OrganizationsUsersRepo extends createDatabaseRepo('organizations_us }), ); + updateUserOrganizationRole = ( + { + value, + forwardTransaction, + }: TransactionalAttrs<{ + value: { + userId: TableId; + role: SdkOrganizationUserRoleT; + }; + }>, + ) => + this.updateAll({ + forwardTransaction, + value: { + role: value.role, + }, + where: [ + ['userId', '=', value.userId], + ], + }); + createOrganizationUsersIdsIterator = flow( this.createOrganizationUsersIterator, mapAsyncIterator(pluck('id')), diff --git a/apps/backend/src/modules/projects-policies/elasticsearch/index.ts b/apps/backend/src/modules/projects-policies/elasticsearch/index.ts new file mode 100644 index 00000000..5230a316 --- /dev/null +++ b/apps/backend/src/modules/projects-policies/elasticsearch/index.ts @@ -0,0 +1,2 @@ +export * from './projects-policies-es-index.repo'; +export * from './projects-policies-es-search.repo'; diff --git a/apps/backend/src/modules/projects-policies/elasticsearch/projects-policies-es-index.repo.ts b/apps/backend/src/modules/projects-policies/elasticsearch/projects-policies-es-index.repo.ts new file mode 100644 index 00000000..757a471c --- /dev/null +++ b/apps/backend/src/modules/projects-policies/elasticsearch/projects-policies-es-index.repo.ts @@ -0,0 +1,73 @@ +import { array as A, taskEither as TE } from 'fp-ts'; +import { pipe } from 'fp-ts/lib/function'; +import snakecaseKeys from 'snakecase-keys'; +import { inject, injectable } from 'tsyringe'; + +import { tryOrThrowTE } from '@llm/commons'; +import { + createBaseDatedRecordMappings, + createElasticsearchIndexRepo, + createIdObjectMapping, + ElasticsearchRepo, + type EsDocument, +} from '~/modules/elasticsearch'; + +import type { ProjectPolicyTableRowWithRelations } from '../projects-policies.tables'; + +import { ProjectsPoliciesRepo } from '../projects-policies.repo'; + +const ProjectsPoliciesAbstractEsIndexRepo = createElasticsearchIndexRepo({ + indexName: 'dashboard-projects-policies', + schema: { + mappings: { + dynamic: false, + properties: { + ...createBaseDatedRecordMappings(), + access_level: { type: 'keyword' }, + project: createIdObjectMapping(), + user: createIdObjectMapping(), + group: createIdObjectMapping({ + users: { + type: 'nested', + ...createIdObjectMapping({ + email: { type: 'keyword' }, + }), + }, + }), + }, + }, + settings: { + 'index.number_of_replicas': 1, + }, + }, +}); + +export type ProjectsPoliciesEsDocument = EsDocument; + +@injectable() +export class ProjectsPoliciesEsIndexRepo extends ProjectsPoliciesAbstractEsIndexRepo { + constructor( + @inject(ElasticsearchRepo) elasticsearchRepo: ElasticsearchRepo, + @inject(ProjectsPoliciesRepo) private readonly policiesRepo: ProjectsPoliciesRepo, + ) { + super(elasticsearchRepo); + } + + protected async findEntities(ids: number[]): Promise { + return pipe( + this.policiesRepo.findWithRelationsByIds({ ids }), + TE.map( + A.map(entity => ({ + ...snakecaseKeys(entity, { deep: true }), + _id: String(entity.id), + })), + ), + tryOrThrowTE, + )(); + } + + protected createAllEntitiesIdsIterator = () => + this.policiesRepo.createIdsIterator({ + chunkSize: 100, + }); +} diff --git a/apps/backend/src/modules/projects-policies/elasticsearch/projects-policies-es-search.repo.ts b/apps/backend/src/modules/projects-policies/elasticsearch/projects-policies-es-search.repo.ts new file mode 100644 index 00000000..bb3e7d67 --- /dev/null +++ b/apps/backend/src/modules/projects-policies/elasticsearch/projects-policies-es-search.repo.ts @@ -0,0 +1,60 @@ +import esb from 'elastic-builder'; +import { taskEither as TE } from 'fp-ts'; +import { pipe } from 'fp-ts/lib/function'; +import { inject, injectable } from 'tsyringe'; + +import type { SdkProjectPolicyT } from '@llm/sdk'; +import type { TableId } from '~/modules/database'; + +import { tryGetFirstRawResponseHitOrNotExists } from '~/modules/elasticsearch/helpers'; + +import { + type ProjectsPoliciesEsDocument, + ProjectsPoliciesEsIndexRepo, +} from './projects-policies-es-index.repo'; + +@injectable() +export class ProjectsPoliciesEsSearchRepo { + constructor( + @inject(ProjectsPoliciesEsIndexRepo) private readonly indexRepo: ProjectsPoliciesEsIndexRepo, + ) {} + + getByProjectId = (projectId: TableId) => pipe( + this.indexRepo.search( + esb + .requestBodySearch() + .query( + esb + .boolQuery() + .must(esb.termQuery('project.id', projectId)), + ) + .toJSON(), + ), + tryGetFirstRawResponseHitOrNotExists, + TE.map(doc => ProjectsPoliciesEsSearchRepo.mapOutputHit(doc._source as ProjectsPoliciesEsDocument)), + ); + + private static mapOutputHit = (source: ProjectsPoliciesEsDocument): SdkProjectPolicyT => { + const record = { + id: source.id, + createdAt: source.created_at, + updatedAt: source.updated_at, + project: source.project, + accessLevel: source.access_level, + user: null, + group: null, + }; + + if (source.user) { + return { + ...record, + user: source.user, + }; + } + + return { + ...record, + group: source.group!, + }; + }; +} diff --git a/apps/backend/src/modules/projects-policies/index.ts b/apps/backend/src/modules/projects-policies/index.ts new file mode 100644 index 00000000..840e6032 --- /dev/null +++ b/apps/backend/src/modules/projects-policies/index.ts @@ -0,0 +1,4 @@ +export * from './elasticsearch'; +export * from './projects-policies.repo'; +export * from './projects-policies.service'; +export * from './projects-policies.tables'; diff --git a/apps/backend/src/modules/projects-policies/projects-policies.repo.ts b/apps/backend/src/modules/projects-policies/projects-policies.repo.ts new file mode 100644 index 00000000..db52c4e8 --- /dev/null +++ b/apps/backend/src/modules/projects-policies/projects-policies.repo.ts @@ -0,0 +1,113 @@ +import { array as A, option as O, taskEither as TE } from 'fp-ts'; +import { pipe } from 'fp-ts/lib/function'; +import { jsonBuildObject } from 'kysely/helpers/postgres'; +import { injectable } from 'tsyringe'; + +import { + createDatabaseRepo, + DatabaseError, + type TableId, + type TransactionalAttrs, + tryReuseTransactionOrSkip, +} from '../database'; +import { ProjectPolicyTableRowWithRelations } from './projects-policies.tables'; + +@injectable() +export class ProjectsPoliciesRepo extends createDatabaseRepo('projects_policies') { + findWithRelationsByIds = ({ forwardTransaction, ids }: TransactionalAttrs<{ ids: TableId[]; }>) => { + const transaction = tryReuseTransactionOrSkip({ db: this.db, forwardTransaction }); + + return pipe( + transaction( + async qb => + qb + .selectFrom(this.table) + .where('projects_policies.id', 'in', ids) + .innerJoin('projects', 'projects.id', 'project_id') + + .leftJoin('users', 'users.id', 'user_id') + .leftJoin('users_groups', 'users_groups.id', 'group_id') + + .selectAll('projects_policies') + .select([ + 'user_id as user_id', + 'users.email as user_email', + + 'group_id as group_id', + 'users_groups.name as group_name', + + 'projects.id as project_id', + 'projects.name as project_name', + + eb => eb + .selectFrom('users_groups_users') + .where('users_groups_users.group_id', '=', eb.ref('users_groups.id')) + .innerJoin('users as group_users', 'group_users.id', 'users_groups_users.user_id') + .select(eb => [ + eb.fn + .jsonAgg( + jsonBuildObject({ + id: eb.ref('group_users.id').$notNull(), + email: eb.ref('group_users.email').$notNull(), + }), + ) + .as('users'), + ]) + .as('users'), + ]) + .limit(ids.length) + .execute(), + ), + DatabaseError.tryTask, + TE.map( + A.filterMap(({ + project_id: projectId, + project_name: projectName, + + user_id: userId, + user_email: userEmail, + + group_id: groupId, + group_name: groupName, + + users, + + ...item + }): O.Option => { + const record = { + id: item.id, + createdAt: item.created_at, + updatedAt: item.updated_at, + accessLevel: item.access_level, + group: null, + user: null, + project: { + id: projectId, + name: projectName, + }, + }; + + if (groupId) { + return O.some({ + ...record, + group: { + id: groupId, + name: groupName!, + users: users ?? [], + }, + }); + } + + if (userId) { + return O.some({ + ...record, + user: { id: userId, email: userEmail! }, + }); + } + + return O.none; + }), + ), + ); + }; +} diff --git a/apps/backend/src/modules/projects-policies/projects-policies.service.ts b/apps/backend/src/modules/projects-policies/projects-policies.service.ts new file mode 100644 index 00000000..e50632e4 --- /dev/null +++ b/apps/backend/src/modules/projects-policies/projects-policies.service.ts @@ -0,0 +1,12 @@ +import { inject, injectable } from 'tsyringe'; + +import { ProjectsPoliciesEsSearchRepo } from './elasticsearch'; + +@injectable() +export class ProjectsPoliciesService { + constructor( + @inject(ProjectsPoliciesEsSearchRepo) private readonly esSearchRepo: ProjectsPoliciesEsSearchRepo, + ) {} + + getByProjectId = this.esSearchRepo.getByProjectId; +} diff --git a/apps/backend/src/modules/projects-policies/projects-policies.tables.ts b/apps/backend/src/modules/projects-policies/projects-policies.tables.ts new file mode 100644 index 00000000..57511904 --- /dev/null +++ b/apps/backend/src/modules/projects-policies/projects-policies.tables.ts @@ -0,0 +1,44 @@ +import type { ColumnType } from 'kysely'; + +import type { SdkProjectAccessLevelT } from '@llm/sdk'; + +import type { + NormalizeSelectTableRow, + TableId, + TableRowWithIdName, + TableWithDefaultColumns, +} from '../database'; +import type { UserTableRowBaseRelation } from '../users'; + +export type ProjectsPoliciesTable = + & TableWithDefaultColumns + & { + project_id: ColumnType; + user_id: ColumnType; + group_id: ColumnType; + access_level: SdkProjectAccessLevelT; + }; + +export type ProjectPolicyTableRow = NormalizeSelectTableRow; + +export type ProjectPolicyGroupRelationTableRow = + & TableRowWithIdName + & { + users: UserTableRowBaseRelation[]; + }; + +export type ProjectPolicyTableRowWithRelations = + & Omit + & { + project: TableRowWithIdName; + } + & ( + { + user: null; + group: ProjectPolicyGroupRelationTableRow; + } + | { + user: UserTableRowBaseRelation; + group: null; + } + ); diff --git a/apps/backend/src/modules/projects/elasticsearch/projects-es-index.repo.ts b/apps/backend/src/modules/projects/elasticsearch/projects-es-index.repo.ts index 17f7e068..a6f184f3 100644 --- a/apps/backend/src/modules/projects/elasticsearch/projects-es-index.repo.ts +++ b/apps/backend/src/modules/projects/elasticsearch/projects-es-index.repo.ts @@ -51,14 +51,14 @@ export type ProjectsEsDocument = EsDocument; export class ProjectsEsIndexRepo extends ProjectsAbstractEsIndexRepo { constructor( @inject(ElasticsearchRepo) elasticsearchRepo: ElasticsearchRepo, - @inject(ProjectsRepo) private readonly organizationsRepo: ProjectsRepo, + @inject(ProjectsRepo) private readonly projectsRepo: ProjectsRepo, ) { super(elasticsearchRepo); } protected async findEntities(ids: number[]): Promise { return pipe( - this.organizationsRepo.findWithRelationsByIds({ ids }), + this.projectsRepo.findWithRelationsByIds({ ids }), TE.map( A.map(entity => ({ ...snakecaseKeys(entity, { deep: true }), @@ -70,7 +70,7 @@ export class ProjectsEsIndexRepo extends ProjectsAbstractEsIndexRepo - this.organizationsRepo.createIdsIterator({ + this.projectsRepo.createIdsIterator({ chunkSize: 100, }); } diff --git a/apps/backend/src/modules/projects/projects.firewall.ts b/apps/backend/src/modules/projects/projects.firewall.ts index 2b47f9c6..dfdac50f 100644 --- a/apps/backend/src/modules/projects/projects.firewall.ts +++ b/apps/backend/src/modules/projects/projects.firewall.ts @@ -1,6 +1,6 @@ -import { flow } from 'fp-ts/lib/function'; +import { flow, pipe } from 'fp-ts/lib/function'; -import type { SdkJwtTokenT } from '@llm/sdk'; +import type { SdkCreateProjectInputT, SdkJwtTokenT } from '@llm/sdk'; import { AuthFirewallService } from '~/modules/auth/firewall'; @@ -34,8 +34,11 @@ export class ProjectsFirewall extends AuthFirewallService { this.tryTEIfUser.is.root, ); - create = flow( - this.projectsService.create, + create = (dto: SdkCreateProjectInputT) => pipe( + this.projectsService.create({ + ...dto, + creator: this.userIdRow, + }), this.tryTEIfUser.is.root, ); diff --git a/apps/backend/src/modules/projects/projects.service.ts b/apps/backend/src/modules/projects/projects.service.ts index 0cd289fa..e5f34003 100644 --- a/apps/backend/src/modules/projects/projects.service.ts +++ b/apps/backend/src/modules/projects/projects.service.ts @@ -16,7 +16,7 @@ import { } from '@llm/commons'; import type { WithAuthFirewall } from '../auth'; -import type { TableId, TableRowWithId, TableUuid } from '../database'; +import type { TableId, TableRowWithId, TableRowWithUuid } from '../database'; import { ChatsService } from '../chats/chats.service'; import { ProjectsEsIndexRepo, ProjectsEsSearchRepo } from './elasticsearch'; @@ -46,8 +46,16 @@ export class ProjectsService implements WithAuthFirewall { TE.tap(() => this.esIndexRepo.findAndIndexDocumentById(id)), ); - ensureChatHasProjectOrCreateInternal = (chatId: TableUuid) => pipe( - this.chatsService.get(chatId), + ensureChatHasProjectOrCreateInternal = ( + { + creator, + chat, + }: { + creator: TableRowWithId; + chat: TableRowWithUuid; + }, + ) => pipe( + this.chatsService.get(chat.id), TE.chainW((chat) => { if (chat.project) { return TE.right(chat.project); @@ -59,8 +67,9 @@ export class ProjectsService implements WithAuthFirewall { organization: chat.organization, name: `Unnamed Project - ${Date.now()}`, description: null, + creator, }), - TE.tap(project => this.chatsService.assignToProject(chatId, project.id)), + TE.tap(project => this.chatsService.assignToProject(chat.id, project.id)), ); }), ); @@ -96,10 +105,21 @@ export class ProjectsService implements WithAuthFirewall { search = this.esSearchRepo.search; - create = ({ internal, organization, ...values }: SdkCreateProjectInputT & { internal?: boolean; }) => pipe( + create = ( + { + creator, + internal, + organization, + ...values + }: SdkCreateProjectInputT & { + internal?: boolean; + creator: TableRowWithId; + }, + ) => pipe( this.repo.create({ value: { ...values, + creatorUserId: creator.id, internal: !!internal, organizationId: organization.id, }, diff --git a/apps/backend/src/modules/projects/projects.tables.ts b/apps/backend/src/modules/projects/projects.tables.ts index 38042cc5..bd53028f 100644 --- a/apps/backend/src/modules/projects/projects.tables.ts +++ b/apps/backend/src/modules/projects/projects.tables.ts @@ -13,6 +13,7 @@ export type ProjectsTable = & TableWithArchivedAtColumn & { organization_id: ColumnType; + creator_user_id: ColumnType; name: string; description: string | null; internal: boolean; diff --git a/apps/backend/src/modules/users-groups/elasticsearch/index.ts b/apps/backend/src/modules/users-groups/elasticsearch/index.ts new file mode 100644 index 00000000..219bd498 --- /dev/null +++ b/apps/backend/src/modules/users-groups/elasticsearch/index.ts @@ -0,0 +1,2 @@ +export * from './users-groups-es-index.repo'; +export * from './users-groups-es-search.repo'; diff --git a/apps/backend/src/modules/users-groups/elasticsearch/users-groups-es-index.repo.ts b/apps/backend/src/modules/users-groups/elasticsearch/users-groups-es-index.repo.ts new file mode 100644 index 00000000..8fc9ce9b --- /dev/null +++ b/apps/backend/src/modules/users-groups/elasticsearch/users-groups-es-index.repo.ts @@ -0,0 +1,69 @@ +import { array as A, taskEither as TE } from 'fp-ts'; +import { pipe } from 'fp-ts/lib/function'; +import snakecaseKeys from 'snakecase-keys'; +import { inject, injectable } from 'tsyringe'; + +import { tryOrThrowTE } from '@llm/commons'; +import { + createArchivedRecordMappings, + createAutocompleteFieldAnalyzeSettings, + createBaseAutocompleteFieldMappings, + createBaseDatedRecordMappings, + createElasticsearchIndexRepo, + createIdNameObjectMapping, + ElasticsearchRepo, + type EsDocument, +} from '~/modules/elasticsearch'; + +import type { UsersGroupTableRowWithRelations } from '../users-groups.tables'; + +import { UsersGroupsRepo } from '../repo/users-groups.repo'; + +const UsersGroupsAbstractEsIndexRepo = createElasticsearchIndexRepo({ + indexName: 'dashboard-users-groups', + schema: { + mappings: { + dynamic: false, + properties: { + ...createBaseDatedRecordMappings(), + ...createBaseAutocompleteFieldMappings(), + ...createArchivedRecordMappings(), + organization: createIdNameObjectMapping(), + }, + }, + settings: { + 'index.number_of_replicas': 1, + 'analysis': createAutocompleteFieldAnalyzeSettings(), + }, + }, +}); + +export type UsersGroupsEsDocument = EsDocument; + +@injectable() +export class UsersGroupsEsIndexRepo extends UsersGroupsAbstractEsIndexRepo { + constructor( + @inject(ElasticsearchRepo) elasticsearchRepo: ElasticsearchRepo, + @inject(UsersGroupsRepo) private readonly usersGroupsRepo: UsersGroupsRepo, + ) { + super(elasticsearchRepo); + } + + protected async findEntities(ids: number[]): Promise { + return pipe( + this.usersGroupsRepo.findWithRelationsByIds({ ids }), + TE.map( + A.map(entity => ({ + ...snakecaseKeys(entity, { deep: true }), + _id: String(entity.id), + })), + ), + tryOrThrowTE, + )(); + } + + protected createAllEntitiesIdsIterator = () => + this.usersGroupsRepo.createIdsIterator({ + chunkSize: 100, + }); +} diff --git a/apps/backend/src/modules/users-groups/elasticsearch/users-groups-es-search.repo.ts b/apps/backend/src/modules/users-groups/elasticsearch/users-groups-es-search.repo.ts new file mode 100644 index 00000000..c7ce6251 --- /dev/null +++ b/apps/backend/src/modules/users-groups/elasticsearch/users-groups-es-search.repo.ts @@ -0,0 +1,84 @@ +import esb from 'elastic-builder'; +import { array as A, taskEither as TE } from 'fp-ts'; +import { flow, pipe } from 'fp-ts/lib/function'; +import { inject, injectable } from 'tsyringe'; + +import type { + SdkSearchUsersGroupItemT, + SdkSearchUsersGroupsInputT, + SdkSearchUsersGroupsOutputT, +} from '@llm/sdk'; + +import { isNil, pluck, rejectFalsyItems } from '@llm/commons'; +import { + createPaginationOffsetSearchQuery, + createPhraseFieldQuery, + createScoredSortFieldQuery, +} from '~/modules/elasticsearch'; + +import { + type UsersGroupsEsDocument, + UsersGroupsEsIndexRepo, +} from './users-groups-es-index.repo'; + +@injectable() +export class UsersGroupsEsSearchRepo { + constructor( + @inject(UsersGroupsEsIndexRepo) private readonly indexRepo: UsersGroupsEsIndexRepo, + ) {} + + get = flow( + this.indexRepo.getDocument, + TE.map(UsersGroupsEsSearchRepo.mapOutputHit), + ); + + search = (dto: SdkSearchUsersGroupsInputT) => pipe( + this.indexRepo.search( + UsersGroupsEsSearchRepo.createEsRequestSearchBody(dto).toJSON(), + ), + TE.map(({ hits: { total, hits } }): SdkSearchUsersGroupsOutputT => ({ + items: pipe( + hits, + pluck('_source'), + A.map(item => UsersGroupsEsSearchRepo.mapOutputHit(item as UsersGroupsEsDocument)), + ), + total: total.value, + })), + ); + + private static createEsRequestSearchFilters = ( + { + phrase, + ids, + excludeIds, + organizationIds, + archived, + }: SdkSearchUsersGroupsInputT, + ): esb.Query => + esb.boolQuery().must( + rejectFalsyItems([ + !!phrase && createPhraseFieldQuery()(phrase).boost(3), + !!ids?.length && esb.termsQuery('id', ids), + !!excludeIds?.length && esb.boolQuery().mustNot(esb.termsQuery('id', excludeIds)), + !!organizationIds?.length && esb.termsQuery('organization.id', organizationIds), + !isNil(archived) && esb.termQuery('archived', archived), + ]), + ); + + private static createEsRequestSearchBody = (dto: SdkSearchUsersGroupsInputT) => + createPaginationOffsetSearchQuery(dto) + .query(UsersGroupsEsSearchRepo.createEsRequestSearchFilters(dto)) + .sorts(createScoredSortFieldQuery(dto.sort)); + + private static mapOutputHit = (source: UsersGroupsEsDocument): SdkSearchUsersGroupItemT => + ({ + id: source.id, + name: source.name, + createdAt: source.created_at, + updatedAt: source.updated_at, + archived: source.archived, + organization: source.organization, + creator: source.creator, + users: source.users, + }); +} diff --git a/apps/backend/src/modules/users-groups/index.ts b/apps/backend/src/modules/users-groups/index.ts new file mode 100644 index 00000000..d0fa16cf --- /dev/null +++ b/apps/backend/src/modules/users-groups/index.ts @@ -0,0 +1,5 @@ +export * from './elasticsearch'; +export * from './repo'; +export * from './users-groups.firewall'; +export * from './users-groups.service'; +export * from './users-groups.tables'; diff --git a/apps/backend/src/modules/users-groups/repo/index.ts b/apps/backend/src/modules/users-groups/repo/index.ts new file mode 100644 index 00000000..308115f3 --- /dev/null +++ b/apps/backend/src/modules/users-groups/repo/index.ts @@ -0,0 +1,2 @@ +export * from './users-groups.repo'; +export * from './users-groups-users.repo'; diff --git a/apps/backend/src/modules/users-groups/repo/users-groups-users.repo.ts b/apps/backend/src/modules/users-groups/repo/users-groups-users.repo.ts new file mode 100644 index 00000000..d804183f --- /dev/null +++ b/apps/backend/src/modules/users-groups/repo/users-groups-users.repo.ts @@ -0,0 +1,55 @@ +import { pipe } from 'fp-ts/lib/function'; +import { injectable } from 'tsyringe'; + +import { + AbstractDatabaseRepo, + DatabaseError, + TableRowWithId, + TransactionalAttrs, + tryReuseOrCreateTransaction, +} from '~/modules/database'; + +@injectable() +export class UsersGroupsUsersRepo extends AbstractDatabaseRepo { + updateGroupUsers = ( + { + forwardTransaction, + group, + users, + }: TransactionalAttrs<{ + group: TableRowWithId; + users: TableRowWithId[]; + }>, + ) => { + const transaction = tryReuseOrCreateTransaction({ + db: this.db, + forwardTransaction, + }); + + return transaction(trx => pipe( + async () => { + await trx + .deleteFrom('users_groups_users') + .where('group_id', '=', group.id) + .execute(); + + if (users.length > 0) { + await trx + .insertInto('users_groups_users') + .values( + users.map(user => ({ + group_id: group.id, + user_id: user.id, + })), + ) + .execute(); + } + + return { + success: true, + }; + }, + DatabaseError.tryTask, + )); + }; +} diff --git a/apps/backend/src/modules/users-groups/repo/users-groups.repo.ts b/apps/backend/src/modules/users-groups/repo/users-groups.repo.ts new file mode 100644 index 00000000..7a1819ce --- /dev/null +++ b/apps/backend/src/modules/users-groups/repo/users-groups.repo.ts @@ -0,0 +1,180 @@ +import camelcaseKeys from 'camelcase-keys'; +import { array as A, taskEither as TE } from 'fp-ts'; +import { pipe } from 'fp-ts/lib/function'; +import { jsonBuildObject } from 'kysely/helpers/postgres'; +import { inject, injectable } from 'tsyringe'; + +import type { SdkCreateUsersGroupInputT, SdkUpdateUsersGroupInputT } from '@llm/sdk'; + +import type { UsersGroupTableRowWithRelations } from '../users-groups.tables'; + +import { + createArchiveRecordQuery, + createArchiveRecordsQuery, + createProtectedDatabaseRepo, + createUnarchiveRecordQuery, + createUnarchiveRecordsQuery, + DatabaseConnectionRepo, + DatabaseError, + type TableId, + type TableRowWithId, + type TransactionalAttrs, + tryReuseOrCreateTransaction, + tryReuseTransactionOrSkip, +} from '../../database'; +import { UsersGroupsUsersRepo } from './users-groups-users.repo'; + +@injectable() +export class UsersGroupsRepo extends createProtectedDatabaseRepo('users_groups') { + constructor( + @inject(DatabaseConnectionRepo) connectionRepo: DatabaseConnectionRepo, + @inject(UsersGroupsUsersRepo) private readonly usersGroupsUsersRepo: UsersGroupsUsersRepo, + ) { + super(connectionRepo); + } + + createIdsIterator = this.baseRepo.createIdsIterator; + + archive = createArchiveRecordQuery(this.baseRepo.queryFactoryAttrs); + + archiveRecords = createArchiveRecordsQuery(this.baseRepo.queryFactoryAttrs); + + unarchive = createUnarchiveRecordQuery(this.baseRepo.queryFactoryAttrs); + + unarchiveRecords = createUnarchiveRecordsQuery(this.baseRepo.queryFactoryAttrs); + + findWithRelationsByIds = ({ forwardTransaction, ids }: TransactionalAttrs<{ ids: TableId[]; }>) => { + const transaction = tryReuseTransactionOrSkip({ db: this.db, forwardTransaction }); + + return pipe( + transaction( + async qb => + qb + .selectFrom(`${this.table} as group`) + .where('group.id', 'in', ids) + .innerJoin('organizations', 'organizations.id', 'organization_id') + .innerJoin('users as creator', 'creator.id', 'creator_user_id') + .selectAll('group') + .select([ + 'organizations.id as organization_id', + 'organizations.name as organization_name', + + 'creator.id as creator_id', + 'creator.email as creator_email', + + eb => eb + .selectFrom('users_groups_users') + .where('users_groups_users.group_id', '=', eb.ref('group.id')) + .innerJoin('users', 'users.id', 'users_groups_users.user_id') + .select(eb => [ + eb.fn + .jsonAgg( + jsonBuildObject({ + id: eb.ref('users.id').$notNull(), + email: eb.ref('users.email').$notNull(), + }), + ) + .as('users'), + ]) + .as('users'), + ]) + .limit(ids.length) + .execute(), + ), + DatabaseError.tryTask, + TE.map( + A.map(({ + organization_id: orgId, + organization_name: orgName, + + creator_id: creatorId, + creator_email: creatorEmail, + + users, + + ...item + }): UsersGroupTableRowWithRelations => ({ + ...camelcaseKeys(item), + creator: { + id: creatorId, + email: creatorEmail, + }, + organization: { + id: orgId, + name: orgName, + }, + users: users ?? [], + })), + ), + ); + }; + + create = ( + { + forwardTransaction, + value: { + users, + creator, + organization, + ...attrs + }, + }: TransactionalAttrs<{ + value: SdkCreateUsersGroupInputT & { + creator: TableRowWithId; + }; + }>, + ) => { + const transaction = tryReuseOrCreateTransaction({ + db: this.db, + forwardTransaction, + }); + + return transaction(trx => pipe( + this.baseRepo.create({ + value: { + ...attrs, + creatorUserId: creator.id, + organizationId: organization.id, + }, + forwardTransaction: trx, + }), + TE.tap(group => this.usersGroupsUsersRepo.updateGroupUsers({ + forwardTransaction: trx, + group, + users, + })), + )); + }; + + update = ( + { + forwardTransaction, + id, + value: { + users, + ...attrs + }, + }: TransactionalAttrs<{ + id: TableId; + value: SdkUpdateUsersGroupInputT; + }>, + ) => { + const transaction = tryReuseOrCreateTransaction({ + db: this.db, + forwardTransaction, + }); + + return transaction(trx => pipe( + this.baseRepo.update({ + id, + value: attrs, + forwardTransaction: trx, + }), + TE.tap(group => this.usersGroupsUsersRepo.updateGroupUsers({ + forwardTransaction: trx, + group, + users, + })), + )); + }; +} diff --git a/apps/backend/src/modules/users-groups/users-groups.firewall.ts b/apps/backend/src/modules/users-groups/users-groups.firewall.ts new file mode 100644 index 00000000..19d64673 --- /dev/null +++ b/apps/backend/src/modules/users-groups/users-groups.firewall.ts @@ -0,0 +1,44 @@ +import { flow, pipe } from 'fp-ts/lib/function'; + +import type { SdkCreateUsersGroupInputT, SdkJwtTokenT } from '@llm/sdk'; + +import { AuthFirewallService } from '~/modules/auth/firewall'; + +import type { UsersGroupsService } from './users-groups.service'; + +export class UsersGroupsFirewall extends AuthFirewallService { + constructor( + jwt: SdkJwtTokenT, + private readonly usersGroupsService: UsersGroupsService, + ) { + super(jwt); + } + + unarchive = flow( + this.usersGroupsService.unarchive, + this.tryTEIfUser.is.root, + ); + + archive = flow( + this.usersGroupsService.archive, + this.tryTEIfUser.is.root, + ); + + update = flow( + this.usersGroupsService.update, + this.tryTEIfUser.is.root, + ); + + create = (dto: SdkCreateUsersGroupInputT) => pipe( + this.usersGroupsService.create({ + ...dto, + creator: this.userIdRow, + }), + this.tryTEIfUser.is.root, + ); + + search = flow( + this.usersGroupsService.search, + this.tryTEIfUser.is.root, + ); +} diff --git a/apps/backend/src/modules/users-groups/users-groups.service.ts b/apps/backend/src/modules/users-groups/users-groups.service.ts new file mode 100644 index 00000000..2ad6d087 --- /dev/null +++ b/apps/backend/src/modules/users-groups/users-groups.service.ts @@ -0,0 +1,91 @@ +import { taskEither as TE } from 'fp-ts'; +import { pipe } from 'fp-ts/lib/function'; +import { inject, injectable } from 'tsyringe'; + +import type { + SdkCreateUsersGroupInputT, + SdkJwtTokenT, + SdkUpdateUsersGroupInputT, +} from '@llm/sdk'; + +import { + asyncIteratorToVoidPromise, + runTaskAsVoid, + tapAsyncIterator, + tryOrThrowTE, +} from '@llm/commons'; + +import type { WithAuthFirewall } from '../auth'; +import type { TableId, TableRowWithId } from '../database'; + +import { UsersGroupsEsIndexRepo, UsersGroupsEsSearchRepo } from './elasticsearch'; +import { UsersGroupsRepo } from './repo/users-groups.repo'; +import { UsersGroupsFirewall } from './users-groups.firewall'; + +@injectable() +export class UsersGroupsService implements WithAuthFirewall { + constructor( + @inject(UsersGroupsRepo) private readonly repo: UsersGroupsRepo, + @inject(UsersGroupsEsSearchRepo) private readonly esSearchRepo: UsersGroupsEsSearchRepo, + @inject(UsersGroupsEsIndexRepo) private readonly esIndexRepo: UsersGroupsEsIndexRepo, + ) {} + + asUser = (jwt: SdkJwtTokenT) => new UsersGroupsFirewall(jwt, this); + + search = this.esSearchRepo.search; + + unarchive = (id: TableId) => pipe( + this.repo.unarchive({ id }), + TE.tap(() => this.esIndexRepo.findAndIndexDocumentById(id)), + ); + + archive = (id: TableId) => pipe( + this.repo.archive({ id }), + TE.tap(() => this.esIndexRepo.findAndIndexDocumentById(id)), + ); + + archiveSeqByOrganizationId = (organizationId: TableId) => TE.fromTask( + pipe( + this.repo.createIdsIterator({ + where: [['organizationId', '=', organizationId]], + chunkSize: 100, + }), + this.archiveSeqStream, + ), + ); + + archiveSeqStream = (stream: AsyncIterableIterator) => async () => + pipe( + stream, + tapAsyncIterator(async ids => + pipe( + this.repo.archiveRecords({ + where: [ + ['id', 'in', ids], + ['archived', '=', false], + ], + }), + TE.tap(() => this.esIndexRepo.findAndIndexDocumentsByIds(ids)), + tryOrThrowTE, + runTaskAsVoid, + ), + ), + asyncIteratorToVoidPromise, + ); + + create = ( + value: SdkCreateUsersGroupInputT & { + creator: TableRowWithId; + }, + ) => pipe( + this.repo.create({ + value, + }), + TE.tap(({ id }) => this.esIndexRepo.findAndIndexDocumentById(id)), + ); + + update = ({ id, ...value }: SdkUpdateUsersGroupInputT & TableRowWithId) => pipe( + this.repo.update({ id, value }), + TE.tap(() => this.esIndexRepo.findAndIndexDocumentById(id)), + ); +} diff --git a/apps/backend/src/modules/users-groups/users-groups.tables.ts b/apps/backend/src/modules/users-groups/users-groups.tables.ts new file mode 100644 index 00000000..3b648511 --- /dev/null +++ b/apps/backend/src/modules/users-groups/users-groups.tables.ts @@ -0,0 +1,34 @@ +import type { ColumnType } from 'kysely'; + +import type { + NormalizeSelectTableRow, + TableId, + TableRowWithIdName, + TableWithArchivedAtColumn, + TableWithDefaultColumns, +} from '../database'; +import type { UserTableRowBaseRelation } from '../users/users.tables'; + +export type UsersGroupsTable = + & TableWithDefaultColumns + & TableWithArchivedAtColumn + & { + name: string; + creator_user_id: ColumnType; + organization_id: ColumnType; + }; + +export type UsersGroupsUsersTable = { + user_id: ColumnType; + group_id: ColumnType; +}; + +export type UsersGroupTableRow = NormalizeSelectTableRow; + +export type UsersGroupTableRowWithRelations = + & Omit + & { + creator: UserTableRowBaseRelation; + organization: TableRowWithIdName; + users: UserTableRowBaseRelation[]; + }; diff --git a/apps/backend/src/modules/users/elasticsearch/users-es-search.repo.ts b/apps/backend/src/modules/users/elasticsearch/users-es-search.repo.ts index 2d4816d9..55065508 100644 --- a/apps/backend/src/modules/users/elasticsearch/users-es-search.repo.ts +++ b/apps/backend/src/modules/users/elasticsearch/users-es-search.repo.ts @@ -6,7 +6,7 @@ import { inject, injectable } from 'tsyringe'; import type { SdkSearchUserItemT, - SdKSearchUsersInputT, + SdkSearchUsersInputT, } from '@llm/sdk'; import { isNil, pluck, rejectFalsyItems } from '@llm/commons'; @@ -27,7 +27,7 @@ export class UsersEsSearchRepo { @inject(UsersEsIndexRepo) private readonly indexRepo: UsersEsIndexRepo, ) {} - search = (dto: SdKSearchUsersInputT) => + search = (dto: SdkSearchUsersInputT) => pipe( this.indexRepo.search( UsersEsSearchRepo.createEsRequestSearchBody(dto).toJSON(), @@ -42,7 +42,7 @@ export class UsersEsSearchRepo { })), ); - private static createEsRequestSearchBody = (dto: SdKSearchUsersInputT) => + private static createEsRequestSearchBody = (dto: SdkSearchUsersInputT) => createPaginationOffsetSearchQuery(dto) .query(UsersEsSearchRepo.createEsRequestSearchFilters(dto)) .sorts(createScoredSortFieldQuery(dto.sort)); @@ -53,7 +53,7 @@ export class UsersEsSearchRepo { ids, organizationIds, archived, - }: SdKSearchUsersInputT, + }: SdkSearchUsersInputT, ): esb.Query => esb.boolQuery().must( rejectFalsyItems([ diff --git a/apps/backend/src/modules/users/users.repo.ts b/apps/backend/src/modules/users/users.repo.ts index 80374ae2..d44750de 100644 --- a/apps/backend/src/modules/users/users.repo.ts +++ b/apps/backend/src/modules/users/users.repo.ts @@ -103,6 +103,19 @@ export class UsersRepo extends createProtectedDatabaseRepo('users') { ...value.auth, }), )), + TE.tap(({ id }) => { + if (value.role !== 'user') { + return TE.of(undefined); + } + + return this.organizationsUsersRepo.updateUserOrganizationRole({ + forwardTransaction: trx, + value: { + userId: id, + role: value.organization.role, + }, + }); + }), )); }; diff --git a/apps/chat/src/i18n/packs/i18n-lang-en.ts b/apps/chat/src/i18n/packs/i18n-lang-en.ts index 07ed8821..7f96db58 100644 --- a/apps/chat/src/i18n/packs/i18n-lang-en.ts +++ b/apps/chat/src/i18n/packs/i18n-lang-en.ts @@ -1,14 +1,22 @@ import deepmerge from 'deepmerge'; +import type { SdkOrganizationUserRoleT } from '@llm/sdk'; + import { I18N_FORWARDED_EN_PACK } from '@llm/ui'; +const I18N_USER_ORGANIZATION_ROLES_EN: Record = { + owner: 'Owner', + member: 'Member', +}; + export const I18N_PACK_EN = deepmerge(I18N_FORWARDED_EN_PACK, { navigation: { links: { - home: 'Home', + home: 'Chats', projects: 'Projects', apps: 'Apps', experts: 'Experts', + management: 'Management', }, loggedIn: { logout: 'Logout', @@ -42,6 +50,7 @@ export const I18N_PACK_EN = deepmerge(I18N_FORWARDED_EN_PACK, { createdAt: 'Created at', updatedAt: 'Updated at', actions: 'Actions', + role: 'Role', }, }, routes: { @@ -178,6 +187,21 @@ export const I18N_PACK_EN = deepmerge(I18N_FORWARDED_EN_PACK, { rootOnly: 'This step is only required for root users - regular users are automatically assigned to their organization', }, }, + management: { + meta: { + title: 'Management', + description: 'Management', + }, + title: 'Management', + pages: { + users: { + title: 'Users', + }, + usersGroups: { + title: 'Users Groups', + }, + }, + }, }, workspace: { selectOrganization: 'Select organization', @@ -267,8 +291,6 @@ export const I18N_PACK_EN = deepmerge(I18N_FORWARDED_EN_PACK, { }, chooseAppModal: { title: 'Choose App', - select: 'Select', - selected: 'Selected', }, }, appsCreator: { @@ -373,6 +395,77 @@ export const I18N_PACK_EN = deepmerge(I18N_FORWARDED_EN_PACK, { remove: 'Remove from favorites', }, }, + organizations: { + userRoles: I18N_USER_ORGANIZATION_ROLES_EN, + }, + users: { + form: { + title: { + create: 'Create user', + edit: 'Edit user', + }, + fields: { + email: { + label: 'E-Mail', + placeholder: 'Enter e-mail address', + }, + flags: { + label: 'Flags', + }, + active: { + label: 'Active', + }, + organization: { + role: { + label: 'Role in organization', + }, + }, + auth: { + label: 'Authentication', + email: { + label: 'Email', + placeholder: 'Enter email address', + }, + password: { + label: 'Password', + placeholder: 'Enter password', + }, + resetPassword: { + label: 'Reset password', + }, + }, + }, + }, + row: { + authMethod: { + password: 'Password', + email: 'Email', + }, + }, + chooseUsersModal: { + title: 'Choose users', + }, + }, + usersGroups: { + form: { + title: { + create: 'Create users group', + edit: 'Edit users group', + }, + fields: { + name: { + label: 'Name', + placeholder: 'Enter users group name', + }, + users: { + label: 'Users', + }, + }, + }, + table: { + totalUsers: 'Total users', + }, + }, footer: { copyright: 'Open Source AI Platform', madeWith: 'Made with', diff --git a/apps/chat/src/i18n/packs/i18n-lang-pl.ts b/apps/chat/src/i18n/packs/i18n-lang-pl.ts index f72fd25d..a876107b 100644 --- a/apps/chat/src/i18n/packs/i18n-lang-pl.ts +++ b/apps/chat/src/i18n/packs/i18n-lang-pl.ts @@ -1,16 +1,24 @@ import deepmerge from 'deepmerge'; +import type { SdkOrganizationUserRoleT } from '@llm/sdk'; + import { I18N_FORWARDED_PL_PACK } from '@llm/ui'; import type { I18nLangPack } from './i18n-packs'; +const I18N_USER_ORGANIZATION_ROLES_PL: Record = { + owner: 'Właściciel', + member: 'Pracownik', +}; + export const I18N_PACK_PL: I18nLangPack = deepmerge(I18N_FORWARDED_PL_PACK, { navigation: { links: { - home: 'Strona główna', + home: 'Czaty', projects: 'Projekty', - apps: 'Aplikacje', + apps: 'Appki', experts: 'Eksperci', + management: 'Administracja', }, loggedIn: { logout: 'Wyloguj się', @@ -33,17 +41,18 @@ export const I18N_PACK_PL: I18nLangPack = deepmerge(I18N_FORWARDED_PL_PACK, { table: { columns: { id: 'ID', - name: 'Name', - description: 'Description', + name: 'Nazwa', + description: 'Opis', email: 'E-Mail', - archived: 'Archived', - active: 'Active', - auth: 'Authentication', - organization: 'Organization', - parentCategory: 'Parent category', - createdAt: 'Created at', - updatedAt: 'Updated at', - actions: 'Actions', + archived: 'Zarchiwizowane', + active: 'Aktywne', + auth: 'Uwierzytelnianie', + organization: 'Organizacja', + parentCategory: 'Kategoria nadrzędna', + createdAt: 'Data utworzenia', + updatedAt: 'Data aktualizacji', + actions: 'Akcje', + role: 'Rola', }, }, routes: { @@ -180,6 +189,21 @@ export const I18N_PACK_PL: I18nLangPack = deepmerge(I18N_FORWARDED_PL_PACK, { rootOnly: 'Ten krok jest wymagany tylko dla użytkowników root - zwykli użytkownicy są automatycznie przypisani do swojej organizacji', }, }, + management: { + meta: { + title: 'Administracja', + description: 'Zarządzaj ustawieniami organizacji', + }, + title: 'Administracja', + pages: { + users: { + title: 'Użytkownicy', + }, + usersGroups: { + title: 'Grupy użytkowników', + }, + }, + }, }, workspace: { selectOrganization: 'Wybierz organizację', @@ -269,8 +293,6 @@ export const I18N_PACK_PL: I18nLangPack = deepmerge(I18N_FORWARDED_PL_PACK, { }, chooseAppModal: { title: 'Wybierz aplikację', - select: 'Wybierz', - selected: 'Wybrano', }, }, appsCreator: { @@ -375,6 +397,77 @@ export const I18N_PACK_PL: I18nLangPack = deepmerge(I18N_FORWARDED_PL_PACK, { remove: 'Usuń z ulubionych', }, }, + organizations: { + userRoles: I18N_USER_ORGANIZATION_ROLES_PL, + }, + users: { + form: { + title: { + create: 'Utwórz użytkownika', + edit: 'Edytuj użytkownika', + }, + fields: { + email: { + label: 'E-Mail', + placeholder: 'Wprowadź adres e-mail', + }, + flags: { + label: 'Flagi', + }, + active: { + label: 'Aktywny', + }, + organization: { + role: { + label: 'Rola w organizacji', + }, + }, + auth: { + label: 'Uwierzytelnianie', + email: { + label: 'Email', + placeholder: 'Wprowadź adres email', + }, + password: { + label: 'Hasło', + placeholder: 'Wprowadź hasło', + }, + resetPassword: { + label: 'Zresetuj hasło', + }, + }, + }, + }, + row: { + authMethod: { + email: 'E-Mail', + password: 'Hasło', + }, + }, + chooseUsersModal: { + title: 'Wybierz użytkowników', + }, + }, + usersGroups: { + form: { + title: { + create: 'Utwórz grupę użytkowników', + edit: 'Edytuj grupę użytkowników', + }, + fields: { + name: { + label: 'Nazwa', + placeholder: 'Wprowadź nazwę grupy', + }, + users: { + label: 'Użytkownicy', + }, + }, + }, + table: { + totalUsers: 'Użytkowników', + }, + }, footer: { copyright: 'Platforma AI Open Source', madeWith: 'Stworzone z', diff --git a/apps/chat/src/layouts/navigation/links/navigation-item.tsx b/apps/chat/src/layouts/navigation/links/navigation-item.tsx index c92728fc..1eaf2862 100644 --- a/apps/chat/src/layouts/navigation/links/navigation-item.tsx +++ b/apps/chat/src/layouts/navigation/links/navigation-item.tsx @@ -11,7 +11,11 @@ export type NavigationItemProps = PropsWithChildren & { export function NavigationItem({ path, icon, children, disabled }: NavigationItemProps) { const [location] = useLocation(); - const isActive = location === path; + const isActive = ( + path !== '/' + ? location.startsWith(path) + : location === path + ); return (
  • diff --git a/apps/chat/src/layouts/navigation/links/navigation-links.tsx b/apps/chat/src/layouts/navigation/links/navigation-links.tsx index 2c18fc7e..0024a8a9 100644 --- a/apps/chat/src/layouts/navigation/links/navigation-links.tsx +++ b/apps/chat/src/layouts/navigation/links/navigation-links.tsx @@ -1,7 +1,8 @@ import { FolderKanbanIcon, GraduationCapIcon, - HomeIcon, + MessageSquareTextIcon, + SettingsIcon, WandSparklesIcon, } from 'lucide-react'; @@ -21,7 +22,7 @@ export function NavigationLinks() {
      } + icon={} disabled={!hasOrganization} > {t.links.home} @@ -50,6 +51,14 @@ export function NavigationLinks() { > {t.links.experts} + + } + disabled={!hasOrganization} + > + {t.links.management} +
    ); } diff --git a/apps/chat/src/layouts/navigation/navigation-right-toolbar.tsx b/apps/chat/src/layouts/navigation/navigation-right-toolbar.tsx index 5f5c402e..c1e1a307 100644 --- a/apps/chat/src/layouts/navigation/navigation-right-toolbar.tsx +++ b/apps/chat/src/layouts/navigation/navigation-right-toolbar.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx'; -import { BellIcon, SearchIcon } from 'lucide-react'; +import { SearchIcon } from 'lucide-react'; import { useI18n } from '~/i18n'; import { useHasWorkspaceOrganization } from '~/modules'; @@ -22,7 +22,7 @@ export function NavigationRightToolbar() { - - diff --git a/apps/chat/src/modules/ai-models/controls/ai-models-search-select.tsx b/apps/chat/src/modules/ai-models/controls/ai-models-search-select.tsx index a3630465..061300cb 100644 --- a/apps/chat/src/modules/ai-models/controls/ai-models-search-select.tsx +++ b/apps/chat/src/modules/ai-models/controls/ai-models-search-select.tsx @@ -22,15 +22,12 @@ const AIModelsSearchAbstractSelect = createSdkAutocomplete< }); export function AIModelsSearchSelect({ filters, ...props }: ComponentProps) { - const { organization } = useWorkspaceOrganization(); + const { assignWorkspaceToFilters } = useWorkspaceOrganization(); return ( ); } diff --git a/apps/chat/src/modules/apps-categories/controls/apps-categories-search-select.tsx b/apps/chat/src/modules/apps-categories/controls/apps-categories-search-select.tsx index 15bbb730..1dc8a815 100644 --- a/apps/chat/src/modules/apps-categories/controls/apps-categories-search-select.tsx +++ b/apps/chat/src/modules/apps-categories/controls/apps-categories-search-select.tsx @@ -21,15 +21,12 @@ const AppsCategoriesAbstractSearchSelect = createSdkAutocomplete< }); export function AppsCategoriesSearchSelect({ filters, ...props }: ComponentProps) { - const { organization } = useWorkspaceOrganization(); + const { assignWorkspaceToFilters } = useWorkspaceOrganization(); return ( ); } diff --git a/apps/chat/src/modules/apps-categories/table/apps-categories-table-container.tsx b/apps/chat/src/modules/apps-categories/table/apps-categories-table-container.tsx index 11d48b75..e25c45bb 100644 --- a/apps/chat/src/modules/apps-categories/table/apps-categories-table-container.tsx +++ b/apps/chat/src/modules/apps-categories/table/apps-categories-table-container.tsx @@ -1,4 +1,4 @@ -import { pipe } from 'fp-ts/lib/function'; +import { flow, pipe } from 'fp-ts/lib/function'; import { tapTaskOption } from '@llm/commons'; import { useAsyncCallback } from '@llm/commons-front'; @@ -24,7 +24,7 @@ import { AppsCategoriesTableRow } from './apps-categories-table-row'; export function AppsCategoriesTableContainer() { const { pack } = useI18n(); - const { organization } = useWorkspaceOrganizationOrThrow(); + const { assignWorkspaceToFilters } = useWorkspaceOrganizationOrThrow(); const t = pack.table.columns; @@ -33,10 +33,7 @@ export function AppsCategoriesTableContainer() { storeDataInUrl: false, schema: SdKSearchAppsInputV, fallbackSearchParams: {}, - fetchResultsTask: filters => sdks.dashboard.appsCategories.search({ - ...filters, - organizationIds: [organization.id], - }), + fetchResultsTask: flow(assignWorkspaceToFilters, sdks.dashboard.appsCategories.search), }); const createModal = useAppCategoryCreateModal(); diff --git a/apps/chat/src/modules/apps/choose-app/choose-app-modal.tsx b/apps/chat/src/modules/apps/choose-app/choose-app-modal.tsx index fda475bc..bee551d2 100644 --- a/apps/chat/src/modules/apps/choose-app/choose-app-modal.tsx +++ b/apps/chat/src/modules/apps/choose-app/choose-app-modal.tsx @@ -1,7 +1,9 @@ +import { suppressEvent } from '@under-control/forms'; + import type { SdkAppT, SdkTableRowWithIdT } from '@llm/sdk'; import { findItemById } from '@llm/commons'; -import { Modal, type ModalProps, ModalTitle } from '@llm/ui'; +import { Modal, type ModalProps, ModalTitle, SelectRecordButton } from '@llm/ui'; import { useI18n } from '~/i18n'; import { AppsContainer } from '../grid'; @@ -22,31 +24,18 @@ export function ChooseAppModal({ const t = useI18n().pack.apps.chooseAppModal; const renderAppCTA = (app: SdkAppT) => { - if (findItemById(app.id)(selectedApps || [])) { - return ( - { - e.preventDefault(); - }} - > - {t.selected} - - ); - } + const installed = !!findItemById(app.id)(selectedApps || []); return ( - { - e.preventDefault(); + suppressEvent(e); onSelect?.(app); }} - > - {t.select} - + /> ); }; diff --git a/apps/chat/src/modules/apps/grid/app-card.tsx b/apps/chat/src/modules/apps/grid/app-card.tsx index f4b8ec41..cd335d1d 100644 --- a/apps/chat/src/modules/apps/grid/app-card.tsx +++ b/apps/chat/src/modules/apps/grid/app-card.tsx @@ -6,10 +6,6 @@ import { StarIcon, WandSparklesIcon } from 'lucide-react'; import { formatDate, runTask, tapTaskOption } from '@llm/commons'; import { type SdkAppT, useSdkForLoggedIn } from '@llm/sdk'; -import { useArchiveWithNotifications } from '@llm/ui'; -import { useI18n } from '~/i18n'; -import { useAppUpdateModal } from '~/modules/apps-creator'; -import { useCreateChatWithInitialApp } from '~/modules/chats/conversation/hooks'; import { CardActions, CardArchiveButton, @@ -19,7 +15,11 @@ import { CardFooter, CardOpenButton, CardTitle, -} from '~/modules/shared/card'; + useArchiveWithNotifications, +} from '@llm/ui'; +import { useI18n } from '~/i18n'; +import { useAppUpdateModal } from '~/modules/apps-creator'; +import { useCreateChatWithInitialApp } from '~/modules/chats/conversation/hooks'; import { useFavoriteApps } from '../favorite'; diff --git a/apps/chat/src/modules/apps/grid/apps-container.tsx b/apps/chat/src/modules/apps/grid/apps-container.tsx index efb9957d..76107a9d 100644 --- a/apps/chat/src/modules/apps/grid/apps-container.tsx +++ b/apps/chat/src/modules/apps/grid/apps-container.tsx @@ -1,6 +1,7 @@ import type { ControlHookResult } from '@under-control/forms'; import { clsx } from 'clsx'; +import { flow } from 'fp-ts/lib/function'; import { type ReactNode, useMemo } from 'react'; import { useLastNonNullValue, useUpdateEffect } from '@llm/commons-front'; @@ -33,7 +34,7 @@ type Props = { export function AppsContainer({ toolbar, itemPropsFn, columns = 3 }: Props) { const favorites = useFavoriteApps(); - const { organization } = useWorkspaceOrganizationOrThrow(); + const { assignWorkspaceToFilters } = useWorkspaceOrganizationOrThrow(); const { sdks } = useSdkForLoggedIn(); const { loading, pagination, result, silentReload } = useDebouncedPaginatedSearch({ @@ -46,10 +47,7 @@ export function AppsContainer({ toolbar, itemPropsFn, columns = 3 }: Props) { ids: [...favorites.ids], }, }, - fetchResultsTask: filters => sdks.dashboard.apps.search({ - ...filters, - organizationIds: [organization.id], - }), + fetchResultsTask: flow(assignWorkspaceToFilters, sdks.dashboard.apps.search), }); const categoriesTree = useLastNonNullValue(result?.aggs?.categories); diff --git a/apps/chat/src/modules/chats/conversation/chat-attached-app.tsx b/apps/chat/src/modules/chats/conversation/chat-attached-app.tsx index 9c9778f0..34293381 100644 --- a/apps/chat/src/modules/chats/conversation/chat-attached-app.tsx +++ b/apps/chat/src/modules/chats/conversation/chat-attached-app.tsx @@ -16,7 +16,7 @@ export const ChatAttachedApp = memo(({ app }: ChatAttachedAppProps) => { -
    +
    Attached app: diff --git a/apps/chat/src/modules/chats/conversation/messages/chat-message.tsx b/apps/chat/src/modules/chats/conversation/messages/chat-message.tsx index b69cd332..1dec178a 100644 --- a/apps/chat/src/modules/chats/conversation/messages/chat-message.tsx +++ b/apps/chat/src/modules/chats/conversation/messages/chat-message.tsx @@ -92,7 +92,7 @@ export function ChatMessage({ message, isLast, readOnly, onRefreshResponse, onRe >
    , 'items'>; + +export const UserOrganizationRoleSelect = controlled(( + { + control: { value, setValue }, + ...props + }, +) => { + const { userRoles } = useI18n().pack.organizations; + const items = Object.entries(userRoles).map(([role, name]) => ({ + id: role, + name, + })); + + return ( + + + + + + ); +}); diff --git a/apps/chat/src/modules/users-groups/form/update/index.ts b/apps/chat/src/modules/users-groups/form/update/index.ts new file mode 100644 index 00000000..ae1a11ff --- /dev/null +++ b/apps/chat/src/modules/users-groups/form/update/index.ts @@ -0,0 +1,3 @@ +export * from './use-users-group-update-form'; +export * from './use-users-group-update-modal'; +export * from './users-group-update-form-modal'; diff --git a/apps/chat/src/modules/users-groups/form/update/use-users-group-update-form.tsx b/apps/chat/src/modules/users-groups/form/update/use-users-group-update-form.tsx new file mode 100644 index 00000000..0a7926b0 --- /dev/null +++ b/apps/chat/src/modules/users-groups/form/update/use-users-group-update-form.tsx @@ -0,0 +1,47 @@ +import { type FormHookAttrs, useForm } from '@under-control/forms'; +import { flow } from 'fp-ts/lib/function'; + +import { runTask, tapTaskEither } from '@llm/commons'; +import { + type SdkTableRowWithIdT, + type SdkUpdateUsersGroupInputT, + useSdkForLoggedIn, +} from '@llm/sdk'; +import { usePredefinedFormValidators, useSaveTaskEitherNotification } from '@llm/ui'; + +type UpdateUsersGroupFormHookAttrs = + & Omit< + FormHookAttrs, + 'validation' | 'onSubmit' + > + & { + onAfterSubmit?: VoidFunction; + }; + +export function useUsersGroupUpdateForm( + { + onAfterSubmit, + ...props + }: UpdateUsersGroupFormHookAttrs, +) { + const { sdks } = useSdkForLoggedIn(); + const { required } = usePredefinedFormValidators(); + const saveNotifications = useSaveTaskEitherNotification(); + + return useForm({ + resetAfterSubmit: false, + onSubmit: flow( + sdks.dashboard.usersGroups.update, + saveNotifications, + tapTaskEither(() => onAfterSubmit?.()), + runTask, + ), + validation: { + mode: ['blur', 'submit'], + validators: () => [ + required('name'), + ], + }, + ...props, + }); +} diff --git a/apps/chat/src/modules/users-groups/form/update/use-users-group-update-modal.tsx b/apps/chat/src/modules/users-groups/form/update/use-users-group-update-modal.tsx new file mode 100644 index 00000000..726f9c45 --- /dev/null +++ b/apps/chat/src/modules/users-groups/form/update/use-users-group-update-modal.tsx @@ -0,0 +1,32 @@ +import type { SdkUsersGroupT } from '@llm/sdk'; + +import { useAnimatedModal } from '@llm/commons-front'; + +import { + UsersGroupUpdateFormModal, + type UsersGroupUpdateFormModalProps, +} from './users-group-update-form-modal'; + +type UsersGroupShowModalProps = + & Pick + & { + usersGroup: SdkUsersGroupT; + }; + +export function useUsersGroupUpdateModal() { + return useAnimatedModal({ + renderModalContent: ({ showProps, hiding, onAnimatedClose }) => ( + { + void onAnimatedClose(true); + showProps?.onAfterSubmit?.(); + }} + onClose={() => { + void onAnimatedClose(); + }} + /> + ), + }); +} diff --git a/apps/chat/src/modules/users-groups/form/update/users-group-update-form-modal.tsx b/apps/chat/src/modules/users-groups/form/update/users-group-update-form-modal.tsx new file mode 100644 index 00000000..e231bdac --- /dev/null +++ b/apps/chat/src/modules/users-groups/form/update/users-group-update-form-modal.tsx @@ -0,0 +1,64 @@ +import type { SdkUsersGroupT } from '@llm/sdk'; + +import { + CancelButton, + FormErrorAlert, + Modal, + type ModalProps, + ModalTitle, + UpdateButton, +} from '@llm/ui'; +import { useI18n } from '~/i18n'; + +import { UsersGroupSharedFormFields } from '../shared'; +import { useUsersGroupUpdateForm } from './use-users-group-update-form'; + +export type UsersGroupUpdateFormModalProps = + & Omit + & { + usersGroup: SdkUsersGroupT; + onAfterSubmit?: VoidFunction; + }; + +export function UsersGroupUpdateFormModal( + { + usersGroup, + onAfterSubmit, + onClose, + ...props + }: UsersGroupUpdateFormModalProps, +) { + const t = useI18n().pack.usersGroups.form; + const { handleSubmitEvent, validator, submitState, bind } = useUsersGroupUpdateForm({ + defaultValue: usersGroup, + onAfterSubmit, + }); + + return ( + + {t.title.edit} + + )} + footer={( + <> + + + + )} + > + + + + + ); +} diff --git a/apps/chat/src/modules/users-groups/index.ts b/apps/chat/src/modules/users-groups/index.ts new file mode 100644 index 00000000..b3eece8a --- /dev/null +++ b/apps/chat/src/modules/users-groups/index.ts @@ -0,0 +1,2 @@ +export * from './form'; +export * from './table'; diff --git a/apps/chat/src/modules/users-groups/table/index.ts b/apps/chat/src/modules/users-groups/table/index.ts new file mode 100644 index 00000000..78dc74af --- /dev/null +++ b/apps/chat/src/modules/users-groups/table/index.ts @@ -0,0 +1,2 @@ +export * from './users-groups-table-container'; +export * from './users-groups-table-row'; diff --git a/apps/chat/src/modules/users-groups/table/users-groups-table-container.tsx b/apps/chat/src/modules/users-groups/table/users-groups-table-container.tsx new file mode 100644 index 00000000..ffa6d332 --- /dev/null +++ b/apps/chat/src/modules/users-groups/table/users-groups-table-container.tsx @@ -0,0 +1,98 @@ +import { flow, pipe } from 'fp-ts/lib/function'; + +import { tapTaskOption } from '@llm/commons'; +import { useAsyncCallback } from '@llm/commons-front'; +import { SdkSearchUsersGroupsInputV, useSdkForLoggedIn } from '@llm/sdk'; +import { + ArchiveFilterTabs, + CreateButton, + PaginatedTable, + PaginationSearchToolbarItem, + PaginationToolbar, + ResetFiltersButton, + useDebouncedPaginatedSearch, +} from '@llm/ui'; +import { useI18n } from '~/i18n'; +import { useWorkspaceOrganizationOrThrow } from '~/modules/workspace'; + +import { useUsersGroupCreateModal } from '../form'; +import { UsersGroupTableRow } from './users-groups-table-row'; + +export function UsersGroupsTableContainer() { + const { pack } = useI18n(); + const t = pack.table.columns; + + const { sdks } = useSdkForLoggedIn(); + const { assignWorkspaceToFilters } = useWorkspaceOrganizationOrThrow(); + + const { loading, pagination, result, reset, reload } = useDebouncedPaginatedSearch({ + schema: SdkSearchUsersGroupsInputV, + fallbackSearchParams: {}, + storeDataInUrl: false, + fetchResultsTask: flow(assignWorkspaceToFilters, sdks.dashboard.usersGroups.search), + }); + + const createModal = useUsersGroupCreateModal(); + const [onCreate, createState] = useAsyncCallback( + pipe( + createModal.showAsOptional({ + defaultValue: { + name: '', + users: [], + }, + }), + tapTaskOption(reset), + ), + ); + + return ( +
    + + + + + )} + > + ({ + ...newGlobalValue, + sort: newControlValue ? 'score:desc' : 'createdAt:asc', + }), + })} + /> + + + + + + {({ item }) => ( + + )} + +
    + ); +} diff --git a/apps/chat/src/modules/users-groups/table/users-groups-table-row.tsx b/apps/chat/src/modules/users-groups/table/users-groups-table-row.tsx new file mode 100644 index 00000000..47555cd8 --- /dev/null +++ b/apps/chat/src/modules/users-groups/table/users-groups-table-row.tsx @@ -0,0 +1,50 @@ +import { pipe } from 'fp-ts/lib/function'; + +import { formatDate, tapTaskEither, tapTaskOption } from '@llm/commons'; +import { type SdkSearchUsersGroupItemT, useSdkForLoggedIn } from '@llm/sdk'; +import { ArchivedBadge, EllipsisCrudDropdownButton } from '@llm/ui'; + +import { useUsersGroupUpdateModal } from '../form'; + +type Props = { + item: SdkSearchUsersGroupItemT; + onUpdated: VoidFunction; +}; + +export function UsersGroupTableRow({ item, onUpdated }: Props) { + const { sdks } = useSdkForLoggedIn(); + const updateModal = useUsersGroupUpdateModal(); + + return ( + + {item.id} + {item.name} + {item.users.length} + + {formatDate(item.updatedAt)} + {formatDate(item.createdAt)} + + + + + ); +} diff --git a/apps/chat/src/modules/users/choose-users/choose-users-modal.tsx b/apps/chat/src/modules/users/choose-users/choose-users-modal.tsx new file mode 100644 index 00000000..21eae4dc --- /dev/null +++ b/apps/chat/src/modules/users/choose-users/choose-users-modal.tsx @@ -0,0 +1,85 @@ +import { useControlStrict } from '@under-control/forms'; + +import type { SdkUserListItemT } from '@llm/sdk'; + +import { findItemById, rejectById } from '@llm/commons'; +import { Modal, type ModalProps, ModalTitle, SaveButton, SelectRecordButton } from '@llm/ui'; +import { useI18n } from '~/i18n'; + +import { UsersTableContainer } from '../table'; + +export type ChooseUsersModalProps = + & Omit + & { + selectedUsers?: SdkUserListItemT[]; + onSelected: (users: SdkUserListItemT[]) => void; + }; + +export function ChooseUsersModal( + { + selectedUsers, + onSelected, + onClose, + ...props + }: ChooseUsersModalProps, +) { + const t = useI18n().pack.users.chooseUsersModal; + const { value, setValue } = useControlStrict({ + defaultValue: selectedUsers || [], + }); + + const renderCTARowButton = (user: SdkUserListItemT) => { + const selected = !!findItemById(user.id)(value); + + return ( +
    + { + const rejectedUsers = rejectById(user.id)(value); + + if (selected) { + setValue({ value: rejectedUsers }); + } + else { + setValue({ value: [user, ...rejectedUsers] }); + } + }} + /> +
    + ); + }; + + return ( + + {t.title} + + )} + footer={( + { + onSelected(value); + }} + /> + )} + > + ({ + ctaButton: renderCTARowButton(item), + })} + /> + + ); +} diff --git a/apps/chat/src/modules/users/choose-users/index.ts b/apps/chat/src/modules/users/choose-users/index.ts new file mode 100644 index 00000000..63bb5aca --- /dev/null +++ b/apps/chat/src/modules/users/choose-users/index.ts @@ -0,0 +1,2 @@ +export * from './choose-users-modal'; +export * from './use-choose-users-modal'; diff --git a/apps/chat/src/modules/users/choose-users/use-choose-users-modal.tsx b/apps/chat/src/modules/users/choose-users/use-choose-users-modal.tsx new file mode 100644 index 00000000..d1b3ec51 --- /dev/null +++ b/apps/chat/src/modules/users/choose-users/use-choose-users-modal.tsx @@ -0,0 +1,27 @@ +import type { SdkUserListItemT } from '@llm/sdk'; + +import { useAnimatedModal } from '@llm/commons-front'; + +import { + ChooseUsersModal, + type ChooseUsersModalProps, +} from './choose-users-modal'; + +type ChooseUsersShowModalProps = Omit; + +export function useChooseUsersModal() { + return useAnimatedModal({ + renderModalContent: ({ showProps, hiding, onAnimatedClose }) => ( + { + void onAnimatedClose(users); + }} + onClose={() => { + void onAnimatedClose(); + }} + /> + ), + }); +} diff --git a/apps/chat/src/modules/users/form/create/fields/index.ts b/apps/chat/src/modules/users/form/create/fields/index.ts new file mode 100644 index 00000000..cd0c6b96 --- /dev/null +++ b/apps/chat/src/modules/users/form/create/fields/index.ts @@ -0,0 +1,2 @@ +export * from './user-create-auth-methods-form-field'; +export * from './user-organization-settings-form-field'; diff --git a/apps/chat/src/modules/users/form/create/fields/user-create-auth-methods-form-field.tsx b/apps/chat/src/modules/users/form/create/fields/user-create-auth-methods-form-field.tsx new file mode 100644 index 00000000..ab785bf2 --- /dev/null +++ b/apps/chat/src/modules/users/form/create/fields/user-create-auth-methods-form-field.tsx @@ -0,0 +1,77 @@ +import { + controlled, + useFormValidatorMessages, + type ValidationErrorsListProps, +} from '@under-control/forms'; + +import type { SdkCreateUserAuthMethodsT } from '@llm/sdk'; + +import { genRandomPassword } from '@llm/commons'; +import { Checkbox, FormField, Input } from '@llm/ui'; +import { useI18n } from '~/i18n'; + +type Props = ValidationErrorsListProps; + +export const UserCreateAuthMethodsFormField = controlled(({ + errors, + control: { bind, value, setValue }, +}) => { + const t = useI18n().pack.users.form; + const validation = useFormValidatorMessages({ errors }); + + const onTogglePassword = (passwordEnabled: boolean) => { + setValue({ + merge: true, + value: { + password: passwordEnabled + ? { + enabled: true, + value: genRandomPassword(), + } + : { enabled: false }, + }, + }); + }; + + return ( + <> + + + {t.fields.auth.email.label} + + + + {t.fields.auth.password.label} + + + + {value.password.enabled && ( + + + + )} + + ); +}); diff --git a/apps/chat/src/modules/users/form/create/fields/user-organization-settings-form-field.tsx b/apps/chat/src/modules/users/form/create/fields/user-organization-settings-form-field.tsx new file mode 100644 index 00000000..48a9c526 --- /dev/null +++ b/apps/chat/src/modules/users/form/create/fields/user-organization-settings-form-field.tsx @@ -0,0 +1,37 @@ +import { + controlled, + useFormValidatorMessages, + type ValidationErrorsListProps, +} from '@under-control/forms'; + +import { FormField } from '@llm/ui'; +import { useI18n } from '~/i18n'; +import { UserOrganizationRoleSelect } from '~/modules/organizations'; + +import type { CreateUserOrganizationValue } from '../types'; + +type Props = ValidationErrorsListProps; + +export const UserOrganizationSettingsFormField = controlled(( + { + errors, + control: { bind }, + }, +) => { + const t = useI18n().pack.users.form.fields.organization; + const validation = useFormValidatorMessages({ errors }); + + return ( + <> + + + + +
    + + ); +}); diff --git a/apps/chat/src/modules/users/form/create/index.ts b/apps/chat/src/modules/users/form/create/index.ts new file mode 100644 index 00000000..1a9d9c89 --- /dev/null +++ b/apps/chat/src/modules/users/form/create/index.ts @@ -0,0 +1,3 @@ +export * from './use-user-create-form'; +export * from './use-user-create-modal'; +export * from './user-create-form-modal'; diff --git a/apps/chat/src/modules/users/form/create/types.ts b/apps/chat/src/modules/users/form/create/types.ts new file mode 100644 index 00000000..d1ee0eb5 --- /dev/null +++ b/apps/chat/src/modules/users/form/create/types.ts @@ -0,0 +1,12 @@ +import type { SdkCreateUserInputT, SdkCreateUserOrganizationInputT } from '@llm/sdk'; +import type { SelectItem } from '@llm/ui'; + +export type CreateUserOrganizationValue = SdkCreateUserOrganizationInputT & { + item: SelectItem; +}; + +export type CreateUserFormValue = + Extract | + Extract & { + organization: CreateUserOrganizationValue; + }; diff --git a/apps/chat/src/modules/users/form/create/use-user-create-form.tsx b/apps/chat/src/modules/users/form/create/use-user-create-form.tsx new file mode 100644 index 00000000..333f5816 --- /dev/null +++ b/apps/chat/src/modules/users/form/create/use-user-create-form.tsx @@ -0,0 +1,53 @@ +import { type FormHookAttrs, useForm } from '@under-control/forms'; +import { flow } from 'fp-ts/lib/function'; + +import { isObjectWithFakeID, runTask, tapTaskEither } from '@llm/commons'; +import { useSdkForLoggedIn } from '@llm/sdk'; +import { usePredefinedFormValidators, useSaveTaskEitherNotification } from '@llm/ui'; + +import type { CreateUserFormValue } from './types'; + +import { useUseAuthFormValidator } from '../shared'; + +type CreateUserFormHookAttrs = + & Omit< + FormHookAttrs, + 'validation' | 'onSubmit' + > + & { + onAfterSubmit?: VoidFunction; + }; + +export function useUserCreateForm( + { + onAfterSubmit, + ...props + }: CreateUserFormHookAttrs, +) { + const { sdks } = useSdkForLoggedIn(); + const { emailFormatValidator, requiredPathByPred } = usePredefinedFormValidators(); + const saveNotifications = useSaveTaskEitherNotification(); + const authValidator = useUseAuthFormValidator(); + + return useForm({ + resetAfterSubmit: false, + onSubmit: flow( + sdks.dashboard.users.create, + saveNotifications, + tapTaskEither(() => onAfterSubmit?.()), + runTask, + ), + validation: { + mode: ['blur', 'submit'], + validators: () => [ + emailFormatValidator('email'), + authValidator('auth'), + requiredPathByPred( + 'organization', + ({ globalValue, value }) => globalValue.role === 'user' && (!value?.item || isObjectWithFakeID(value.item)), + ), + ], + }, + ...props, + }); +} diff --git a/apps/chat/src/modules/users/form/create/use-user-create-modal.tsx b/apps/chat/src/modules/users/form/create/use-user-create-modal.tsx new file mode 100644 index 00000000..67c421bf --- /dev/null +++ b/apps/chat/src/modules/users/form/create/use-user-create-modal.tsx @@ -0,0 +1,32 @@ +import { useAnimatedModal } from '@llm/commons-front'; + +import type { CreateUserFormValue } from './types'; + +import { + UserCreateFormModal, + type UserCreateFormModalProps, +} from './user-create-form-modal'; + +type UserShowModalProps = + & Pick + & { + defaultValue: CreateUserFormValue; + }; + +export function useUserCreateModal() { + return useAnimatedModal({ + renderModalContent: ({ showProps, hiding, onAnimatedClose }) => ( + { + void onAnimatedClose(true); + showProps?.onAfterSubmit?.(); + }} + onClose={() => { + void onAnimatedClose(); + }} + /> + ), + }); +} diff --git a/apps/chat/src/modules/users/form/create/user-create-form-modal.tsx b/apps/chat/src/modules/users/form/create/user-create-form-modal.tsx new file mode 100644 index 00000000..73c9dcdd --- /dev/null +++ b/apps/chat/src/modules/users/form/create/user-create-form-modal.tsx @@ -0,0 +1,79 @@ +import { + CancelButton, + CreateButton, + FormErrorAlert, + Modal, + type ModalProps, + ModalTitle, +} from '@llm/ui'; +import { useI18n } from '~/i18n'; + +import type { + CreateUserFormValue, +} from './types'; + +import { UserSharedFormFields } from '../shared'; +import { UserCreateAuthMethodsFormField, UserOrganizationSettingsFormField } from './fields'; +import { useUserCreateForm } from './use-user-create-form'; + +export type UserCreateFormModalProps = + & Omit + & { + defaultValue: CreateUserFormValue; + onAfterSubmit?: VoidFunction; + }; + +export function UserCreateFormModal( + { + defaultValue, + onAfterSubmit, + onClose, + ...props + }: UserCreateFormModalProps, +) { + const t = useI18n().pack.users.form; + const { handleSubmitEvent, validator, submitState, bind, value } = useUserCreateForm({ + defaultValue, + onAfterSubmit, + }); + + return ( + + {t.title.create} + + )} + footer={( + <> + + + + )} + > + {value.role === 'user' && ( + + )} + + + + + + + + ); +} diff --git a/apps/chat/src/modules/users/form/index.ts b/apps/chat/src/modules/users/form/index.ts new file mode 100644 index 00000000..63cc03c7 --- /dev/null +++ b/apps/chat/src/modules/users/form/index.ts @@ -0,0 +1,3 @@ +export * from './create'; +export * from './shared'; +export * from './update'; diff --git a/apps/chat/src/modules/users/form/shared/index.ts b/apps/chat/src/modules/users/form/shared/index.ts new file mode 100644 index 00000000..bb5fe6eb --- /dev/null +++ b/apps/chat/src/modules/users/form/shared/index.ts @@ -0,0 +1,2 @@ +export * from './use-auth-form-validator'; +export * from './user-shared-form-fields'; diff --git a/apps/chat/src/modules/users/form/shared/use-auth-form-validator.ts b/apps/chat/src/modules/users/form/shared/use-auth-form-validator.ts new file mode 100644 index 00000000..3d498504 --- /dev/null +++ b/apps/chat/src/modules/users/form/shared/use-auth-form-validator.ts @@ -0,0 +1,43 @@ +import { + type ControlValue, + error, + type GetAllObjectPaths, + type GetAllObjectPathsEntries, + type PathValidator, +} from '@under-control/forms'; + +import { format } from '@llm/commons'; +import { SDK_MIN_PASSWORD_LENGTH, type SdkCreateUserAuthMethodsT } from '@llm/sdk'; +import { useI18n } from '~/i18n'; + +type AllAuthObjectPaths = Extract, { + type: { + password: { + enabled: boolean; + value?: string | null; + }; + }; +}>['path']; + +export function useUseAuthFormValidator() { + const t = useI18n().pack.validation.password; + + return

    & AllAuthObjectPaths>(path: P): PathValidator => ({ + path, + fn: ({ value }) => { + const castedValue = value as SdkCreateUserAuthMethodsT; + + if ( + castedValue.password + && 'value' in castedValue.password + && castedValue.password.value.length < SDK_MIN_PASSWORD_LENGTH + ) { + return error( + format(t.mustBeLongerThan, { number: SDK_MIN_PASSWORD_LENGTH }), + null, + `${path}.password.value`, + ); + } + }, + }); +} diff --git a/apps/chat/src/modules/users/form/shared/user-shared-form-fields.tsx b/apps/chat/src/modules/users/form/shared/user-shared-form-fields.tsx new file mode 100644 index 00000000..1db22658 --- /dev/null +++ b/apps/chat/src/modules/users/form/shared/user-shared-form-fields.tsx @@ -0,0 +1,46 @@ +import { controlled, useFormValidatorMessages, type ValidationErrorsListProps } from '@under-control/forms'; + +import type { SdkUserT } from '@llm/sdk'; + +import { Checkbox, FormField, Input } from '@llm/ui'; +import { useI18n } from '~/i18n'; + +type Value = Pick; + +type Props = ValidationErrorsListProps; + +export const UserSharedFormFields = controlled(({ errors, control: { bind } }) => { + const t = useI18n().pack.users.form; + const validation = useFormValidatorMessages({ errors }); + + return ( + <> + + + + + + + {t.fields.active.label} + + + + ); +}); diff --git a/apps/chat/src/modules/users/form/update/fields/index.ts b/apps/chat/src/modules/users/form/update/fields/index.ts new file mode 100644 index 00000000..4244f6db --- /dev/null +++ b/apps/chat/src/modules/users/form/update/fields/index.ts @@ -0,0 +1,2 @@ +export * from './user-organization-settings-form-field'; +export * from './user-update-auth-methods-form-field'; diff --git a/apps/chat/src/modules/users/form/update/fields/user-organization-settings-form-field.tsx b/apps/chat/src/modules/users/form/update/fields/user-organization-settings-form-field.tsx new file mode 100644 index 00000000..2f9d9056 --- /dev/null +++ b/apps/chat/src/modules/users/form/update/fields/user-organization-settings-form-field.tsx @@ -0,0 +1,37 @@ +import { + controlled, + useFormValidatorMessages, + type ValidationErrorsListProps, +} from '@under-control/forms'; + +import type { SdkUpdateUserOrganizationInputT } from '@llm/sdk'; + +import { FormField } from '@llm/ui'; +import { useI18n } from '~/i18n'; +import { UserOrganizationRoleSelect } from '~/modules/organizations'; + +type Props = ValidationErrorsListProps; + +export const UserOrganizationSettingsFormField = controlled(( + { + errors, + control: { bind }, + }, +) => { + const t = useI18n().pack.users.form.fields.organization; + const validation = useFormValidatorMessages({ errors }); + + return ( + <> + + + + +


    + + ); +}); diff --git a/apps/chat/src/modules/users/form/update/fields/user-update-auth-methods-form-field.tsx b/apps/chat/src/modules/users/form/update/fields/user-update-auth-methods-form-field.tsx new file mode 100644 index 00000000..293ac5fd --- /dev/null +++ b/apps/chat/src/modules/users/form/update/fields/user-update-auth-methods-form-field.tsx @@ -0,0 +1,98 @@ +import { + controlled, + useFormValidatorMessages, + type ValidationErrorsListProps, +} from '@under-control/forms'; +import { useMemo } from 'react'; + +import type { SdkUpdateUserAuthMethodsT } from '@llm/sdk'; + +import { genRandomPassword } from '@llm/commons'; +import { Checkbox, FormField, Input } from '@llm/ui'; +import { useI18n } from '~/i18n'; + +type Props = ValidationErrorsListProps; + +export const UserUpdateAuthMethodsFormField = controlled(({ + errors, + control: { bind, value, setValue }, +}) => { + const t = useI18n().pack.users.form; + const validation = useFormValidatorMessages({ errors }); + + const hasInitiallyEnabledPassword = useMemo(() => value.password.enabled, []); + + const isResetPassword = 'value' in value.password; + const onResetPassword = (resetPassword: boolean) => { + setValue({ + merge: true, + value: { + password: { + enabled: true, + ...resetPassword ? { value: '' } : {}, + }, + }, + }); + }; + + return ( + <> + + + {t.fields.auth.email.label} + + + ({ + ...newGlobalValue, + password: { + enabled: newControlValue, + ...!hasInitiallyEnabledPassword && { + value: genRandomPassword(), + }, + }, + }), + })} + > + {t.fields.auth.password.label} + + + {hasInitiallyEnabledPassword && ( + + {t.fields.auth.resetPassword.label} + + )} + + + {(isResetPassword || (!hasInitiallyEnabledPassword && value.password.enabled)) && ( + + + + )} + + ); +}); diff --git a/apps/chat/src/modules/users/form/update/index.ts b/apps/chat/src/modules/users/form/update/index.ts new file mode 100644 index 00000000..1b0fc35c --- /dev/null +++ b/apps/chat/src/modules/users/form/update/index.ts @@ -0,0 +1,3 @@ +export * from './use-user-update-form'; +export * from './use-user-update-modal'; +export * from './user-update-form-modal'; diff --git a/apps/chat/src/modules/users/form/update/types.ts b/apps/chat/src/modules/users/form/update/types.ts new file mode 100644 index 00000000..50222338 --- /dev/null +++ b/apps/chat/src/modules/users/form/update/types.ts @@ -0,0 +1,8 @@ +import type { + SdkTableRowWithIdT, + SdkUpdateUserInputT, +} from '@llm/sdk'; + +export type UpdateUserFormValue = + SdkTableRowWithIdT & + SdkUpdateUserInputT; diff --git a/apps/chat/src/modules/users/form/update/use-user-update-form.tsx b/apps/chat/src/modules/users/form/update/use-user-update-form.tsx new file mode 100644 index 00000000..98cd2e40 --- /dev/null +++ b/apps/chat/src/modules/users/form/update/use-user-update-form.tsx @@ -0,0 +1,49 @@ +import { type FormHookAttrs, useForm } from '@under-control/forms'; +import { flow } from 'fp-ts/lib/function'; + +import { runTask, tapTaskEither } from '@llm/commons'; +import { useSdkForLoggedIn } from '@llm/sdk'; +import { usePredefinedFormValidators, useSaveTaskEitherNotification } from '@llm/ui'; + +import type { UpdateUserFormValue } from './types'; + +import { useUseAuthFormValidator } from '../shared'; + +type UpdateUserFormHookAttrs = + & Omit< + FormHookAttrs, + 'validation' | 'onSubmit' + > + & { + onAfterSubmit?: VoidFunction; + }; + +export function useUserUpdateForm( + { + onAfterSubmit, + ...props + }: UpdateUserFormHookAttrs, +) { + const { sdks } = useSdkForLoggedIn(); + const { emailFormatValidator } = usePredefinedFormValidators(); + const saveNotifications = useSaveTaskEitherNotification(); + const authValidator = useUseAuthFormValidator(); + + return useForm({ + resetAfterSubmit: false, + onSubmit: flow( + sdks.dashboard.users.update, + saveNotifications, + tapTaskEither(() => onAfterSubmit?.()), + runTask, + ), + validation: { + mode: ['blur', 'submit'], + validators: () => [ + emailFormatValidator('email'), + authValidator('auth'), + ], + }, + ...props, + }); +} diff --git a/apps/chat/src/modules/users/form/update/use-user-update-modal.tsx b/apps/chat/src/modules/users/form/update/use-user-update-modal.tsx new file mode 100644 index 00000000..9c1588ec --- /dev/null +++ b/apps/chat/src/modules/users/form/update/use-user-update-modal.tsx @@ -0,0 +1,32 @@ +import type { SdkUserT } from '@llm/sdk'; + +import { useAnimatedModal } from '@llm/commons-front'; + +import { + UserUpdateFormModal, + type UserUpdateFormModalProps, +} from './user-update-form-modal'; + +type UserShowModalProps = + & Pick + & { + user: SdkUserT; + }; + +export function useUserUpdateModal() { + return useAnimatedModal({ + renderModalContent: ({ showProps, hiding, onAnimatedClose }) => ( + { + void onAnimatedClose(true); + showProps?.onAfterSubmit?.(); + }} + onClose={() => { + void onAnimatedClose(); + }} + /> + ), + }); +} diff --git a/apps/chat/src/modules/users/form/update/user-update-form-modal.tsx b/apps/chat/src/modules/users/form/update/user-update-form-modal.tsx new file mode 100644 index 00000000..26d63ee9 --- /dev/null +++ b/apps/chat/src/modules/users/form/update/user-update-form-modal.tsx @@ -0,0 +1,105 @@ +import { useMemo } from 'react'; + +import type { SdkUserT } from '@llm/sdk'; + +import { + CancelButton, + FormErrorAlert, + Modal, + type ModalProps, + ModalTitle, + UpdateButton, +} from '@llm/ui'; +import { useI18n } from '~/i18n'; + +import type { UpdateUserFormValue } from './types'; + +import { UserSharedFormFields } from '../shared'; +import { UserOrganizationSettingsFormField, UserUpdateAuthMethodsFormField } from './fields'; +import { useUserUpdateForm } from './use-user-update-form'; + +export type UserUpdateFormModalProps = + & Omit + & { + user: SdkUserT; + onAfterSubmit?: VoidFunction; + }; + +export function UserUpdateFormModal( + { + user, + onAfterSubmit, + onClose, + ...props + }: UserUpdateFormModalProps, +) { + const t = useI18n().pack.users.form; + + const defaultValue = useMemo(() => { + const attrs = { + id: user.id, + active: user.active, + archiveProtection: user.archiveProtection, + auth: user.auth, + email: user.email, + }; + + return ( + user.role === 'user' + ? { + ...attrs, + role: 'user', + organization: user.organization, + } + : { + ...attrs, + role: 'root', + } + ); + }, [user]); + + const { handleSubmitEvent, validator, submitState, bind, value } = useUserUpdateForm({ + defaultValue, + onAfterSubmit, + }); + + return ( + + {t.title.edit} + + )} + footer={( + <> + + + + )} + > + {value.role === 'user' && ( + + )} + + + + + + + + ); +} diff --git a/apps/chat/src/modules/users/index.ts b/apps/chat/src/modules/users/index.ts new file mode 100644 index 00000000..de6efddf --- /dev/null +++ b/apps/chat/src/modules/users/index.ts @@ -0,0 +1,3 @@ +export * from './choose-users'; +export * from './form'; +export * from './table'; diff --git a/apps/chat/src/modules/users/table/index.ts b/apps/chat/src/modules/users/table/index.ts new file mode 100644 index 00000000..9e9a2a6e --- /dev/null +++ b/apps/chat/src/modules/users/table/index.ts @@ -0,0 +1,2 @@ +export * from './users-table-container'; +export * from './users-table-row'; diff --git a/apps/chat/src/modules/users/table/users-table-container.tsx b/apps/chat/src/modules/users/table/users-table-container.tsx new file mode 100644 index 00000000..f79f3bda --- /dev/null +++ b/apps/chat/src/modules/users/table/users-table-container.tsx @@ -0,0 +1,119 @@ +import { flow, pipe } from 'fp-ts/lib/function'; + +import { genRandomPassword, tapTaskOption } from '@llm/commons'; +import { useAsyncCallback } from '@llm/commons-front'; +import { type SdkSearchUserItemT, SdkSearchUsersInputV, useSdkForLoggedIn } from '@llm/sdk'; +import { + ArchiveFilterTabs, + CreateButton, + PaginatedTable, + PaginationSearchToolbarItem, + PaginationToolbar, + ResetFiltersButton, + useDebouncedPaginatedSearch, +} from '@llm/ui'; +import { useI18n } from '~/i18n'; +import { useWorkspaceOrganizationOrThrow } from '~/modules/workspace'; + +import { useUserCreateModal } from '../form'; +import { UsersTableRow, type UsersTableRowProps } from './users-table-row'; + +type Props = { + itemPropsFn?: (item: SdkSearchUserItemT) => Omit; +}; + +export function UsersTableContainer({ itemPropsFn }: Props) { + const { pack } = useI18n(); + const t = pack.table.columns; + + const { sdks } = useSdkForLoggedIn(); + const { organization, assignWorkspaceToFilters } = useWorkspaceOrganizationOrThrow(); + + const { loading, pagination, result, reset, reload } = useDebouncedPaginatedSearch({ + schema: SdkSearchUsersInputV, + fallbackSearchParams: {}, + storeDataInUrl: false, + fetchResultsTask: flow(assignWorkspaceToFilters, sdks.dashboard.users.search), + }); + + const createModal = useUserCreateModal(); + const [onCreate, createState] = useAsyncCallback( + pipe( + createModal.showAsOptional({ + defaultValue: { + email: '', + role: 'user', + active: true, + archiveProtection: false, + organization: { + item: organization, + role: 'member', + }, + auth: { + email: { + enabled: true, + }, + password: { + enabled: true, + value: genRandomPassword(), + }, + }, + }, + }), + tapTaskOption(reset), + ), + ); + + return ( +
    + + + + + )} + > + ({ + ...newGlobalValue, + sort: newControlValue ? 'score:desc' : 'createdAt:asc', + }), + })} + /> + + + + + + {({ item }) => ( + + )} + +
    + ); +} diff --git a/apps/chat/src/modules/users/table/users-table-row.tsx b/apps/chat/src/modules/users/table/users-table-row.tsx new file mode 100644 index 00000000..c9b10147 --- /dev/null +++ b/apps/chat/src/modules/users/table/users-table-row.tsx @@ -0,0 +1,86 @@ +import type { ReactNode } from 'react'; + +import { pipe } from 'fp-ts/lib/function'; +import { KeyRoundIcon, MailIcon } from 'lucide-react'; + +import { formatDate, tapTaskEither, tapTaskOption } from '@llm/commons'; +import { type SdkSearchUserItemT, useSdkForLoggedIn } from '@llm/sdk'; +import { ArchivedBadge, BooleanBadge, EllipsisCrudDropdownButton } from '@llm/ui'; +import { useI18n } from '~/i18n'; +import { OrganizationUserRoleBadge } from '~/modules/organizations'; + +import { useUserUpdateModal } from '../form'; + +export type UsersTableRowProps = { + item: SdkSearchUserItemT; + ctaButton?: ReactNode; + onUpdated: VoidFunction; +}; + +export function UsersTableRow({ item, ctaButton, onUpdated }: UsersTableRowProps) { + const { pack } = useI18n(); + const t = pack.users; + + const { sdks } = useSdkForLoggedIn(); + const { auth } = item; + + const updateModal = useUserUpdateModal(); + + if (item.role !== 'user') { + return null; + } + + return ( + + {item.id} + {item.email} + + + + + +
    + {auth.email?.enabled && ( + + + + )} + + {auth.password?.enabled && ( + + + + )} +
    + + + {formatDate(item.updatedAt)} + + {ctaButton || ( + + )} + + + ); +} diff --git a/apps/chat/src/modules/workspace/use-workspace-organization.tsx b/apps/chat/src/modules/workspace/use-workspace-organization.tsx index 376b48f2..c2108978 100644 --- a/apps/chat/src/modules/workspace/use-workspace-organization.tsx +++ b/apps/chat/src/modules/workspace/use-workspace-organization.tsx @@ -1,4 +1,4 @@ -import { useSdkForLoggedIn } from '@llm/sdk'; +import { type SdkTableRowIdT, useSdkForLoggedIn } from '@llm/sdk'; import { useWorkspace } from './workspace-context'; @@ -22,6 +22,12 @@ export function useWorkspaceOrganization() { ...obj, ...organization && { organization }, }), + assignWorkspaceToFilters: (obj: D) => ({ + ...obj, + ...organization && { + organizationIds: [organization.id], + }, + }), }; } @@ -36,7 +42,11 @@ export function useWorkspaceOrganizationOrThrow() { organization, assignWorkspaceOrganization: (obj: D) => ({ ...obj, - ...organization && { organization }, + organization, + }), + assignWorkspaceToFilters: (obj: D) => ({ + ...obj, + organizationIds: [organization.id], }), }; } diff --git a/apps/chat/src/router.tsx b/apps/chat/src/router.tsx index 932c0aee..c4cbbbb7 100644 --- a/apps/chat/src/router.tsx +++ b/apps/chat/src/router.tsx @@ -10,9 +10,12 @@ import { ForceRedirectRoute, HomeRoute, LoginRoute, + ManagementRoute, ProjectRoute, ProjectsRoute, SettingsRoute, + UsersGroupsManagementRoute, + UsersManagementRoute, useSitemap, } from '~/routes'; @@ -64,6 +67,9 @@ function LoggedInRouter() { + + + path={sitemap.chat.raw}> diff --git a/apps/chat/src/routes/index.ts b/apps/chat/src/routes/index.ts index cda4a698..63279385 100644 --- a/apps/chat/src/routes/index.ts +++ b/apps/chat/src/routes/index.ts @@ -6,6 +6,7 @@ export * from './experts'; export * from './force-redirect-route'; export * from './home'; export * from './login'; +export * from './management'; export * from './project'; export * from './projects'; export * from './settings'; diff --git a/apps/chat/src/routes/management/index.ts b/apps/chat/src/routes/management/index.ts new file mode 100644 index 00000000..6bcbec9a --- /dev/null +++ b/apps/chat/src/routes/management/index.ts @@ -0,0 +1,3 @@ +export * from './layout'; +export * from './management.route'; +export * from './pages'; diff --git a/apps/chat/src/routes/management/layout/index.ts b/apps/chat/src/routes/management/layout/index.ts new file mode 100644 index 00000000..d641ae67 --- /dev/null +++ b/apps/chat/src/routes/management/layout/index.ts @@ -0,0 +1 @@ +export * from './management.layout'; diff --git a/apps/chat/src/routes/management/layout/management.layout.tsx b/apps/chat/src/routes/management/layout/management.layout.tsx new file mode 100644 index 00000000..9053ec4a --- /dev/null +++ b/apps/chat/src/routes/management/layout/management.layout.tsx @@ -0,0 +1,54 @@ +import type { PropsWithChildren } from 'react'; + +import { UserCircleIcon, UsersIcon } from 'lucide-react'; + +import { SideLayout, SideNav, SideNavItem } from '@llm/ui'; +import { useI18n } from '~/i18n'; +import { LayoutHeader, PageWithNavigationLayout } from '~/layouts'; +import { RouteMetaTags, useSitemap } from '~/routes'; + +type Props = PropsWithChildren & { + title: string; +}; + +export function ManagementLayout({ title, children }: Props) { + const t = useI18n().pack.routes.management; + const sitemap = useSitemap(); + + return ( + + + + + {t.title} + + + + } + href={sitemap.management.users} + > + {t.pages.users.title} + + + } + href={sitemap.management.usersGroups} + > + {t.pages.usersGroups.title} + + + )} + > + {children} + + + ); +} diff --git a/apps/chat/src/routes/management/management.route.tsx b/apps/chat/src/routes/management/management.route.tsx new file mode 100644 index 00000000..c3589541 --- /dev/null +++ b/apps/chat/src/routes/management/management.route.tsx @@ -0,0 +1,9 @@ +import { Redirect } from 'wouter'; + +import { useSitemap } from '../use-sitemap'; + +export function ManagementRoute() { + const sitemap = useSitemap(); + + return ; +} diff --git a/apps/chat/src/routes/management/pages/index.ts b/apps/chat/src/routes/management/pages/index.ts new file mode 100644 index 00000000..bf5f5eec --- /dev/null +++ b/apps/chat/src/routes/management/pages/index.ts @@ -0,0 +1,2 @@ +export * from './users-groups-management.route'; +export * from './users-management.route'; diff --git a/apps/chat/src/routes/management/pages/users-groups-management.route.tsx b/apps/chat/src/routes/management/pages/users-groups-management.route.tsx new file mode 100644 index 00000000..fa464dcd --- /dev/null +++ b/apps/chat/src/routes/management/pages/users-groups-management.route.tsx @@ -0,0 +1,17 @@ +import { ContentCard } from '@llm/ui'; +import { useI18n } from '~/i18n'; +import { UsersGroupsTableContainer } from '~/modules/users-groups'; + +import { ManagementLayout } from '../layout'; + +export function UsersGroupsManagementRoute() { + const t = useI18n().pack.routes.management.pages.usersGroups; + + return ( + + + + + + ); +} diff --git a/apps/chat/src/routes/management/pages/users-management.route.tsx b/apps/chat/src/routes/management/pages/users-management.route.tsx new file mode 100644 index 00000000..cbee9428 --- /dev/null +++ b/apps/chat/src/routes/management/pages/users-management.route.tsx @@ -0,0 +1,17 @@ +import { ContentCard } from '@llm/ui'; +import { useI18n } from '~/i18n'; +import { UsersTableContainer } from '~/modules/users'; + +import { ManagementLayout } from '../layout'; + +export function UsersManagementRoute() { + const t = useI18n().pack.routes.management.pages.users; + + return ( + + + + + + ); +} diff --git a/apps/chat/src/routes/settings/settings.route.tsx b/apps/chat/src/routes/settings/settings.route.tsx index 0554de55..2664f206 100644 --- a/apps/chat/src/routes/settings/settings.route.tsx +++ b/apps/chat/src/routes/settings/settings.route.tsx @@ -1,20 +1,16 @@ import { BellRing, Bot, Database, Shield, Trash2, UserCircle } from 'lucide-react'; import { useState } from 'react'; +import { SideLayout, SideNav, SideNavItem } from '@llm/ui'; import { useI18n } from '~/i18n'; import { LayoutHeader, PageWithNavigationLayout } from '~/layouts'; import { RouteMetaTags } from '~/routes/shared'; -const SETTINGS_SECTIONS = [ - { id: 'account', label: 'Account Settings', icon: UserCircle }, - { id: 'security', label: 'Security & Privacy', icon: Shield }, - { id: 'notifications', label: 'Notifications', icon: BellRing }, - { id: 'data', label: 'Data Management', icon: Database }, -] as const; +type SectionId = 'account' | 'security' | 'notifications' | 'data'; export function SettingsRoute() { const t = useI18n().pack.routes.settings; - const [activeSection, setActiveSection] = useState('account'); + const [activeSection, setActiveSection] = useState('account'); return ( @@ -22,119 +18,133 @@ export function SettingsRoute() { {t.title} -
    - + + } + isActive={activeSection === 'account'} + onClick={() => setActiveSection('account')} + > + Account Settings + -
    - {activeSection === 'account' && ( -
    -
    -

    Profile Information

    -
    -
    - - -
    -
    - - -
    -
    -
    + } + isActive={activeSection === 'security'} + onClick={() => setActiveSection('security')} + > + Security & Privacy + + + } + isActive={activeSection === 'notifications'} + onClick={() => setActiveSection('notifications')} + > + Notifications + -
    -

    - - Default AI Model -

    + } + isActive={activeSection === 'data'} + onClick={() => setActiveSection('data')} + > + Data Management + + + )} + > + {activeSection === 'account' && ( +
    +
    +

    Profile Information

    +
    - - + +
    -
    - -
    -

    Danger Zone

    -
    -

    Delete Account

    -

    - Once you delete your account, there is no going back. Please be certain. -

    - +
    + +
    -
    -
    - )} +
    + - {activeSection === 'data' && ( -
    -
    -

    Data Export

    -

    - Download a copy of your data including your profile information, conversations, and settings. +

    +

    + + Default AI Model +

    +
    + + +
    +
    + +
    +

    Danger Zone

    +
    +

    Delete Account

    +

    + Once you delete your account, there is no going back. Please be certain.

    - -
    -
    - )} + +
    + +
    + )} - {activeSection === 'security' && ( -
    -
    -

    Password

    - -
    + {activeSection === 'data' && ( +
    +
    +

    Data Export

    +

    + Download a copy of your data including your profile information, conversations, and settings. +

    + +
    +
    + )} -
    -

    Two-Factor Authentication

    - -
    -
    - )} + {activeSection === 'security' && ( +
    +
    +

    Password

    + +
    - {activeSection === 'notifications' && ( -
    -
    -

    Email Notifications

    -
    - {['New messages', 'System updates', 'Newsletter'].map(item => ( - - ))} -
    -
    -
    - )} - -
    +
    +

    Two-Factor Authentication

    + +
    +
    + )} + + {activeSection === 'notifications' && ( +
    +
    +

    Email Notifications

    +
    + {['New messages', 'System updates', 'Newsletter'].map(item => ( + + ))} +
    +
    +
    + )} + ); } diff --git a/apps/chat/src/routes/use-sitemap.tsx b/apps/chat/src/routes/use-sitemap.tsx index 357af112..2663df5d 100644 --- a/apps/chat/src/routes/use-sitemap.tsx +++ b/apps/chat/src/routes/use-sitemap.tsx @@ -18,6 +18,11 @@ export function useSitemap() { login: prefixWithBaseRoute('/login'), settings: prefixWithBaseRoute('/settings'), chat: defineSitemapRouteGenerator(prefixWithBaseRoute)('/chat/:id'), + management: { + index: prefixWithBaseRoute('/management'), + users: prefixWithBaseRoute('/management/users'), + usersGroups: prefixWithBaseRoute('/management/users-groups'), + }, forceRedirect: { raw: prefixWithBaseRoute('/force-redirect'), generate: (targetUrl: string) => pipe( diff --git a/apps/admin/src/helpers/gen-random-password.tsx b/packages/commons/src/helpers/gen-random-password.ts similarity index 90% rename from apps/admin/src/helpers/gen-random-password.tsx rename to packages/commons/src/helpers/gen-random-password.ts index 70ded1e7..c66cf940 100644 --- a/apps/admin/src/helpers/gen-random-password.tsx +++ b/packages/commons/src/helpers/gen-random-password.ts @@ -1,4 +1,4 @@ -import { genRandomBetweenInclusive } from '@llm/commons'; +import { genRandomBetweenInclusive } from './gen-random-between-inclusive'; const RANDOM_PASSWORD_CHARACTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+[]{}|;:,.<>?'; diff --git a/packages/commons/src/helpers/index.ts b/packages/commons/src/helpers/index.ts index d1f7759a..9fbe7bf2 100644 --- a/packages/commons/src/helpers/index.ts +++ b/packages/commons/src/helpers/index.ts @@ -7,6 +7,7 @@ export * from './find-item-by-id'; export * from './find-item-index-by-id'; export * from './format'; export * from './gen-random-between-inclusive'; +export * from './gen-random-password'; export * from './get-first-obj-key-value'; export * from './invert'; export * from './is-nil'; diff --git a/packages/sdk/src/modules/dashboard/dashboard.sdk.ts b/packages/sdk/src/modules/dashboard/dashboard.sdk.ts index bb3e7b06..b0af4d3b 100644 --- a/packages/sdk/src/modules/dashboard/dashboard.sdk.ts +++ b/packages/sdk/src/modules/dashboard/dashboard.sdk.ts @@ -11,12 +11,15 @@ import { ProjectsEmbeddingsSdk } from './projects-embeddings'; import { ProjectsFilesSdk } from './projects-files'; import { S3BucketsSdk } from './s3-buckets'; import { UsersSdk } from './users'; +import { UsersGroupsSdk } from './users-groups'; export class DashboardSdk { public readonly organizations = new OrganizationsSdk(this.config); public readonly users = new UsersSdk(this.config); + public readonly usersGroups = new UsersGroupsSdk(this.config); + public readonly projects = new ProjectsSdk(this.config); public readonly projectsFiles = new ProjectsFilesSdk(this.config); diff --git a/packages/sdk/src/modules/dashboard/index.ts b/packages/sdk/src/modules/dashboard/index.ts index 2dea76f1..b2d25ebc 100644 --- a/packages/sdk/src/modules/dashboard/index.ts +++ b/packages/sdk/src/modules/dashboard/index.ts @@ -9,6 +9,8 @@ export * from './organizations'; export * from './projects'; export * from './projects-embeddings'; export * from './projects-files'; +export * from './projects-policies'; export * from './s3-buckets'; export * from './s3-files'; export * from './users'; +export * from './users-groups'; diff --git a/packages/sdk/src/modules/dashboard/projects-policies/dto/index.ts b/packages/sdk/src/modules/dashboard/projects-policies/dto/index.ts new file mode 100644 index 00000000..b1cb4d27 --- /dev/null +++ b/packages/sdk/src/modules/dashboard/projects-policies/dto/index.ts @@ -0,0 +1,2 @@ +export * from './sdk-project-access-level.dto'; +export * from './sdk-project-policy.dto'; diff --git a/packages/sdk/src/modules/dashboard/projects-policies/dto/sdk-project-access-level.dto.ts b/packages/sdk/src/modules/dashboard/projects-policies/dto/sdk-project-access-level.dto.ts new file mode 100644 index 00000000..0964a50f --- /dev/null +++ b/packages/sdk/src/modules/dashboard/projects-policies/dto/sdk-project-access-level.dto.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const SDK_PROJECT_ACCESS_LEVELS = ['read', 'write', 'admin'] as const; + +export const SdkProjectAccessLevelV = z.enum(SDK_PROJECT_ACCESS_LEVELS); + +export type SdkProjectAccessLevelT = z.infer; diff --git a/packages/sdk/src/modules/dashboard/projects-policies/dto/sdk-project-policy.dto.ts b/packages/sdk/src/modules/dashboard/projects-policies/dto/sdk-project-policy.dto.ts new file mode 100644 index 00000000..2ee7a378 --- /dev/null +++ b/packages/sdk/src/modules/dashboard/projects-policies/dto/sdk-project-policy.dto.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; + +import { + SdkTableRowWithDatesV, + SdkTableRowWithIdNameV, + SdkTableRowWithIdV, +} from '~/shared'; + +import { SdkUserListItemV } from '../../users/dto'; +import { SdkProjectAccessLevelV } from './sdk-project-access-level.dto'; + +export const SdkProjectPolicyV = z + .object({ + project: SdkTableRowWithIdNameV, + accessLevel: SdkProjectAccessLevelV, + }) + .merge(SdkTableRowWithIdV) + .merge(SdkTableRowWithDatesV) + .and( + z.union([ + z.object({ + user: SdkUserListItemV, + group: z.null(), + }), + z.object({ + group: SdkTableRowWithIdNameV, + user: z.null(), + }), + ]), + ); + +export type SdkProjectPolicyT = z.infer; diff --git a/packages/sdk/src/modules/dashboard/projects-policies/index.ts b/packages/sdk/src/modules/dashboard/projects-policies/index.ts new file mode 100644 index 00000000..dacfee3d --- /dev/null +++ b/packages/sdk/src/modules/dashboard/projects-policies/index.ts @@ -0,0 +1 @@ +export * from './dto'; diff --git a/packages/sdk/src/modules/dashboard/users-groups/dto/index.ts b/packages/sdk/src/modules/dashboard/users-groups/dto/index.ts new file mode 100644 index 00000000..0abbc803 --- /dev/null +++ b/packages/sdk/src/modules/dashboard/users-groups/dto/index.ts @@ -0,0 +1,4 @@ +export * from './sdk-create-users-group.dto'; +export * from './sdk-search-users-groups.dto'; +export * from './sdk-update-users-group.dto'; +export * from './sdk-users-group.dto'; diff --git a/packages/sdk/src/modules/dashboard/users-groups/dto/sdk-create-users-group.dto.ts b/packages/sdk/src/modules/dashboard/users-groups/dto/sdk-create-users-group.dto.ts new file mode 100644 index 00000000..c2102974 --- /dev/null +++ b/packages/sdk/src/modules/dashboard/users-groups/dto/sdk-create-users-group.dto.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; + +import { SdkTableRowWithIdV, ZodOmitArchivedFields, ZodOmitDateFields } from '~/shared'; + +import { SdkUsersGroupV } from './sdk-users-group.dto'; + +export const SdkCreateUsersGroupInputV = SdkUsersGroupV + .omit({ + ...ZodOmitDateFields, + ...ZodOmitArchivedFields, + id: true, + organization: true, + creator: true, + }) + .extend({ + organization: SdkTableRowWithIdV, + users: z.array(SdkTableRowWithIdV), + }); + +export type SdkCreateUsersGroupInputT = z.infer; + +export const SdkCreateUsersGroupOutputV = SdkTableRowWithIdV; + +export type SdkCreateUsersGroupOutputT = z.infer; diff --git a/packages/sdk/src/modules/dashboard/users-groups/dto/sdk-search-users-groups.dto.ts b/packages/sdk/src/modules/dashboard/users-groups/dto/sdk-search-users-groups.dto.ts new file mode 100644 index 00000000..c245b0d9 --- /dev/null +++ b/packages/sdk/src/modules/dashboard/users-groups/dto/sdk-search-users-groups.dto.ts @@ -0,0 +1,34 @@ +import type { z } from 'zod'; + +import { + SdkArchivedFiltersInputV, + SdkDefaultSortInputV, + SdkExcludeIdsFiltersInputV, + SdkFilteredPhraseInputV, + SdkIdsArrayV, + SdkIdsFiltersInputV, + SdkOffsetPaginationInputV, + SdkOffsetPaginationOutputV, +} from '~/shared'; + +import { SdkUsersGroupV } from './sdk-users-group.dto'; + +export const SdkSearchUsersGroupItemV = SdkUsersGroupV; + +export type SdkSearchUsersGroupItemT = z.infer; + +export const SdkSearchUsersGroupsInputV = SdkOffsetPaginationInputV + .extend({ + organizationIds: SdkIdsArrayV.optional(), + }) + .merge(SdkDefaultSortInputV) + .merge(SdkArchivedFiltersInputV) + .merge(SdkIdsFiltersInputV) + .merge(SdkExcludeIdsFiltersInputV) + .merge(SdkFilteredPhraseInputV); + +export type SdkSearchUsersGroupsInputT = z.infer; + +export const SdkSearchUsersGroupsOutputV = SdkOffsetPaginationOutputV(SdkSearchUsersGroupItemV); + +export type SdkSearchUsersGroupsOutputT = z.infer; diff --git a/packages/sdk/src/modules/dashboard/users-groups/dto/sdk-update-users-group.dto.ts b/packages/sdk/src/modules/dashboard/users-groups/dto/sdk-update-users-group.dto.ts new file mode 100644 index 00000000..befb3980 --- /dev/null +++ b/packages/sdk/src/modules/dashboard/users-groups/dto/sdk-update-users-group.dto.ts @@ -0,0 +1,19 @@ +import type { z } from 'zod'; + +import { SdkTableRowWithIdV, ZodOmitArchivedFields, ZodOmitDateFields } from '~/shared'; + +import { SdkUsersGroupV } from './sdk-users-group.dto'; + +export const SdkUpdateUsersGroupInputV = SdkUsersGroupV.omit({ + ...ZodOmitDateFields, + ...ZodOmitArchivedFields, + id: true, + organization: true, + creator: true, +}); + +export type SdkUpdateUsersGroupInputT = z.infer; + +export const SdkUpdateUsersGroupOutputV = SdkTableRowWithIdV; + +export type SdkUpdateUsersGroupOutputT = z.infer; diff --git a/packages/sdk/src/modules/dashboard/users-groups/dto/sdk-users-group.dto.ts b/packages/sdk/src/modules/dashboard/users-groups/dto/sdk-users-group.dto.ts new file mode 100644 index 00000000..7645c4d7 --- /dev/null +++ b/packages/sdk/src/modules/dashboard/users-groups/dto/sdk-users-group.dto.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +import { + SdkIdNameUrlEntryV, + SdkTableRowWithArchivedV, + SdkTableRowWithDatesV, + SdkTableRowWithIdNameV, +} from '~/shared'; + +import { SdkUserListItemV } from '../../users/dto/sdk-user-list-item.dto'; + +export const SdkUsersGroupV = z.strictObject({ + organization: SdkIdNameUrlEntryV, + creator: SdkUserListItemV, + users: z.array(SdkUserListItemV), +}) + .merge(SdkTableRowWithIdNameV) + .merge(SdkTableRowWithDatesV) + .merge(SdkTableRowWithArchivedV); + +export type SdkUsersGroupT = z.infer; diff --git a/packages/sdk/src/modules/dashboard/users-groups/index.ts b/packages/sdk/src/modules/dashboard/users-groups/index.ts new file mode 100644 index 00000000..10eac77c --- /dev/null +++ b/packages/sdk/src/modules/dashboard/users-groups/index.ts @@ -0,0 +1,2 @@ +export * from './dto'; +export * from './users-groups.sdk'; diff --git a/packages/sdk/src/modules/dashboard/users-groups/users-groups.sdk.ts b/packages/sdk/src/modules/dashboard/users-groups/users-groups.sdk.ts new file mode 100644 index 00000000..0c77e95e --- /dev/null +++ b/packages/sdk/src/modules/dashboard/users-groups/users-groups.sdk.ts @@ -0,0 +1,64 @@ +import { AbstractNestedSdkWithAuth } from '~/modules/abstract-nested-sdk-with-auth'; +import { + getPayload, + patchPayload, + postPayload, + putPayload, + type SdkRecordAlreadyExistsError, + type SdkRecordNotFoundError, + type SdkTableRowIdT, + type SdkTableRowWithIdT, +} from '~/shared'; + +import type { + SdkCreateUsersGroupInputT, + SdkCreateUsersGroupOutputT, + SdkSearchUsersGroupsInputT, + SdkSearchUsersGroupsOutputT, + SdkUpdateUsersGroupInputT, + SdkUpdateUsersGroupOutputT, +} from './dto'; + +export class UsersGroupsSdk extends AbstractNestedSdkWithAuth { + protected endpointPrefix = '/dashboard/users/groups'; + + search = (data: SdkSearchUsersGroupsInputT) => + this.fetch({ + url: this.endpoint('/search'), + query: data, + options: getPayload(), + }); + + create = (data: SdkCreateUsersGroupInputT) => + this.fetch({ + url: this.endpoint('/'), + options: postPayload(data), + }); + + unarchive = (id: SdkTableRowIdT) => + this.fetch< + SdkTableRowWithIdT, + SdkRecordNotFoundError | SdkRecordAlreadyExistsError + >({ + url: this.endpoint(`/unarchive/${id}`), + options: patchPayload({}), + }); + + archive = (id: SdkTableRowIdT) => + this.fetch< + SdkTableRowWithIdT, + SdkRecordNotFoundError | SdkRecordAlreadyExistsError + >({ + url: this.endpoint(`/archive/${id}`), + options: patchPayload({}), + }); + + update = ({ id, ...data }: SdkUpdateUsersGroupInputT & SdkTableRowWithIdT) => + this.fetch< + SdkUpdateUsersGroupOutputT, + SdkRecordAlreadyExistsError | SdkRecordNotFoundError + >({ + url: this.endpoint(`/${id}`), + options: putPayload(data), + }); +}; diff --git a/packages/sdk/src/modules/dashboard/users/dto/sdk-search-users.dto.ts b/packages/sdk/src/modules/dashboard/users/dto/sdk-search-users.dto.ts index 0c30b588..91180970 100644 --- a/packages/sdk/src/modules/dashboard/users/dto/sdk-search-users.dto.ts +++ b/packages/sdk/src/modules/dashboard/users/dto/sdk-search-users.dto.ts @@ -16,7 +16,7 @@ export const SdkSearchUserItemV = SdkUserV; export type SdkSearchUserItemT = z.infer; -export const SdKSearchUsersInputV = SdkOffsetPaginationInputV +export const SdkSearchUsersInputV = SdkOffsetPaginationInputV .extend({ organizationIds: SdkIdsArrayV.optional(), }) @@ -25,7 +25,7 @@ export const SdKSearchUsersInputV = SdkOffsetPaginationInputV .merge(SdkIdsFiltersInputV) .merge(SdkFilteredPhraseInputV); -export type SdKSearchUsersInputT = z.infer; +export type SdkSearchUsersInputT = z.infer; export const SdKSearchUsersOutputV = SdkOffsetPaginationOutputV(SdkSearchUserItemV); diff --git a/packages/sdk/src/modules/dashboard/users/dto/sdk-update-user.dto.ts b/packages/sdk/src/modules/dashboard/users/dto/sdk-update-user.dto.ts index d3768fcd..c2bf7163 100644 --- a/packages/sdk/src/modules/dashboard/users/dto/sdk-update-user.dto.ts +++ b/packages/sdk/src/modules/dashboard/users/dto/sdk-update-user.dto.ts @@ -2,14 +2,34 @@ import { z } from 'zod'; import { SdkTableRowWithArchiveProtectionV, SdkTableRowWithIdV } from '~/shared'; +import { SdkOrganizationUserRoleV } from '../../organizations/dto/sdk-organization-user.dto'; import { SdkUpdateUserAuthMethodsV } from './auth'; +export const SdkUpdateUserOrganizationInputV = z.object({ + role: SdkOrganizationUserRoleV, +}); + +export type SdkUpdateUserOrganizationInputT = z.infer< + typeof SdkUpdateUserOrganizationInputV +>; + export const SdkUpdateUserInputV = z.object({ email: z.string(), active: z.boolean(), auth: SdkUpdateUserAuthMethodsV, }) - .merge(SdkTableRowWithArchiveProtectionV); + .merge(SdkTableRowWithArchiveProtectionV) + .and( + z.discriminatedUnion('role', [ + z.object({ + role: z.literal('root'), + }), + z.object({ + role: z.literal('user'), + organization: SdkUpdateUserOrganizationInputV, + }), + ]), + ); export type SdkUpdateUserInputT = z.infer; diff --git a/packages/sdk/src/modules/dashboard/users/users.sdk.ts b/packages/sdk/src/modules/dashboard/users/users.sdk.ts index 0f742aa9..5f47ba95 100644 --- a/packages/sdk/src/modules/dashboard/users/users.sdk.ts +++ b/packages/sdk/src/modules/dashboard/users/users.sdk.ts @@ -13,7 +13,7 @@ import { import type { SdkCreateUserInputT, SdkCreateUserOutputT, - SdKSearchUsersInputT, + SdkSearchUsersInputT, SdKSearchUsersOutputT, SdkUpdateUserInputT, SdkUpdateUserOutputT, @@ -40,7 +40,7 @@ export class UsersSdk extends AbstractNestedSdkWithAuth { options: patchPayload({}), }); - search = (data: SdKSearchUsersInputT) => + search = (data: SdkSearchUsersInputT) => this.fetch({ url: this.endpoint('/search'), query: data, diff --git a/apps/chat/src/modules/shared/card/card-action-buttons.tsx b/packages/ui/src/components/card/card-action-buttons.tsx similarity index 92% rename from apps/chat/src/modules/shared/card/card-action-buttons.tsx rename to packages/ui/src/components/card/card-action-buttons.tsx index 69386c14..6a8caf69 100644 --- a/apps/chat/src/modules/shared/card/card-action-buttons.tsx +++ b/packages/ui/src/components/card/card-action-buttons.tsx @@ -3,7 +3,7 @@ import type { ReactNode } from 'react'; import clsx from 'clsx'; import { PencilIcon, TrashIcon } from 'lucide-react'; -import { useI18n } from '~/i18n'; +import { useForwardedI18n } from '~/i18n'; type CardButtonProps = { icon?: ReactNode; @@ -43,7 +43,7 @@ export function CardActionButton({ } export function CardEditButton({ onClick }: { onClick: () => void; }) { - const t = useI18n().pack; + const t = useForwardedI18n().pack; return ( void; }) { } export function CardArchiveButton({ onClick, loading }: { onClick: () => void; loading?: boolean; }) { - const t = useI18n().pack; + const t = useForwardedI18n().pack; return ( (null); const wrapperRef = useRef(null); @@ -60,13 +60,13 @@ export function CardDescription({ children, limitHeight, className }: Props) { {isExpanded ? ( <> - {t.chat.actions.expand.less} + {t.buttons.expand.less} ) : ( <> - {t.chat.actions.expand.more} + {t.buttons.expand.more} )} diff --git a/apps/chat/src/modules/shared/card/card-footer.tsx b/packages/ui/src/components/card/card-footer.tsx similarity index 100% rename from apps/chat/src/modules/shared/card/card-footer.tsx rename to packages/ui/src/components/card/card-footer.tsx diff --git a/apps/chat/src/modules/shared/card/card-open-button.tsx b/packages/ui/src/components/card/card-open-button.tsx similarity index 92% rename from apps/chat/src/modules/shared/card/card-open-button.tsx rename to packages/ui/src/components/card/card-open-button.tsx index 5ba055a5..63b51b6b 100644 --- a/apps/chat/src/modules/shared/card/card-open-button.tsx +++ b/packages/ui/src/components/card/card-open-button.tsx @@ -2,7 +2,7 @@ import clsx from 'clsx'; import { ExternalLinkIcon } from 'lucide-react'; import { Link } from 'wouter'; -import { useI18n } from '~/i18n'; +import { useForwardedI18n } from '~/i18n'; type Props = { href?: string; @@ -11,7 +11,7 @@ type Props = { }; export function CardOpenButton({ href, onClick, loading }: Props) { - const t = useI18n().pack; + const t = useForwardedI18n().pack; const className = clsx( 'uk-button uk-button-secondary uk-button-small', loading && 'uk-disabled opacity-50', diff --git a/apps/chat/src/modules/shared/card/card-title.tsx b/packages/ui/src/components/card/card-title.tsx similarity index 100% rename from apps/chat/src/modules/shared/card/card-title.tsx rename to packages/ui/src/components/card/card-title.tsx diff --git a/apps/chat/src/modules/shared/card/index.ts b/packages/ui/src/components/card/index.ts similarity index 100% rename from apps/chat/src/modules/shared/card/index.ts rename to packages/ui/src/components/card/index.ts diff --git a/packages/ui/src/components/content-card.tsx b/packages/ui/src/components/content-card.tsx new file mode 100644 index 00000000..e8e60581 --- /dev/null +++ b/packages/ui/src/components/content-card.tsx @@ -0,0 +1,25 @@ +import type { PropsWithChildren, ReactNode } from 'react'; + +type Props = PropsWithChildren & { + title: string; + toolbar?: ReactNode; +}; + +export function ContentCard({ title, toolbar, children }: Props) { + return ( +
    +
    +

    {title}

    + {toolbar && ( +
    + {toolbar} +
    + )} +
    + +
    + {children} +
    +
    + ); +} diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index a248a57c..34b393d8 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -1,6 +1,8 @@ export * from './alert'; export * from './balloon'; +export * from './card'; export * from './collapsible-panel'; +export * from './content-card'; export * from './controls'; export * from './ellipsis-dropdown'; export * from './form'; @@ -9,6 +11,7 @@ export * from './modal'; export * from './notifications'; export * from './pagination'; export * from './predefined'; +export * from './side-layout'; export * from './skeleton'; export * from './spinner-container'; export * from './table'; diff --git a/packages/ui/src/components/list/paginated-list.tsx b/packages/ui/src/components/list/paginated-list.tsx index 3055b11c..c68405c1 100644 --- a/packages/ui/src/components/list/paginated-list.tsx +++ b/packages/ui/src/components/list/paginated-list.tsx @@ -17,7 +17,7 @@ export type PaginatedListProps< I extends SdkTableRowWithIdT | SdkTableRowWithUuidT, P extends SdkOffsetPaginationInputT, > = { - loading: boolean; + loading?: boolean; withEmptyPlaceholder?: boolean; result?: SdkOffsetPaginationOutputT | null; pagination: ControlledControlStateAttrs

    ; diff --git a/packages/ui/src/components/predefined/buttons/add-button.tsx b/packages/ui/src/components/predefined/buttons/add-button.tsx new file mode 100644 index 00000000..00bffcd7 --- /dev/null +++ b/packages/ui/src/components/predefined/buttons/add-button.tsx @@ -0,0 +1,20 @@ +import { PlusIcon } from 'lucide-react'; + +import { FormSpinnerCTA, type FormSpinnerCTAProps } from '~/components/form'; +import { useForwardedI18n } from '~/i18n'; + +type Props = Omit; + +export function AddButton({ loading, ...props }: Props) { + const { pack } = useForwardedI18n(); + + return ( + + {!loading && ( + + )} + + {pack.buttons.add} + + ); +} diff --git a/packages/ui/src/components/predefined/buttons/archive-button.tsx b/packages/ui/src/components/predefined/buttons/archive-button.tsx index 3992d632..bca133c9 100644 --- a/packages/ui/src/components/predefined/buttons/archive-button.tsx +++ b/packages/ui/src/components/predefined/buttons/archive-button.tsx @@ -3,7 +3,7 @@ import { useForwardedI18n } from '~/i18n'; type Props = Omit; -export function ArchiveButton({ className, ...props }: Props) { +export function ArchiveButton(props: Props) { const { pack } = useForwardedI18n(); return ( diff --git a/packages/ui/src/components/predefined/buttons/create-button.tsx b/packages/ui/src/components/predefined/buttons/create-button.tsx index 050d66c0..6ace161a 100644 --- a/packages/ui/src/components/predefined/buttons/create-button.tsx +++ b/packages/ui/src/components/predefined/buttons/create-button.tsx @@ -3,15 +3,16 @@ import { PlusIcon } from 'lucide-react'; import { FormSpinnerCTA, type FormSpinnerCTAProps } from '~/components/form'; import { useForwardedI18n } from '~/i18n'; -type Props = Omit; +type Props = Omit & { + withIcon?: boolean; +}; -export function CreateButton({ className, ...props }: Props) { +export function CreateButton({ loading, withIcon = true, ...props }: Props) { const { pack } = useForwardedI18n(); return ( - - - + + {withIcon && !loading && } {pack.buttons.create} ); diff --git a/packages/ui/src/components/predefined/buttons/delete-button.tsx b/packages/ui/src/components/predefined/buttons/delete-button.tsx index 0f1c2d41..12927276 100644 --- a/packages/ui/src/components/predefined/buttons/delete-button.tsx +++ b/packages/ui/src/components/predefined/buttons/delete-button.tsx @@ -3,7 +3,7 @@ import { useForwardedI18n } from '~/i18n'; type Props = Omit; -export function DeleteButton({ className, ...props }: Props) { +export function DeleteButton(props: Props) { const { pack } = useForwardedI18n(); return ( diff --git a/packages/ui/src/components/predefined/buttons/index.ts b/packages/ui/src/components/predefined/buttons/index.ts index 71e1f525..36c3afc6 100644 --- a/packages/ui/src/components/predefined/buttons/index.ts +++ b/packages/ui/src/components/predefined/buttons/index.ts @@ -1,8 +1,10 @@ +export * from './add-button'; export * from './archive-button'; export * from './cancel-button'; export * from './create-button'; export * from './delete-button'; export * from './reset-filters-button'; export * from './save-button'; +export * from './select-record-button'; export * from './unarchive-button'; export * from './update-button'; diff --git a/packages/ui/src/components/predefined/buttons/save-button.tsx b/packages/ui/src/components/predefined/buttons/save-button.tsx index a2b0c7f8..696cad5e 100644 --- a/packages/ui/src/components/predefined/buttons/save-button.tsx +++ b/packages/ui/src/components/predefined/buttons/save-button.tsx @@ -3,14 +3,16 @@ import { SendIcon } from 'lucide-react'; import { FormSpinnerCTA, type FormSpinnerCTAProps } from '~/components/form'; import { useForwardedI18n } from '~/i18n'; -type Props = Omit; +type Props = Omit & { + withIcon?: boolean; +}; -export function SaveButton(props: Props) { +export function SaveButton({ loading, withIcon = true, ...props }: Props) { const { pack } = useForwardedI18n(); return ( - - + + {withIcon && !loading && } {pack.buttons.save} diff --git a/packages/ui/src/components/predefined/buttons/select-record-button.tsx b/packages/ui/src/components/predefined/buttons/select-record-button.tsx new file mode 100644 index 00000000..6dea3fd1 --- /dev/null +++ b/packages/ui/src/components/predefined/buttons/select-record-button.tsx @@ -0,0 +1,37 @@ +import type { ComponentProps } from 'react'; + +import clsx from 'clsx'; +import { CheckIcon } from 'lucide-react'; + +import { useForwardedI18n } from '~/i18n'; + +type Props = Omit, 'children'> & { + selected?: boolean; + disabled?: boolean; +}; + +export function SelectRecordButton({ className, disabled, selected, ...props }: Props) { + const t = useForwardedI18n().pack.buttons; + + return ( + + ); +} diff --git a/packages/ui/src/components/predefined/buttons/unarchive-button.tsx b/packages/ui/src/components/predefined/buttons/unarchive-button.tsx index e9be45d0..b0cd1479 100644 --- a/packages/ui/src/components/predefined/buttons/unarchive-button.tsx +++ b/packages/ui/src/components/predefined/buttons/unarchive-button.tsx @@ -3,7 +3,7 @@ import { useForwardedI18n } from '~/i18n'; type Props = Omit; -export function UnarchiveButton({ className, ...props }: Props) { +export function UnarchiveButton(props: Props) { const { pack } = useForwardedI18n(); return ( diff --git a/packages/ui/src/components/predefined/buttons/update-button.tsx b/packages/ui/src/components/predefined/buttons/update-button.tsx index 44ca3bab..854f9a73 100644 --- a/packages/ui/src/components/predefined/buttons/update-button.tsx +++ b/packages/ui/src/components/predefined/buttons/update-button.tsx @@ -3,7 +3,7 @@ import { useForwardedI18n } from '~/i18n'; type Props = Omit; -export function UpdateButton({ className, ...props }: Props) { +export function UpdateButton(props: Props) { const { pack } = useForwardedI18n(); return ( diff --git a/packages/ui/src/components/side-layout/index.ts b/packages/ui/src/components/side-layout/index.ts new file mode 100644 index 00000000..83465476 --- /dev/null +++ b/packages/ui/src/components/side-layout/index.ts @@ -0,0 +1,3 @@ +export * from './side-layout'; +export * from './side-nav'; +export * from './side-nav-item'; diff --git a/packages/ui/src/components/side-layout/side-layout.tsx b/packages/ui/src/components/side-layout/side-layout.tsx new file mode 100644 index 00000000..83b1b320 --- /dev/null +++ b/packages/ui/src/components/side-layout/side-layout.tsx @@ -0,0 +1,18 @@ +import type { PropsWithChildren, ReactNode } from 'react'; + +type SideLayoutProps = PropsWithChildren & { + sidebar: ReactNode; +}; + +export function SideLayout({ sidebar, children }: SideLayoutProps) { + return ( +

    + +
    + {children} +
    +
    + ); +} diff --git a/packages/ui/src/components/side-layout/side-nav-item.tsx b/packages/ui/src/components/side-layout/side-nav-item.tsx new file mode 100644 index 00000000..731c059e --- /dev/null +++ b/packages/ui/src/components/side-layout/side-nav-item.tsx @@ -0,0 +1,43 @@ +import type { PropsWithChildren, ReactNode } from 'react'; + +import { clsx } from 'clsx'; +import { Link, useRoute } from 'wouter'; + +type SideNavItemProps = PropsWithChildren & { + icon: ReactNode; + isActive?: boolean; + href?: string; + onClick?: () => void; +}; + +export function SideNavItem({ icon, isActive: isActiveProp, onClick, href, children }: SideNavItemProps) { + const [isCurrentRoute] = useRoute(href ?? ''); + const isActive = isActiveProp ?? (href ? isCurrentRoute : false); + + const className = clsx( + 'flex items-center gap-3 px-3 py-2 rounded-md w-full text-sm transition-colors', + isActive + ? 'bg-primary/5 text-primary font-semibold' + : 'text-muted-foreground hover:bg-gray-100', + ); + + if (href) { + return ( + + {icon} + {children} + + ); + } + + return ( + + ); +} diff --git a/packages/ui/src/components/side-layout/side-nav.tsx b/packages/ui/src/components/side-layout/side-nav.tsx new file mode 100644 index 00000000..d809c516 --- /dev/null +++ b/packages/ui/src/components/side-layout/side-nav.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from 'react'; + +type SideNavProps = { + children: ReactNode; +}; + +export function SideNav({ children }: SideNavProps) { + return ( + + ); +} diff --git a/packages/ui/src/components/table/paginated-table.tsx b/packages/ui/src/components/table/paginated-table.tsx index dda6d293..6ed70d44 100644 --- a/packages/ui/src/components/table/paginated-table.tsx +++ b/packages/ui/src/components/table/paginated-table.tsx @@ -14,23 +14,27 @@ type Props< P extends SdkOffsetPaginationInputT, > = & Omit, 'items'> - & Omit, 'children'>; + & Omit, 'children'> + & { + spaced?: boolean; + }; export function PaginatedTable< I extends SdkTableRowWithIdT | SdkTableRowWithUuidT, P extends SdkOffsetPaginationInputT, ->({ result, loading, pagination, className, ...props }: Props) { +>({ result, loading = false, pagination, className, footerProps, spaced = true, ...props }: Props) { return ( {({ items }) => ( )} diff --git a/packages/ui/src/i18n/packs/i18n-forwarded-en-pack.tsx b/packages/ui/src/i18n/packs/i18n-forwarded-en-pack.tsx index 7526319c..2d78c4d0 100644 --- a/packages/ui/src/i18n/packs/i18n-forwarded-en-pack.tsx +++ b/packages/ui/src/i18n/packs/i18n-forwarded-en-pack.tsx @@ -45,6 +45,12 @@ export const I18N_FORWARDED_EN_PACK = { confirm: 'Confirm', resetFilters: 'Reset filters', download: 'Download', + select: 'Select', + selected: 'Selected', + expand: { + more: 'More', + less: 'Less', + }, }, errors: { tagged: I18N_SDK_ERRORS_EN, diff --git a/packages/ui/src/i18n/packs/i18n-forwarded-pl-pack.tsx b/packages/ui/src/i18n/packs/i18n-forwarded-pl-pack.tsx index f237f87e..9d17a5b1 100644 --- a/packages/ui/src/i18n/packs/i18n-forwarded-pl-pack.tsx +++ b/packages/ui/src/i18n/packs/i18n-forwarded-pl-pack.tsx @@ -47,6 +47,12 @@ export const I18N_FORWARDED_PL_PACK: typeof I18N_FORWARDED_EN_PACK = { add: 'Dodaj', confirm: 'Potwierdź', download: 'Pobierz', + select: 'Wybierz', + selected: 'Wybrano', + expand: { + more: 'Więcej', + less: 'Mniej', + }, }, errors: { tagged: I18N_SDK_ERRORS_PL,