mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-11 12:57:29 -08:00
feat: Add manual login option and password reset link for SSO (#6328)
* consolidate IUserSettings in workflow and add allowSSOManualLogin * add pw reset link to owners ui
This commit is contained in:
parent
8f0ff460b1
commit
77e3f1551d
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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<
|
||||
|
|
|
@ -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<User>): Promise<User | null> {
|
||||
|
@ -22,4 +24,17 @@ export class UserService {
|
|||
});
|
||||
return Db.collections.User.update(id, { settings: { ...currentSettings, ...userSettings } });
|
||||
}
|
||||
|
||||
static async generatePasswordResetUrl(user: User): Promise<string> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,7 +23,14 @@
|
|||
</div>
|
||||
<div v-if="!isOwner">
|
||||
<n8n-text v-if="signInType" size="small" color="text-light">
|
||||
Sign-in type: {{ signInType }}
|
||||
Sign-in type:
|
||||
{{
|
||||
isSamlLoginEnabled
|
||||
? settings?.allowSSOManualLogin
|
||||
? $locale.baseText('settings.sso') + ' + ' + signInType
|
||||
: $locale.baseText('settings.sso')
|
||||
: signInType
|
||||
}}
|
||||
</n8n-text>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -71,6 +78,14 @@ export default defineComponent({
|
|||
type: String,
|
||||
required: false,
|
||||
},
|
||||
settings: {
|
||||
type: Object,
|
||||
required: false,
|
||||
},
|
||||
isSamlLoginEnabled: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
classes(): Record<string, boolean> {
|
||||
|
|
|
@ -7,7 +7,11 @@
|
|||
:class="i === sortedUsers.length - 1 ? $style.itemContainer : $style.itemWithBorder"
|
||||
:data-test-id="`user-list-item-${user.email}`"
|
||||
>
|
||||
<n8n-user-info v-bind="user" :isCurrentUser="currentUserId === user.id" />
|
||||
<n8n-user-info
|
||||
v-bind="user"
|
||||
:isCurrentUser="currentUserId === user.id"
|
||||
:isSamlLoginEnabled="isSamlLoginEnabled"
|
||||
/>
|
||||
<div :class="$style.badgeContainer">
|
||||
<n8n-badge v-if="user.isOwner" theme="tertiary" bold>
|
||||
{{ t('nds.auth.roles.owner') }}
|
||||
|
@ -67,6 +71,10 @@ export default defineComponent({
|
|||
type: Array as PropType<UserAction[]>,
|
||||
default: () => [],
|
||||
},
|
||||
isSamlLoginEnabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
sortedUsers(): IUser[] {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -105,7 +105,15 @@ export async function updateCurrentUserSettings(
|
|||
context: IRestApiContext,
|
||||
settings: IUserResponse['settings'],
|
||||
): Promise<IUserResponse['settings']> {
|
||||
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<IUserResponse['settings']> {
|
||||
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,
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
const rootStore = useRootStore();
|
||||
await submitPersonalizationSurvey(rootStore.getRestApiContext, results);
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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');
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in a new issue