diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index ad3b041f691c7..9d36930f5efce 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -21,6 +21,7 @@ import type { ExecutionStatus, IExecutionsSummary, FeatureFlags, + IUserSettings, } from 'n8n-workflow'; import type { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; @@ -478,13 +479,6 @@ export interface IPersonalizationSurveyAnswers { workArea: string[] | string | null; } -export interface IUserSettings { - isOnboarded?: boolean; - showUserActivationSurvey?: boolean; - firstSuccessfulWorkflowId?: string; - userActivated?: boolean; -} - export interface IActiveDirectorySettings { enabled: boolean; } diff --git a/packages/cli/src/controllers/auth.controller.ts b/packages/cli/src/controllers/auth.controller.ts index f025b60eea0b2..5ae9fe0d7a894 100644 --- a/packages/cli/src/controllers/auth.controller.ts +++ b/packages/cli/src/controllers/auth.controller.ts @@ -76,7 +76,10 @@ export class AuthController { // attempt to fetch user data with the credentials, but don't log in yet const preliminaryUser = await handleEmailLogin(email, password); // if the user is an owner, continue with the login - if (preliminaryUser?.globalRole?.name === 'owner') { + if ( + preliminaryUser?.globalRole?.name === 'owner' || + preliminaryUser?.settings?.allowSSOManualLogin + ) { user = preliminaryUser; usedAuthenticationMethod = 'email'; } else { diff --git a/packages/cli/src/controllers/passwordReset.controller.ts b/packages/cli/src/controllers/passwordReset.controller.ts index 64e46582066bd..f316fe2da9325 100644 --- a/packages/cli/src/controllers/passwordReset.controller.ts +++ b/packages/cli/src/controllers/passwordReset.controller.ts @@ -1,5 +1,4 @@ import { IsNull, MoreThanOrEqual, Not } from 'typeorm'; -import { v4 as uuid } from 'uuid'; import validator from 'validator'; import { Get, Post, RestController } from '@/decorators'; import { @@ -25,6 +24,7 @@ import type { IDatabaseCollections, IExternalHooksClass, IInternalHooksClass } f import { issueCookie } from '@/auth/jwt'; import { isLdapEnabled } from '@/Ldap/helpers'; import { isSamlCurrentAuthenticationMethod } from '../sso/ssoHelpers'; +import { UserService } from '../user/user.service'; @RestController() export class PasswordResetController { @@ -103,7 +103,10 @@ export class PasswordResetController { relations: ['authIdentities', 'globalRole'], }); - if (isSamlCurrentAuthenticationMethod() && user?.globalRole.name !== 'owner') { + if ( + isSamlCurrentAuthenticationMethod() && + !(user?.globalRole.name === 'owner' || user?.settings?.allowSSOManualLogin === true) + ) { this.logger.debug( 'Request to send password reset email failed because login is handled by SAML', ); @@ -126,18 +129,9 @@ export class PasswordResetController { throw new UnprocessableRequestError('forgotPassword.ldapUserPasswordResetUnavailable'); } - user.resetPasswordToken = uuid(); - - const { id, firstName, lastName, resetPasswordToken } = user; - - const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) + 7200; - - await this.userRepository.update(id, { resetPasswordToken, resetPasswordTokenExpiration }); - const baseUrl = getInstanceBaseUrl(); - const url = new URL(`${baseUrl}/change-password`); - url.searchParams.append('userId', id); - url.searchParams.append('token', resetPasswordToken); + const { id, firstName, lastName } = user; + const url = UserService.generatePasswordResetUrl(user); try { await this.mailer.passwordReset({ diff --git a/packages/cli/src/controllers/users.controller.ts b/packages/cli/src/controllers/users.controller.ts index c2ae07d344d1d..e68f8699685f0 100644 --- a/packages/cli/src/controllers/users.controller.ts +++ b/packages/cli/src/controllers/users.controller.ts @@ -5,7 +5,7 @@ import { ErrorReporterProxy as ErrorReporter } from 'n8n-workflow'; import { User } from '@db/entities/User'; import { SharedCredentials } from '@db/entities/SharedCredentials'; import { SharedWorkflow } from '@db/entities/SharedWorkflow'; -import { Authorized, NoAuthRequired, Delete, Get, Post, RestController } from '@/decorators'; +import { Authorized, NoAuthRequired, Delete, Get, Post, RestController, Patch } from '@/decorators'; import { addInviteLinkToUser, generateUserInviteUrl, @@ -20,7 +20,7 @@ import { issueCookie } from '@/auth/jwt'; import { BadRequestError, InternalServerError, NotFoundError } from '@/ResponseHelper'; import { Response } from 'express'; import type { Config } from '@/config'; -import { UserRequest } from '@/requests'; +import { UserRequest, UserSettingsUpdatePayload } from '@/requests'; import type { UserManagementMailer } from '@/UserManagement/email'; import type { PublicUser, @@ -40,6 +40,8 @@ import type { SharedWorkflowRepository, UserRepository, } from '@db/repositories'; +import { UserService } from '../user/user.service'; +import { plainToInstance } from 'class-transformer'; @Authorized(['global', 'owner']) @RestController('/users') @@ -355,6 +357,38 @@ export class UsersController { ); } + @Authorized(['global', 'owner']) + @Get('/:id/password-reset-link') + async getUserPasswordResetLink(req: UserRequest.PasswordResetLink) { + const user = await this.userRepository.findOneOrFail({ + where: { id: req.params.id }, + }); + if (!user) { + throw new NotFoundError('User not found'); + } + const link = await UserService.generatePasswordResetUrl(user); + return { + link, + }; + } + + @Authorized(['global', 'owner']) + @Patch('/:id/settings') + async updateUserSettings(req: UserRequest.UserSettingsUpdate) { + const payload = plainToInstance(UserSettingsUpdatePayload, req.body); + + const id = req.params.id; + + await UserService.updateUserSettings(id, payload); + + const user = await this.userRepository.findOneOrFail({ + select: ['settings'], + where: { id }, + }); + + return user.settings; + } + /** * Delete a user. Optionally, designate a transferee for their workflows and credentials. */ diff --git a/packages/cli/src/databases/entities/User.ts b/packages/cli/src/databases/entities/User.ts index 6cba438f7942a..1aa334d489bf3 100644 --- a/packages/cli/src/databases/entities/User.ts +++ b/packages/cli/src/databases/entities/User.ts @@ -11,14 +11,14 @@ import { BeforeInsert, } from 'typeorm'; import { IsEmail, IsString, Length } from 'class-validator'; -import type { IUser } from 'n8n-workflow'; +import type { IUser, IUserSettings } from 'n8n-workflow'; import { Role } from './Role'; import type { SharedWorkflow } from './SharedWorkflow'; import type { SharedCredentials } from './SharedCredentials'; import { NoXss } from '../utils/customValidators'; import { objectRetriever, lowerCaser } from '../utils/transformers'; import { AbstractEntity, jsonColumnType } from './AbstractEntity'; -import type { IPersonalizationSurveyAnswers, IUserSettings } from '@/Interfaces'; +import type { IPersonalizationSurveyAnswers } from '@/Interfaces'; import type { AuthIdentity } from './AuthIdentity'; export const MIN_PASSWORD_LENGTH = 8; diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index af1fdae591b43..a18e8b0f26f0d 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -40,6 +40,10 @@ export class UserSettingsUpdatePayload { @IsBoolean({ message: 'userActivated should be a boolean' }) @IsOptional() userActivated: boolean; + + @IsBoolean({ message: 'allowSSOManualLogin should be a boolean' }) + @IsOptional() + allowSSOManualLogin?: boolean; } export type AuthlessRequest< @@ -250,6 +254,14 @@ export declare namespace UserRequest { { limit?: number; offset?: number; cursor?: string; includeRole?: boolean } >; + export type PasswordResetLink = AuthenticatedRequest<{ id: string }, {}, {}, {}>; + + export type UserSettingsUpdate = AuthenticatedRequest< + { id: string }, + {}, + UserSettingsUpdatePayload + >; + export type Reinvite = AuthenticatedRequest<{ id: string }>; export type Update = AuthlessRequest< diff --git a/packages/cli/src/user/user.service.ts b/packages/cli/src/user/user.service.ts index a7f8bff52d0c8..6183a97e59d59 100644 --- a/packages/cli/src/user/user.service.ts +++ b/packages/cli/src/user/user.service.ts @@ -1,8 +1,10 @@ import type { EntityManager, FindOptionsWhere } from 'typeorm'; import { In } from 'typeorm'; +import { v4 as uuid } from 'uuid'; import * as Db from '@/Db'; import { User } from '@db/entities/User'; -import type { IUserSettings } from '@/Interfaces'; +import type { IUserSettings } from 'n8n-workflow'; +import { getInstanceBaseUrl } from '../UserManagement/UserManagementHelper'; export class UserService { static async get(where: FindOptionsWhere): Promise { @@ -22,4 +24,17 @@ export class UserService { }); return Db.collections.User.update(id, { settings: { ...currentSettings, ...userSettings } }); } + + static async generatePasswordResetUrl(user: User): Promise { + user.resetPasswordToken = uuid(); + const { id, resetPasswordToken } = user; + const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) + 7200; + await Db.collections.User.update(id, { resetPasswordToken, resetPasswordTokenExpiration }); + + const baseUrl = getInstanceBaseUrl(); + const url = new URL(`${baseUrl}/change-password`); + url.searchParams.append('userId', id); + url.searchParams.append('token', resetPasswordToken); + return url.toString(); + } } diff --git a/packages/design-system/src/components/N8nUserInfo/UserInfo.vue b/packages/design-system/src/components/N8nUserInfo/UserInfo.vue index 248507b052aec..ec780a157752e 100644 --- a/packages/design-system/src/components/N8nUserInfo/UserInfo.vue +++ b/packages/design-system/src/components/N8nUserInfo/UserInfo.vue @@ -23,7 +23,14 @@
- Sign-in type: {{ signInType }} + Sign-in type: + {{ + isSamlLoginEnabled + ? settings?.allowSSOManualLogin + ? $locale.baseText('settings.sso') + ' + ' + signInType + : $locale.baseText('settings.sso') + : signInType + }}
@@ -71,6 +78,14 @@ export default defineComponent({ type: String, required: false, }, + settings: { + type: Object, + required: false, + }, + isSamlLoginEnabled: { + type: Boolean, + required: false, + }, }, computed: { classes(): Record { diff --git a/packages/design-system/src/components/N8nUsersList/UsersList.vue b/packages/design-system/src/components/N8nUsersList/UsersList.vue index 9ea1075c894fd..9ab256dd412f2 100644 --- a/packages/design-system/src/components/N8nUsersList/UsersList.vue +++ b/packages/design-system/src/components/N8nUsersList/UsersList.vue @@ -7,7 +7,11 @@ :class="i === sortedUsers.length - 1 ? $style.itemContainer : $style.itemWithBorder" :data-test-id="`user-list-item-${user.email}`" > - +
{{ t('nds.auth.roles.owner') }} @@ -67,6 +71,10 @@ export default defineComponent({ type: Array as PropType, default: () => [], }, + isSamlLoginEnabled: { + type: Boolean, + default: false, + }, }, computed: { sortedUsers(): IUser[] { diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 3b9546127abcd..8e561d56646c7 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -33,6 +33,7 @@ import type { IN8nUISettings, IUserManagementSettings, WorkflowSettings, + IUserSettings, } from 'n8n-workflow'; import type { SignInType } from './constants'; import type { @@ -561,12 +562,7 @@ export interface IUserResponse { personalizationAnswers?: IPersonalizationSurveyVersions | null; isPending: boolean; signInType?: SignInType; - settings?: { - isOnboarded?: boolean; - showUserActivationSurvey?: boolean; - firstSuccessfulWorkflowId?: string; - userActivated?: boolean; - }; + settings?: IUserSettings; } export interface CurrentUserResponse extends IUserResponse { diff --git a/packages/editor-ui/src/api/users.ts b/packages/editor-ui/src/api/users.ts index 5067da18c4bf2..bb360d4902763 100644 --- a/packages/editor-ui/src/api/users.ts +++ b/packages/editor-ui/src/api/users.ts @@ -105,7 +105,15 @@ export async function updateCurrentUserSettings( context: IRestApiContext, settings: IUserResponse['settings'], ): Promise { - return makeRestApiRequest(context, 'PATCH', '/me/settings', settings); + return makeRestApiRequest(context, 'PATCH', '/me/settings', settings as IDataObject); +} + +export async function updateOtherUserSettings( + context: IRestApiContext, + userId: string, + settings: IUserResponse['settings'], +): Promise { + return makeRestApiRequest(context, 'PATCH', `/users/${userId}/settings`, settings as IDataObject); } export async function updateCurrentUserPassword( @@ -144,6 +152,13 @@ export async function getInviteLink( return makeRestApiRequest(context, 'GET', `/users/${id}/invite-link`); } +export async function getPasswordResetLink( + context: IRestApiContext, + { id }: { id: string }, +): Promise<{ link: string }> { + return makeRestApiRequest(context, 'GET', `/users/${id}/password-reset-link`); +} + export async function submitPersonalizationSurvey( context: IRestApiContext, params: IPersonalizationLatestVersion, diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 0e9829ac5947b..ac1366799d8d5 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -1169,6 +1169,9 @@ "settings.users.actions.delete": "Delete User", "settings.users.actions.reinvite": "Resend Invite", "settings.users.actions.copyInviteLink": "Copy Invite Link", + "settings.users.actions.copyPasswordResetLink": "Copy Password Reset Link", + "settings.users.actions.allowSSOManualLogin": "Allow Manual Login", + "settings.users.actions.disallowSSOManualLogin": "Disallow Manual Login", "settings.users.deleteWorkflowsAndCredentials": "Delete their workflows and credentials", "settings.users.emailInvitesSent": "An invite email was sent to {emails}", "settings.users.emailInvitesSentError": "Could not invite {emails}", @@ -1187,6 +1190,12 @@ "settings.users.inviteXUser.inviteUrl": "Create {count} invite links", "settings.users.inviteUrlCreated": "Invite link copied to clipboard", "settings.users.inviteUrlCreated.message": "Send the invite link to your invitee for activation", + "settings.users.passwordResetUrlCreated": "Password reset link copied to clipboard", + "settings.users.passwordResetUrlCreated.message": "Send the reset link to your user for them to reset their password", + "settings.users.allowSSOManualLogin": "Manual Login Allowed", + "settings.users.allowSSOManualLogin.message": "User can now login manually and through SSO", + "settings.users.disallowSSOManualLogin": "Manual Login Disallowed", + "settings.users.disallowSSOManualLogin.message": "User must now login through SSO only", "settings.users.multipleInviteUrlsCreated": "Invite links created", "settings.users.multipleInviteUrlsCreated.message": "Send the invite links to your invitees for activation", "settings.users.newEmailsToInvite": "New User Email Addresses", diff --git a/packages/editor-ui/src/stores/users.store.ts b/packages/editor-ui/src/stores/users.store.ts index 6f8d203764c38..200deaa2a06f6 100644 --- a/packages/editor-ui/src/stores/users.store.ts +++ b/packages/editor-ui/src/stores/users.store.ts @@ -2,6 +2,7 @@ import { changePassword, deleteUser, getInviteLink, + getPasswordResetLink, getUsers, inviteUsers, login, @@ -17,6 +18,7 @@ import { updateCurrentUser, updateCurrentUserPassword, updateCurrentUserSettings, + updateOtherUserSettings, validatePasswordToken, validateSignupToken, } from '@/api/users'; @@ -251,6 +253,19 @@ export const useUsersStore = defineStore(STORES.USERS, { this.addUsers([this.currentUser]); } }, + async updateOtherUserSettings( + userId: string, + settings: IUserResponse['settings'], + ): Promise { + const rootStore = useRootStore(); + const updatedSettings = await updateOtherUserSettings( + rootStore.getRestApiContext, + userId, + settings, + ); + this.users[userId].settings = updatedSettings; + this.addUsers([this.users[userId]]); + }, async updateCurrentUserPassword({ password, currentPassword, @@ -288,6 +303,10 @@ export const useUsersStore = defineStore(STORES.USERS, { const rootStore = useRootStore(); return getInviteLink(rootStore.getRestApiContext, params); }, + async getUserPasswordResetLink(params: { id: string }): Promise<{ link: string }> { + const rootStore = useRootStore(); + return getPasswordResetLink(rootStore.getRestApiContext, params); + }, async submitPersonalizationSurvey(results: IPersonalizationLatestVersion): Promise { const rootStore = useRootStore(); await submitPersonalizationSurvey(rootStore.getRestApiContext, results); diff --git a/packages/editor-ui/src/views/SettingsUsersView.vue b/packages/editor-ui/src/views/SettingsUsersView.vue index 0bc2c6b1a0dc6..4080bb8eb7ef5 100644 --- a/packages/editor-ui/src/views/SettingsUsersView.vue +++ b/packages/editor-ui/src/views/SettingsUsersView.vue @@ -50,9 +50,13 @@ :actions="usersListActions" :users="usersStore.allUsers" :currentUserId="usersStore.currentUserId" + :isSamlLoginEnabled="ssoStore.isSamlLoginEnabled" @delete="onDelete" @reinvite="onReinvite" @copyInviteLink="onCopyInviteLink" + @copyPasswordResetLink="onCopyPasswordResetLink" + @allowSSOManualLogin="onAllowSSOManualLogin" + @disallowSSOManualLogin="onDisallowSSOManualLogin" />
@@ -106,6 +110,22 @@ export default defineComponent({ label: this.$locale.baseText('settings.users.actions.delete'), value: 'delete', }, + { + label: this.$locale.baseText('settings.users.actions.copyPasswordResetLink'), + value: 'copyPasswordResetLink', + }, + { + label: this.$locale.baseText('settings.users.actions.allowSSOManualLogin'), + value: 'allowSSOManualLogin', + guard: (user) => + this.settingsStore.isSamlLoginEnabled && !user.settings?.allowSSOManualLogin, + }, + { + label: this.$locale.baseText('settings.users.actions.disallowSSOManualLogin'), + value: 'disallowSSOManualLogin', + guard: (user) => + this.settingsStore.isSamlLoginEnabled && user.settings?.allowSSOManualLogin === true, + }, ]; }, }, @@ -152,6 +172,44 @@ export default defineComponent({ }); } }, + async onCopyPasswordResetLink(userId: string) { + const user = this.usersStore.getUserById(userId) as IUser | null; + if (user) { + const url = await this.usersStore.getUserPasswordResetLink(user); + this.copyToClipboard(url.link); + + this.showToast({ + type: 'success', + title: this.$locale.baseText('settings.users.passwordResetUrlCreated'), + message: this.$locale.baseText('settings.users.passwordResetUrlCreated.message'), + }); + } + }, + async onAllowSSOManualLogin(userId: string) { + const user = this.usersStore.getUserById(userId) as IUser | null; + if (user?.settings) { + user.settings.allowSSOManualLogin = true; + await this.usersStore.updateOtherUserSettings(userId, user.settings); + + this.showToast({ + type: 'success', + title: this.$locale.baseText('settings.users.allowSSOManualLogin'), + message: this.$locale.baseText('settings.users.allowSSOManualLogin.message'), + }); + } + }, + async onDisallowSSOManualLogin(userId: string) { + const user = this.usersStore.getUserById(userId) as IUser | null; + if (user?.settings) { + user.settings.allowSSOManualLogin = false; + await this.usersStore.updateOtherUserSettings(userId, user.settings); + this.showToast({ + type: 'success', + title: this.$locale.baseText('settings.users.disallowSSOManualLogin'), + message: this.$locale.baseText('settings.users.disallowSSOManualLogin.message'), + }); + } + }, goToUpgrade() { this.uiStore.goToUpgrade('users', 'upgrade-users'); }, diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 60a3555546a82..f71244e31611e 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -1961,6 +1961,14 @@ export interface IUserManagementSettings { authenticationMethod: AuthenticationMethod; } +export interface IUserSettings { + isOnboarded?: boolean; + showUserActivationSurvey?: boolean; + firstSuccessfulWorkflowId?: string; + userActivated?: boolean; + allowSSOManualLogin?: boolean; +} + export interface IPublicApiSettings { enabled: boolean; latestVersion: number;