diff --git a/backend/LexBoxApi/GraphQL/CustomTypes/OrgMembersGqlConfiguration.cs b/backend/LexBoxApi/GraphQL/CustomTypes/OrgMembersGqlConfiguration.cs new file mode 100644 index 000000000..65b81bede --- /dev/null +++ b/backend/LexBoxApi/GraphQL/CustomTypes/OrgMembersGqlConfiguration.cs @@ -0,0 +1,13 @@ +using LexCore.Entities; + +namespace LexBoxApi.GraphQL.CustomTypes; + +[ObjectType] +public class OrgMembersGqlConfiguration : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Field(f => f.User).Type>(); + descriptor.Field(f => f.Organization).Type>(); + } +} diff --git a/backend/LexBoxApi/GraphQL/OrgMutations.cs b/backend/LexBoxApi/GraphQL/OrgMutations.cs index abd50d789..e9c8217d1 100644 --- a/backend/LexBoxApi/GraphQL/OrgMutations.cs +++ b/backend/LexBoxApi/GraphQL/OrgMutations.cs @@ -1,4 +1,6 @@ using LexBoxApi.Auth; +using LexBoxApi.Auth.Attributes; +using LexBoxApi.Models.Org; using LexCore.Entities; using LexCore.Exceptions; using LexCore.ServiceInterfaces; @@ -36,6 +38,20 @@ public async Task> CreateOrganization(string name, return dbContext.Orgs.Where(o => o.Id == orgId); } + [Error] + [UseMutationConvention] + [AdminRequired] + public async Task DeleteOrg(Guid orgId, + LexBoxDbContext dbContext) + { + var org = await dbContext.Orgs.Include(o => o.Members).FirstOrDefaultAsync(o => o.Id == orgId); + NotFoundException.ThrowIfNull(org); + + dbContext.Remove(org); + await dbContext.SaveChangesAsync(); + return org; + } + /// /// set the role of a member in an organization, if the member does not exist it will be created /// @@ -55,14 +71,39 @@ public async Task> SetOrgMemberRole( Guid orgId, OrgRole? role, string emailOrUsername) + { + var user = await dbContext.Users.FindByEmailOrUsername(emailOrUsername); + NotFoundException.ThrowIfNull(user); // TODO: Implement inviting user + return await ChangeOrgMemberRole(dbContext, permissionService, orgId, user.Id, role); + } + + /// + /// Change the role of an existing member in an organization + /// + /// + /// + /// + /// ID (GUID) of the user whose membership should be updated + /// set to null to remove the member + [Error] + [Error] + [UseMutationConvention] + [UseFirstOrDefault] + [UseProjection] + public async Task> ChangeOrgMemberRole( + LexBoxDbContext dbContext, + IPermissionService permissionService, + Guid orgId, + Guid userId, + OrgRole? role) { var org = await dbContext.Orgs.Include(o => o.Members).FirstOrDefaultAsync(o => o.Id == orgId); NotFoundException.ThrowIfNull(org); - var user = await dbContext.Users.FindByEmailOrUsername(emailOrUsername); - NotFoundException.ThrowIfNull(user); permissionService.AssertCanEditOrg(org); - await UpdateOrgMemberRole(dbContext, org, role, user.Id); + var user = await dbContext.Users.FindAsync(userId); + NotFoundException.ThrowIfNull(user); + await UpdateOrgMemberRole(dbContext, org, role, userId); return dbContext.Orgs.Where(o => o.Id == orgId); } @@ -85,4 +126,26 @@ private async Task UpdateOrgMemberRole(LexBoxDbContext dbContext, Organization o await dbContext.SaveChangesAsync(); } + + [Error] + [Error] + [Error] + [UseMutationConvention] + [UseFirstOrDefault] + [UseProjection] + public async Task> ChangeOrgName(ChangeOrgNameInput input, + IPermissionService permissionService, + LexBoxDbContext dbContext) + { + if (string.IsNullOrEmpty(input.Name)) throw new RequiredException("Org name cannot be empty"); + + var org = await dbContext.Orgs.FindAsync(input.OrgId); + NotFoundException.ThrowIfNull(org); + permissionService.AssertCanEditOrg(org); + + org.Name = input.Name; + org.UpdateUpdatedDate(); + await dbContext.SaveChangesAsync(); + return dbContext.Orgs.Where(o => o.Id == input.OrgId); + } } diff --git a/backend/LexBoxApi/Models/Org/ChangeOrgInputs.cs b/backend/LexBoxApi/Models/Org/ChangeOrgInputs.cs new file mode 100644 index 000000000..646803106 --- /dev/null +++ b/backend/LexBoxApi/Models/Org/ChangeOrgInputs.cs @@ -0,0 +1,3 @@ +namespace LexBoxApi.Models.Org; + +public record ChangeOrgNameInput(Guid OrgId, string Name); diff --git a/frontend/package.json b/frontend/package.json index 6c16afef4..4edec0e1c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -38,7 +38,7 @@ "@iconify-json/mdi": "^1.1.66", "@playwright/test": "^1.44.0", "@sveltejs/adapter-node": "^4.0.1", - "@sveltejs/kit": "^2.5.8", + "@sveltejs/kit": "^2.5.10", "@sveltejs/vite-plugin-svelte": "^3.1.0", "@tailwindcss/typography": "^0.5.13", "@types/mjml": "^4.7.4", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 1c1306947..e84c17b69 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -73,7 +73,7 @@ importers: version: 0.12.3(@babel/core@7.24.4)(svelte@4.2.17) sveltekit-search-params: specifier: ^2.1.2 - version: 2.1.2(@sveltejs/kit@2.5.8)(svelte@4.2.17)(vite@5.2.11) + version: 2.1.2(@sveltejs/kit@2.5.10)(svelte@4.2.17)(vite@5.2.11) tus-js-client: specifier: ^4.1.0 version: 4.1.0 @@ -113,10 +113,10 @@ importers: version: 1.44.0 '@sveltejs/adapter-node': specifier: ^4.0.1 - version: 4.0.1(@sveltejs/kit@2.5.8) + version: 4.0.1(@sveltejs/kit@2.5.10) '@sveltejs/kit': - specifier: ^2.5.8 - version: 2.5.8(@sveltejs/vite-plugin-svelte@3.1.0)(svelte@4.2.17)(vite@5.2.11) + specifier: ^2.5.10 + version: 2.5.10(@sveltejs/vite-plugin-svelte@3.1.0)(svelte@4.2.17)(vite@5.2.11) '@sveltejs/vite-plugin-svelte': specifier: ^3.1.0 version: 3.1.0(svelte@4.2.17)(vite@5.2.11) @@ -194,7 +194,7 @@ importers: version: 0.5.0(svelte@4.2.17) sveltekit-superforms: specifier: ^1.13.4 - version: 1.13.4(@sveltejs/kit@2.5.8)(svelte@4.2.17)(zod@3.23.8) + version: 1.13.4(@sveltejs/kit@2.5.10)(svelte@4.2.17)(zod@3.23.8) tailwindcss: specifier: ^3.4.3 version: 3.4.3 @@ -3534,7 +3534,7 @@ packages: resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} dev: true - /@sveltejs/adapter-node@4.0.1(@sveltejs/kit@2.5.8): + /@sveltejs/adapter-node@4.0.1(@sveltejs/kit@2.5.10): resolution: {integrity: sha512-IviiTtKCDp+0QoTmmMlGGZBA1EoUNsjecU6XGV9k62S3f01SNsVhpqi2e4nbI62BLGKh/YKKfFii+Vz/b9XIxg==} peerDependencies: '@sveltejs/kit': ^2.4.0 @@ -3542,12 +3542,12 @@ packages: '@rollup/plugin-commonjs': 25.0.7(rollup@4.9.6) '@rollup/plugin-json': 6.1.0(rollup@4.9.6) '@rollup/plugin-node-resolve': 15.2.3(rollup@4.9.6) - '@sveltejs/kit': 2.5.8(@sveltejs/vite-plugin-svelte@3.1.0)(svelte@4.2.17)(vite@5.2.11) + '@sveltejs/kit': 2.5.10(@sveltejs/vite-plugin-svelte@3.1.0)(svelte@4.2.17)(vite@5.2.11) rollup: 4.9.6 dev: true - /@sveltejs/kit@2.5.8(@sveltejs/vite-plugin-svelte@3.1.0)(svelte@4.2.17)(vite@5.2.11): - resolution: {integrity: sha512-ZQXYaVHd1p0kDGwOi4l82i5kAiUQtrhMthDKtJi0zVzmNupKJ0ZlBVAoceuarCuIntPNctyQchW29h5DkFxd1Q==} + /@sveltejs/kit@2.5.10(@sveltejs/vite-plugin-svelte@3.1.0)(svelte@4.2.17)(vite@5.2.11): + resolution: {integrity: sha512-OqoyTmFG2cYmCFAdBfW+Qxbg8m23H4dv6KqwEt7ofr/ROcfcIl3Z/VT56L22H9f0uNZyr+9Bs1eh2gedOCK9kA==} engines: {node: '>=18.13'} hasBin: true requiresBuild: true @@ -3561,9 +3561,9 @@ packages: cookie: 0.6.0 devalue: 5.0.0 esm-env: 1.0.0 - import-meta-resolve: 4.0.0 + import-meta-resolve: 4.1.0 kleur: 4.1.5 - magic-string: 0.30.6 + magic-string: 0.30.10 mrmime: 2.0.0 sade: 1.8.1 set-cookie-parser: 2.6.0 @@ -6172,8 +6172,8 @@ packages: module-details-from-path: 1.0.3 dev: false - /import-meta-resolve@4.0.0: - resolution: {integrity: sha512-okYUR7ZQPH+efeuMJGlq4f8ubUgO50kByRPyt/Cy1Io4PSRsPjxME+YlVaCOx+NIToW7hCsZNFJyTPFFKepRSA==} + /import-meta-resolve@4.1.0: + resolution: {integrity: sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==} /imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} @@ -9196,13 +9196,13 @@ packages: magic-string: 0.30.6 periscopic: 3.1.0 - /sveltekit-search-params@2.1.2(@sveltejs/kit@2.5.8)(svelte@4.2.17)(vite@5.2.11): + /sveltekit-search-params@2.1.2(@sveltejs/kit@2.5.10)(svelte@4.2.17)(vite@5.2.11): resolution: {integrity: sha512-wh5WSo46wz48MdWvpchVGrOjoDmbmsNJ7dUToSZ4L1SQ2LOasmTjAAlFhfG/EFvEhR34phRzLF7BjE0ZHzx1Uw==} peerDependencies: '@sveltejs/kit': ^1.0.0 || ^2.0.0 svelte: ^3.55.0 || ^4.0.0 || ^5.0.0 dependencies: - '@sveltejs/kit': 2.5.8(@sveltejs/vite-plugin-svelte@3.1.0)(svelte@4.2.17)(vite@5.2.11) + '@sveltejs/kit': 2.5.10(@sveltejs/vite-plugin-svelte@3.1.0)(svelte@4.2.17)(vite@5.2.11) '@sveltejs/vite-plugin-svelte': 3.1.0(svelte@4.2.17)(vite@5.2.11) svelte: 4.2.17 transitivePeerDependencies: @@ -9210,14 +9210,14 @@ packages: - vite dev: false - /sveltekit-superforms@1.13.4(@sveltejs/kit@2.5.8)(svelte@4.2.17)(zod@3.23.8): + /sveltekit-superforms@1.13.4(@sveltejs/kit@2.5.10)(svelte@4.2.17)(zod@3.23.8): resolution: {integrity: sha512-rM2+Ictaw7OAIorCLmvg82orci/mtO9ZouI4emtx8SyYngx9aED+eNZlHPLufgB6D7geL2a+hMSFtM3zmMQixQ==} peerDependencies: '@sveltejs/kit': 1.x || 2.x svelte: 3.x || 4.x zod: 3.x dependencies: - '@sveltejs/kit': 2.5.8(@sveltejs/vite-plugin-svelte@3.1.0)(svelte@4.2.17)(vite@5.2.11) + '@sveltejs/kit': 2.5.10(@sveltejs/vite-plugin-svelte@3.1.0)(svelte@4.2.17)(vite@5.2.11) devalue: 4.3.2 klona: 2.0.6 svelte: 4.2.17 diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 7555f3af2..5c5feaef2 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -32,6 +32,16 @@ type BulkAddProjectMembersResult { existingMembers: [UserProjectRole!]! } +type ChangeOrgMemberRolePayload { + organization: Organization + errors: [ChangeOrgMemberRoleError!] +} + +type ChangeOrgNamePayload { + organization: Organization + errors: [ChangeOrgNameError!] +} + type ChangeProjectDescriptionPayload { project: Project errors: [ChangeProjectDescriptionError!] @@ -107,6 +117,11 @@ type DeleteDraftProjectPayload { errors: [DeleteDraftProjectError!] } +type DeleteOrgPayload { + organization: Organization + errors: [DeleteOrgError!] +} + type DeleteUserByAdminOrSelfPayload { user: User errors: [DeleteUserByAdminOrSelfError!] @@ -176,7 +191,10 @@ type MeDto { type Mutation { createOrganization(input: CreateOrganizationInput!): CreateOrganizationPayload! + deleteOrg(input: DeleteOrgInput!): DeleteOrgPayload! @authorize(policy: "AdminRequiredPolicy") setOrgMemberRole(input: SetOrgMemberRoleInput!): SetOrgMemberRolePayload! + changeOrgMemberRole(input: ChangeOrgMemberRoleInput!): ChangeOrgMemberRolePayload! + changeOrgName(input: ChangeOrgNameInput!): ChangeOrgNamePayload! createProject(input: CreateProjectInput!): CreateProjectPayload! @authorize(policy: "VerifiedEmailRequiredPolicy") addProjectMember(input: AddProjectMemberInput!): AddProjectMemberPayload! bulkAddProjectMembers(input: BulkAddProjectMembersInput!): BulkAddProjectMembersPayload! @authorize(policy: "AdminRequiredPolicy") @@ -201,11 +219,11 @@ type NotFoundError implements Error { } type OrgMember { + user: User! + organization: Project! userId: UUID! orgId: UUID! role: OrgRole! - user: User - organization: Organization id: UUID! createdDate: DateTime! updatedDate: DateTime! @@ -360,6 +378,10 @@ union AddProjectMemberError = NotFoundError | DbError | ProjectMembersMustBeVeri union BulkAddProjectMembersError = NotFoundError | InvalidEmailError | DbError +union ChangeOrgMemberRoleError = DbError | NotFoundError + +union ChangeOrgNameError = NotFoundError | DbError | RequiredError + union ChangeProjectDescriptionError = NotFoundError | DbError union ChangeProjectMemberRoleError = NotFoundError | DbError | ProjectMembersMustBeVerified | ProjectMembersMustBeVerifiedForRole @@ -378,6 +400,8 @@ union CreateProjectError = DbError | AlreadyExistsError | ProjectCreatorsMustHav union DeleteDraftProjectError = NotFoundError | DbError +union DeleteOrgError = DbError + union DeleteUserByAdminOrSelfError = NotFoundError | DbError union LeaveProjectError = NotFoundError | LastMemberCantLeaveError @@ -408,6 +432,17 @@ input BulkAddProjectMembersInput { passwordHash: String! } +input ChangeOrgMemberRoleInput { + orgId: UUID! + userId: UUID! + role: OrgRole +} + +input ChangeOrgNameInput { + orgId: UUID! + name: String! +} + input ChangeProjectDescriptionInput { projectId: UUID! description: String! @@ -481,6 +516,10 @@ input DeleteDraftProjectInput { draftProjectId: UUID! } +input DeleteOrgInput { + orgId: UUID! +} + input DeleteUserByAdminOrSelfInput { userId: UUID! } diff --git a/frontend/src/lib/app.postcss b/frontend/src/lib/app.postcss index ee5ff9963..909847aef 100644 --- a/frontend/src/lib/app.postcss +++ b/frontend/src/lib/app.postcss @@ -192,3 +192,27 @@ img[src*="onestory-editor-logo"] { radial-gradient(ellipse 10px 70% at center right, oklch(var(--b3)) 0px, rgba(0, 0, 0, 0) 100%); background-attachment: local, local, scroll, scroll, local, local, scroll, scroll; } + +.tab { + /* https://daisyui.com/docs/themes/#-5 */ + --tab-border: 0.1rem; + /* using a tab radius leads to tiny rendering issues at random screen sizes */ + --tab-radius: 0; + + /* https://daisyui.com/components/tab/#tabs-with-custom-color */ + --tab-border-color: oklch(var(--bc)); + + &:not(.tab-active):not(.tab-divider) { + border: var(--tab-border) solid var(--tab-border-color); + + &:hover { + @apply bg-base-200; + } + } + + /* .tab-divider needs .tab so it can access the tab css-variables */ + &.tab-divider { + @apply px-2; + border-bottom: var(--tab-border) solid var(--tab-border-color); + } +} diff --git a/frontend/src/lib/components/Badges/MemberBadge.svelte b/frontend/src/lib/components/Badges/MemberBadge.svelte index 5cfa1cbd8..822c5be6b 100644 --- a/frontend/src/lib/components/Badges/MemberBadge.svelte +++ b/frontend/src/lib/components/Badges/MemberBadge.svelte @@ -1,6 +1,6 @@ @@ -21,6 +20,6 @@ - + diff --git a/frontend/src/lib/components/Orgs/FormatUserOrgRole.svelte b/frontend/src/lib/components/Orgs/FormatUserOrgRole.svelte new file mode 100644 index 000000000..c1a63f8bb --- /dev/null +++ b/frontend/src/lib/components/Orgs/FormatUserOrgRole.svelte @@ -0,0 +1,16 @@ + + +{_role ?? $t('unknown')} diff --git a/frontend/src/lib/components/FormatUserProjectRole.svelte b/frontend/src/lib/components/Projects/FormatUserProjectRole.svelte similarity index 67% rename from frontend/src/lib/components/FormatUserProjectRole.svelte rename to frontend/src/lib/components/Projects/FormatUserProjectRole.svelte index a93002bc5..c0a823ffd 100644 --- a/frontend/src/lib/components/FormatUserProjectRole.svelte +++ b/frontend/src/lib/components/Projects/FormatUserProjectRole.svelte @@ -2,15 +2,15 @@ import { ProjectRole } from '$lib/gql/types'; import t from '$lib/i18n'; - export let projectRole: ProjectRole; + export let role: ProjectRole; const roles: Record = { [ProjectRole.Manager]: $t('project_role.manager'), [ProjectRole.Editor]: $t('project_role.editor'), - [ProjectRole.Unknown]: $t('unknown') + [ProjectRole.Unknown]: $t('unknown'), }; - $: role = roles[projectRole]; + $: _role = roles[role]; -{role ?? $t('unknown')} +{_role ?? $t('unknown')} diff --git a/frontend/src/lib/components/help/index.ts b/frontend/src/lib/components/help/index.ts index 142bfd6f9..599af1220 100644 --- a/frontend/src/lib/components/help/index.ts +++ b/frontend/src/lib/components/help/index.ts @@ -4,6 +4,7 @@ export const helpLinks = { helpList: 'https://scribehow.com/page/Language_Depot_How-tos__Jy5qu62XRQ-pVGGw6-Cqbw', createProject: 'https://scribehow.com/shared/Create_a_Project__3LFa5XTHSmOLbSSOm8hZKQ', addProjectMember: 'https://scribehow.com/shared/Add_Project_Member__bUJVVK2QT9KhWMqtiPYckA', + addOrgMember: 'https://scribehow.com/shared/Add_Project_Member__bUJVVK2QT9KhWMqtiPYckA', // TODO: Create Add_Org_Member help confidentiality: 'https://scribehow.com/shared/Project_Confidentiality__s6TX8_wFQ1ejVpH1s5Bsmw', bulkAddCreate: 'https://scribehow.com/shared/Bulk_AddCreate_Project_Members__3wwDKk3TTGaAwMEmT4rrXQ', projectRequest: 'https://scribehow.com/shared/Project_requests__zOdcHT8KRGygGmPgr5z2_A', diff --git a/frontend/src/lib/components/modals/FormModal.svelte b/frontend/src/lib/components/modals/FormModal.svelte index dddaac573..e9e51f7bf 100644 --- a/frontend/src/lib/components/modals/FormModal.svelte +++ b/frontend/src/lib/components/modals/FormModal.svelte @@ -89,7 +89,7 @@ reset()} bottom closeOnClickOutside={!$tainted}>
-

+

diff --git a/frontend/src/lib/forms/OrgRoleSelect.svelte b/frontend/src/lib/forms/OrgRoleSelect.svelte new file mode 100644 index 000000000..b322aabda --- /dev/null +++ b/frontend/src/lib/forms/OrgRoleSelect.svelte @@ -0,0 +1,18 @@ + + + diff --git a/frontend/src/lib/forms/index.ts b/frontend/src/lib/forms/index.ts index 9564bc1cc..4fb3d6635 100644 --- a/frontend/src/lib/forms/index.ts +++ b/frontend/src/lib/forms/index.ts @@ -14,6 +14,7 @@ import { lexSuperForm } from './superforms'; import type { ErrorMessage } from './types'; export * from './utils'; import SystemRoleSelect from './SystemRoleSelect.svelte'; +import OrgRoleSelect from './OrgRoleSelect.svelte'; import ProjectRoleSelect from './ProjectRoleSelect.svelte'; import ProjectTypeSelect from './ProjectTypeSelect.svelte'; import DisplayLanguageSelect from './DisplayLanguageSelect.svelte'; @@ -35,6 +36,7 @@ export { type Token, type ErrorMessage, SystemRoleSelect, + OrgRoleSelect, ProjectRoleSelect, ProjectTypeSelect, DisplayLanguageSelect, diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index b584ee0ba..b276b3375 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -6,6 +6,7 @@ "title": "Admin Dashboard", "column_code": "Code", "column_email": "Email", + "column_email_or_login": "Email / Login", "column_login": "Login", "column_last_change": "Last Change", "column_migrated": "Migrated", @@ -108,6 +109,15 @@ "remove": "Remove {entityName}" }, "environment_warning": "This is a {environmentName} environment. Click here to go to the public site.", + "delete_org_modal": { + "title": "Delete Organization", + "submit": "Delete Organization", + "enter_to_delete": { + "label": "Enter ''{_value}'' to confirm deleting the organization ''{name}''", + "value": "DELETE ORGANIZATION" + }, + "success": "Organization ''{name}'' was deleted." + }, "delete_project_modal": { "title": "Delete Project", "submit": "Delete Project", @@ -214,6 +224,58 @@ the [Linguistics Institute at Payap University](https://li.payap.ac.th/) in Chia } }, }, + "org_page": { + "organization": "Organization", + "add_user": { + "add_button": "Add Member", + "__comment_add_button": "Should become 'Add/Invite Member' once email invitations implemented for orgs", + "modal_title": "Add a Member to this organization", + "__comment_modal_title": "Should become 'Add or invite a Member ...' once email invitations implemented for orgs", + "submit_button": "Add Member", + "empty_user_field": "Please enter an email address or login", + "submit_button_email": "Add Member", + "__comment_submit_button_email": "Should become 'Add or invite Member' once email invitations implemented for orgs", + "org_not_found": "Organization not found. Please refresh the page.", + "username_not_found": "No user was found with this login", + "user_must_be_verified": "User needs a verified email address", + "admin_must_be_verified": "User needs a verified email address in order to be an organization admin", + "user_already_member": "User is already a member of this organization", + "user_needs_to_relogin": "Added members will need to log out and back in again before they see the new organization.", + "invalid_email_address": "Invalid email address: {email}", + }, + "change_role_modal": { + "title": "Choose role for {name}", + "button_label": "Change Role" + }, + "delete_modal": { + "submit": "Delete Organization" + }, + "details": { + "created_at": "Created", + "updated_at": "Last updated", + "member_count": "Members", + "project_count": "Projects", + }, + "notifications": { + "role_change": "Organizational role of {name} set to {role}.", + "user_delete": "{name} has been removed.", + "rename_org": "Organization name set to {name}.", + "delete_org": "Organization {name} has been deleted.", + "leave_org": "You have left the organization {name}.", + "leave_org_error": "An error occurred trying to remove you from organization {name}. Please try again later.", + "describe": "Organization description has been updated.", + "add_member": "{email} has been added to organization.", + "member_invited": "{email} has been sent an invitation email to register and join the organization." + }, + "edit_member_role": "Change Role", + "remove_member": "Remove", + "org_name_empty_error": "Organization name cannot be empty", + "projects_table_title": "Projects", + "members_table_title": "Members", + "settings_view_title": "Settings", + "history_view_title": "History", + "leave_org": "Leave Organization", + }, "project_page": { "project": "Project", "add_user": { @@ -301,8 +363,10 @@ the [Linguistics Institute at Payap University](https://li.payap.ac.th/) in Chia "last_commit": "Last Commit", "members": { "title": "Members", + "filter_members_placeholder": "Filter members...", "show_all": "Show all...", "show_less": "Show less", + "no_matching": "No matching members", }, "add_description": "Add description...", "remove_project_user_title": "Member", @@ -374,6 +438,15 @@ If you don't see a dialog or already closed it, click the button below:", "leave_project": "Leave Project" }, }, + "org_role": { + "label": "Role", + "user": "Member", + "user_description": "Member (can join projects)", + "admin": "Admin", + "admin_description": "Admin (can join projects & add new members)", + "unknown": "Unknown", + "unknown_description": "Unknown" + }, "project_role": { "label": "Role", "editor": "Editor", diff --git a/frontend/src/lib/layout/DetailItem.svelte b/frontend/src/lib/layout/DetailItem.svelte new file mode 100644 index 000000000..290076d5b --- /dev/null +++ b/frontend/src/lib/layout/DetailItem.svelte @@ -0,0 +1,17 @@ + + +
+ {title}: {text} + {#if copyToClipboard} + + {/if} + {#if $$slots.extras} + + {/if} +
diff --git a/frontend/src/lib/layout/DetailsPage.svelte b/frontend/src/lib/layout/DetailsPage.svelte new file mode 100644 index 000000000..b98bc1bdd --- /dev/null +++ b/frontend/src/lib/layout/DetailsPage.svelte @@ -0,0 +1,30 @@ + + + + + + + + {#if $$slots.badges} + + + + {/if} + + + {#if $$slots.details} +
+

{$t('project_page.summary')}

+ +
+ {/if} + +
diff --git a/frontend/src/lib/layout/EditableDetailItem.svelte b/frontend/src/lib/layout/EditableDetailItem.svelte new file mode 100644 index 000000000..a774c211f --- /dev/null +++ b/frontend/src/lib/layout/EditableDetailItem.svelte @@ -0,0 +1,21 @@ + + +
+
+ {title}: +
+ + + +
diff --git a/frontend/src/lib/layout/index.ts b/frontend/src/lib/layout/index.ts index 01ddc54c1..c540fc48a 100644 --- a/frontend/src/lib/layout/index.ts +++ b/frontend/src/lib/layout/index.ts @@ -2,6 +2,9 @@ import AdminContent from './AdminContent.svelte' import AppBar from './AppBar.svelte' import AppMenu from './AppMenu.svelte' import Content from './Content.svelte' +import DetailItem from './DetailItem.svelte' +import DetailsPage from './DetailsPage.svelte' +import EditableDetailItem from './EditableDetailItem.svelte' import Footer from './Footer.svelte' import HeaderPage from './HeaderPage.svelte' import Layout from './Layout.svelte' @@ -20,6 +23,9 @@ export { PageTitle, TitlePage, HeaderPage, + DetailItem, + EditableDetailItem, + DetailsPage, } export * from './Breadcrumbs' diff --git a/frontend/src/routes/(authenticated)/admin/+page.svelte b/frontend/src/routes/(authenticated)/admin/+page.svelte index 59df52d22..1b045a5da 100644 --- a/frontend/src/routes/(authenticated)/admin/+page.svelte +++ b/frontend/src/routes/(authenticated)/admin/+page.svelte @@ -8,8 +8,6 @@ import { useNotifications } from '$lib/notify'; import { DialogResponse } from '$lib/components/modals'; import { Duration } from '$lib/util/time'; - import { AdminIcon, Icon } from '$lib/icons'; - import Dropdown from '$lib/components/Dropdown.svelte'; import FilterBar from '$lib/components/FilterBar/FilterBar.svelte'; import { RefineFilterMessage } from '$lib/components/Table'; import type { AdminSearchParams, User } from './+page'; @@ -18,13 +16,13 @@ import { derived } from 'svelte/store'; import AdminProjects from './AdminProjects.svelte'; import UserModal from '$lib/components/Users/UserModal.svelte'; - import { Button } from '$lib/forms'; import { PageBreadcrumb } from '$lib/layout'; import AdminTabs, { type AdminTabId } from './AdminTabs.svelte'; import { createGuestUserByAdmin, type LexAuthUser } from '$lib/user'; import CreateUserModal from '$lib/components/Users/CreateUserModal.svelte'; import type { Confidentiality } from '$lib/components/Projects'; import { browser } from '$app/environment'; + import UserTable from './UserTable.svelte'; export let data: PageData; $: projects = data.projects; @@ -157,97 +155,12 @@
- - - - - - - - - - {#each shownUsers as user} - - - - - - - {/each} - -
- {$t('admin_dashboard.column_name')} - - {$t('admin_dashboard.column_email')} -
-
- - {#if user.locked} - - - - {/if} - {#if user.isAdmin} - - - - {/if} -
-
- - {#if user.email} - - {user.email} - - {#if !user.emailVerified} - - - - {/if} - {:else} - – - {/if} - - - - - - -
+ userModal.open(event.detail)} + on:editUser={(event) => openModal(event.detail)} + on:filterProjectsByUser={(event) => filterProjectsByUser(event.detail)} + />
diff --git a/frontend/src/routes/(authenticated)/admin/AdminTabs.svelte b/frontend/src/routes/(authenticated)/admin/AdminTabs.svelte index 8c258d3f9..ffac799ab 100644 --- a/frontend/src/routes/(authenticated)/admin/AdminTabs.svelte +++ b/frontend/src/routes/(authenticated)/admin/AdminTabs.svelte @@ -44,29 +44,3 @@ {$t(DEFAULT_TAB_I18N[activeTab])}
- - diff --git a/frontend/src/routes/(authenticated)/admin/UserTable.svelte b/frontend/src/routes/(authenticated)/admin/UserTable.svelte new file mode 100644 index 000000000..6ee9adc45 --- /dev/null +++ b/frontend/src/routes/(authenticated)/admin/UserTable.svelte @@ -0,0 +1,108 @@ + + + + + + + + + + + + {#each shownUsers as user} + + + + + + + {/each} + +
+ {$t('admin_dashboard.column_name')} + + {$t('admin_dashboard.column_email')} +
+
+ + {#if user.locked} + + + + {/if} + {#if user.isAdmin} + + + + {/if} +
+
+ + {#if user.email} + + {user.email} + + {#if !user.emailVerified} + + + + {/if} + {:else} + – + {/if} + + + + + + +
diff --git a/frontend/src/routes/(authenticated)/org/[org_id]/+page.svelte b/frontend/src/routes/(authenticated)/org/[org_id]/+page.svelte new file mode 100644 index 000000000..f4b7c35c0 --- /dev/null +++ b/frontend/src/routes/(authenticated)/org/[org_id]/+page.svelte @@ -0,0 +1,154 @@ + + + + + {#if canManage} + + + {/if} + +
+ {$t('org_page.organization')}: + + + +
+
+ + +
+
+ {#if $queryParamValues.tab === 'projects'} + Projects list will go here once orgs have projects associated with them + {:else if $queryParamValues.tab === 'members'} + openUserModal(event.detail)} + on:removeMember={(event) => _deleteOrgUser(org.id, event.detail.id)} + on:changeMemberRole={(event) => openChangeMemberRoleModal(event.detail)} + /> + {:else if $queryParamValues.tab === 'history'} +
+ + +
+ {:else if $queryParamValues.tab === 'settings'} +
+ +
+ +
+
+ +
+ + {/if} +
+ + + + diff --git a/frontend/src/routes/(authenticated)/org/[org_id]/+page.ts b/frontend/src/routes/(authenticated)/org/[org_id]/+page.ts new file mode 100644 index 000000000..effa8dbc6 --- /dev/null +++ b/frontend/src/routes/(authenticated)/org/[org_id]/+page.ts @@ -0,0 +1,211 @@ +import type { + $OpResult, + AddOrgMemberMutation, + ChangeOrgMemberRoleMutation, + ChangeOrgNameInput, + ChangeOrgNameMutation, + DeleteOrgMutation, + DeleteOrgUserMutation, + OrgPageQuery, + OrgRole, +} from '$lib/gql/types'; +import { getClient, graphql } from '$lib/gql'; + +import type { OrgTabId } from './OrgTabs.svelte'; +import type { PageLoadEvent } from './$types'; +import type { UUID } from 'crypto'; +import { error } from '@sveltejs/kit'; +import { tryMakeNonNullable } from '$lib/util/store'; + +export type Org = NonNullable; +export type OrgUser = Org['members'][number]; +export type User = OrgUser['user']; + +export async function load(event: PageLoadEvent) { + const client = getClient(); + const user = (await event.parent()).user; + const userIsAdmin = user.isAdmin; + const orgId = event.params.org_id as UUID; + const orgResult = await client + .awaitedQueryStore(event.fetch, + graphql(` + query orgPage($orgId: UUID!, $userIsAdmin: Boolean!) { + orgById(orgId: $orgId) { + id + createdDate + updatedDate + name + members { + id + role + user { + id + name + ... on User @include(if: $userIsAdmin) { + locked + username + createdDate + updatedDate + email + localizationCode + lastActive + canCreateProjects + isAdmin + emailVerified + } + } + } + } + } + `), + { orgId, userIsAdmin } + ); + + const nonNullableOrg = tryMakeNonNullable(orgResult.orgById); + if (!nonNullableOrg) { + error(404); + } + + event.depends(`org:${orgId}`); + + return { + org: nonNullableOrg, + id: orgId, + user, + }; +} + +export type OrgSearchParams = { + tab: OrgTabId +}; + +export async function _changeOrgName(input: ChangeOrgNameInput): $OpResult { + //language=GraphQL + const result = await getClient() + .mutation( + graphql(` + mutation ChangeOrgName($input: ChangeOrgNameInput!) { + changeOrgName(input: $input) { + organization { + id + name + } + errors { + ... on Error { + message + } + } + } + } + `), + { input: input } + ); + return result; +} + +export async function _deleteOrgUser(orgId: string, userId: string): $OpResult { + const result = await getClient() + .mutation( + graphql(` + mutation deleteOrgUser($input: ChangeOrgMemberRoleInput!) { + changeOrgMemberRole(input: $input) { + organization { + id + members { + id + role + user { + id + name + } + } + } + } + } + `), + { input: { orgId, userId, role: null } } + ); + return result; +} + +export async function _addOrgMember(orgId: UUID, emailOrUsername: string, role: OrgRole): $OpResult { + //language=GraphQL + const result = await getClient() + .mutation( + graphql(` + mutation AddOrgMember($input: SetOrgMemberRoleInput!) { + setOrgMemberRole(input: $input) { + organization { + id + members { + id + role + user { + id + name + } + } + } + errors { + __typename + ... on Error { + message + } + } + } + } + `), + { input: { orgId, emailOrUsername, role} }, + ); + return result; +} + +export async function _changeOrgMemberRole(orgId: string, userId: string, role: OrgRole): $OpResult { + //language=GraphQL + const result = await getClient() + .mutation( + graphql(` + mutation ChangeOrgMemberRole($input: ChangeOrgMemberRoleInput!) { + changeOrgMemberRole(input: $input) { + organization { + id + members { + id + role + user { + id + name + } + } + } + errors { + __typename + ... on Error { + message + } + } + } + } + `), + { input: { orgId, userId, role} }, + ); + return result; +} + +export async function _deleteOrg(orgId: string): $OpResult { + const result = await getClient() + .mutation( + graphql(` + mutation deleteOrg($input: DeleteOrgInput!) { + deleteOrg(input: $input) { + organization { + id + name + } + } + } + `), + { input: { orgId } } + ); + return result; +} diff --git a/frontend/src/routes/(authenticated)/org/[org_id]/AddOrgMemberModal.svelte b/frontend/src/routes/(authenticated)/org/[org_id]/AddOrgMemberModal.svelte new file mode 100644 index 000000000..2a7af4dac --- /dev/null +++ b/frontend/src/routes/(authenticated)/org/[org_id]/AddOrgMemberModal.svelte @@ -0,0 +1,84 @@ + + + + + {$t('org_page.add_user.modal_title')} + + + + {#if $page.data.user?.isAdmin} + + {:else} + + {/if} + + + {#if $form.usernameOrEmail.includes('@')} + {$t('org_page.add_user.submit_button_email')} + {:else} + {$t('org_page.add_user.submit_button')} + {/if} + + diff --git a/frontend/src/routes/(authenticated)/org/[org_id]/ChangeOrgMemberRoleModal.svelte b/frontend/src/routes/(authenticated)/org/[org_id]/ChangeOrgMemberRoleModal.svelte new file mode 100644 index 000000000..6ebe38440 --- /dev/null +++ b/frontend/src/routes/(authenticated)/org/[org_id]/ChangeOrgMemberRoleModal.svelte @@ -0,0 +1,37 @@ + + + + {$t('org_page.change_role_modal.title', { name })} + + {$t('org_page.change_role_modal.button_label')} + diff --git a/frontend/src/routes/(authenticated)/org/[org_id]/OrgMemberTable.svelte b/frontend/src/routes/(authenticated)/org/[org_id]/OrgMemberTable.svelte new file mode 100644 index 000000000..121afa7cf --- /dev/null +++ b/frontend/src/routes/(authenticated)/org/[org_id]/OrgMemberTable.svelte @@ -0,0 +1,99 @@ + + +
+ + + + + + + + + + + {#each shownUsers as member} + {@const user = member.user} + + + + + + + {/each} + +
+ {$t('admin_dashboard.column_name')} + + {$t('admin_dashboard.column_email_or_login')} + {$t('admin_dashboard.column_role')} + +
+
+ + {#if user.locked} + + + + {/if} +
+
+ + + {user.email ?? user.username} + + {#if user.email && !user.emailVerified} + + + + {/if} + + + + + + + + +
+
diff --git a/frontend/src/routes/(authenticated)/org/[org_id]/OrgTabs.svelte b/frontend/src/routes/(authenticated)/org/[org_id]/OrgTabs.svelte new file mode 100644 index 000000000..b444f7075 --- /dev/null +++ b/frontend/src/routes/(authenticated)/org/[org_id]/OrgTabs.svelte @@ -0,0 +1,51 @@ + + + + +
+
+ {#each orgTabs as tab} + {@const isActiveTab = activeTab === tab} + +
+ {/each} +
diff --git a/frontend/src/routes/(authenticated)/project/[project_code]/+page.svelte b/frontend/src/routes/(authenticated)/project/[project_code]/+page.svelte index deefdcc76..1f4a80b77 100644 --- a/frontend/src/routes/(authenticated)/project/[project_code]/+page.svelte +++ b/frontend/src/routes/(authenticated)/project/[project_code]/+page.svelte @@ -1,5 +1,5 @@ diff --git a/frontend/src/routes/(authenticated)/project/[project_code]/ChangeMemberRoleModal.svelte b/frontend/src/routes/(authenticated)/project/[project_code]/ChangeMemberRoleModal.svelte index ed9d53923..675dc479b 100644 --- a/frontend/src/routes/(authenticated)/project/[project_code]/ChangeMemberRoleModal.svelte +++ b/frontend/src/routes/(authenticated)/project/[project_code]/ChangeMemberRoleModal.svelte @@ -5,11 +5,12 @@ import t from '$lib/i18n'; import { z } from 'zod'; import { _changeProjectMemberRole } from './+page'; + import type { UUID } from 'crypto'; export let projectId: string; const schema = z.object({ - role: z.enum([ProjectRole.Editor, ProjectRole.Manager]), + role: z.enum([ProjectRole.Editor, ProjectRole.Manager]) }); type Schema = typeof schema; let formModal: FormModal; @@ -17,13 +18,13 @@ let name: string; - export async function open(member: { userId: string; name: string; role: ProjectRole }): Promise> { + export async function open(member: { userId: UUID; name: string; role: ProjectRole }): Promise> { name = member.name; return await formModal.open(tryParse(schema, member), async () => { const result = await _changeProjectMemberRole({ - projectId, + projectId: projectId, userId: member.userId, - role: $form.role, + role: $form.role as ProjectRole, }); if (result.error?.byType('ProjectMembersMustBeVerified')) { return { role: [$t('project_page.add_user.user_must_be_verified')] }; diff --git a/frontend/src/routes/(authenticated)/project/[project_code]/MembersList.svelte b/frontend/src/routes/(authenticated)/project/[project_code]/MembersList.svelte new file mode 100644 index 000000000..3cf3e8232 --- /dev/null +++ b/frontend/src/routes/(authenticated)/project/[project_code]/MembersList.svelte @@ -0,0 +1,145 @@ + + + + +
+

+ {$t('project_page.members.title')} + {#if members?.length > TRUNCATED_MEMBER_COUNT} +

+ +
+ {/if} +

+ + TRUNCATED_MEMBER_COUNT}> + + {#if !members.length} + {$t('common.none')} + {:else if !showMembers.length} + {$t('project_page.members.no_matching')} + {/if} + + {#each showMembers as member (member.id)} + {@const canManage = canManageMember(member)} + + + + + {/each} + + {#if members.length > TRUNCATED_MEMBER_COUNT} +
+ +
+ {/if} + + {#if canManageList} +
+ +
+ {/if} + + +
+ +