UI: remove cluster route and model boundary route mixins (#11873) (#12025)

* remove cluster route and model boundary route mixins

* add copyright header

* remove old unit test

* change replication/application route

* don't use cluster route in replication

* why have a base class at all?

* test tweaks

* remove afterModel redirect in replication

* refactor targetRouteName to use derived state

* Update route class name on replication-dr-promote.js



---------

Co-authored-by: Matthew Irish <39469+meirish@users.noreply.github.com>
Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>
This commit is contained in:
Vault Automation 2026-01-29 12:02:04 -05:00 committed by GitHub
parent 1a68ff6123
commit 2eb6905459
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 492 additions and 643 deletions

View file

@ -6,9 +6,11 @@
import Ember from 'ember';
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { tracked } from 'tracked-built-ins';
import type { TrackedSet } from 'tracked-built-ins';
import { action } from '@ember/object';
import { task, timeout } from 'ember-concurrency';
import { waitFor } from '@ember/test-waiters';
import errorMessage from 'vault/utils/error-message';
import MfaConstraint from 'vault/resources/mfa/constraint';
@ -54,7 +56,7 @@ export default class MfaForm extends Component<Args> {
@tracked error = '';
// Self-enrollment is per MFA method, not per login enforcement (constraint)
// Track method IDs used to fetch a QR code so we don't re-request if a user just enrolled.
@tracked enrolledMethods = new Set<string>();
enrolledMethods: TrackedSet<string> = tracked(Set);
constructor(owner: unknown, args: Args) {
super(owner, args);
@ -124,7 +126,7 @@ export default class MfaForm extends Component<Args> {
await this.args.loginAndTransition.unlinked().perform(response);
} catch (error) {
// Reset enrolled methods if there's an error
this.enrolledMethods = new Set<string>();
this.enrolledMethods.clear();
const errorMsg = errorMessage(error);
const codeUsed = errorMsg.includes('code already used');
const rateLimit = errorMsg.includes('maximum TOTP validation attempts');
@ -141,30 +143,32 @@ export default class MfaForm extends Component<Args> {
}
});
fetchQrCode = task(async (mfa_method_id: string, constraint: MfaConstraint) => {
// Self-enrollment is an enterprise only feature
if (this.version.isCommunity) return;
fetchQrCode = task(
waitFor(async (mfa_method_id: string, constraint: MfaConstraint) => {
// Self-enrollment is an enterprise only feature
if (this.version.isCommunity) return;
const adapter = this.store.adapterFor('application');
const { mfaRequirement } = this.args.authData;
try {
const { data } = await adapter.ajax('/v1/identity/mfa/method/totp/self-enroll', 'POST', {
unauthenticated: true,
data: { mfa_method_id, mfa_request_id: mfaRequirement.mfa_request_id },
});
if (data?.url) {
// Set QR code which recomputes currentSelfEnrollConstraint and renders it for the user to scan
constraint.qrCode = data.url;
// Add mfa_method_id to list of already enrolled methods for client-side tracking
this.enrolledMethods.add(mfa_method_id);
return;
const adapter = this.store.adapterFor('application');
const { mfaRequirement } = this.args.authData;
try {
const { data } = await adapter.ajax('/v1/identity/mfa/method/totp/self-enroll', 'POST', {
unauthenticated: true,
data: { mfa_method_id, mfa_request_id: mfaRequirement.mfa_request_id },
});
if (data?.url) {
// Set QR code which recomputes currentSelfEnrollConstraint and renders it for the user to scan
constraint.qrCode = data.url;
// Add mfa_method_id to list of already enrolled methods for client-side tracking
this.enrolledMethods.add(mfa_method_id);
return;
}
// Not sure it's realistic to get here without the endpoint throwing an error, but just in case!
this.error = 'There was a problem generating the QR code. Please try again.';
} catch (error) {
this.error = errorMessage(error);
}
// Not sure it's realistic to get here without the endpoint throwing an error, but just in case!
this.error = 'There was a problem generating the QR code. Please try again.';
} catch (error) {
this.error = errorMessage(error);
}
});
})
);
newCodeDelay = task(async (errorMessage) => {
let delay;

View file

@ -4,8 +4,9 @@
*/
import Controller from '@ember/controller';
import { tracked } from '@glimmer/tracking';
export default class VaultController extends Controller {
queryParams = [{ redirectTo: 'redirect_to' }];
redirectTo = '';
@tracked redirectTo = '';
}

View file

@ -1,18 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import config from '../config/environment';
export function initialize(/* application */) {
// attach mount hooks to the environment config
// context will be the router DSL
config.addRootMounts = function () {
this.mount('replication');
};
}
export default {
initialize,
};

View file

@ -1,119 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { service } from '@ember/service';
import Mixin from '@ember/object/mixin';
import RSVP from 'rsvp';
import {
INIT,
UNSEAL,
AUTH,
CLUSTER,
CLUSTER_INDEX,
OIDC_CALLBACK,
OIDC_PROVIDER,
NS_OIDC_PROVIDER,
DR_REPLICATION_SECONDARY,
DR_REPLICATION_SECONDARY_DETAILS,
EXCLUDED_REDIRECT_URLS,
REDIRECT,
} from 'vault/lib/route-paths';
export default Mixin.create({
auth: service(),
store: service(),
router: service(),
transitionToTargetRoute(transition = {}) {
const targetRoute = this.targetRouteName(transition);
if (
targetRoute &&
targetRoute !== this.routeName &&
targetRoute !== transition.targetName &&
targetRoute !== this.router.currentRouteName
) {
// there may be query params so check for inclusion rather than exact match
const isExcluded = EXCLUDED_REDIRECT_URLS.find((url) => this.router.currentURL?.includes(url));
if (
// only want to redirect if we're going to authenticate
targetRoute === AUTH &&
transition.targetName !== CLUSTER_INDEX &&
!isExcluded
) {
return this.router.transitionTo(targetRoute, {
queryParams: { redirect_to: this.router.currentURL },
});
}
return this.router.transitionTo(targetRoute);
}
return RSVP.resolve();
},
beforeModel(transition) {
return this.transitionToTargetRoute(transition);
},
clusterModel() {
return this.modelFor(CLUSTER) || this.store.peekRecord('cluster', 'vault');
},
authToken() {
return this.auth.currentToken;
},
hasKeyData() {
/* eslint-disable-next-line ember/no-controller-access-in-routes */
return !!this.controllerFor(INIT).keyData;
},
targetRouteName(transition) {
const cluster = this.clusterModel();
const isAuthed = this.authToken();
if (cluster.needsInit) {
return INIT;
}
if (this.hasKeyData() && this.routeName !== UNSEAL && this.routeName !== AUTH) {
return INIT;
}
if (cluster.sealed) {
return UNSEAL;
}
if (cluster?.dr?.isSecondary) {
if (transition && transition.targetName === DR_REPLICATION_SECONDARY_DETAILS) {
return DR_REPLICATION_SECONDARY_DETAILS;
}
if (this.router.currentRouteName === DR_REPLICATION_SECONDARY_DETAILS) {
return DR_REPLICATION_SECONDARY_DETAILS;
}
return DR_REPLICATION_SECONDARY;
}
if (!isAuthed) {
if ((transition && transition.targetName === OIDC_PROVIDER) || this.routeName === OIDC_PROVIDER) {
return OIDC_PROVIDER;
}
if ((transition && transition.targetName === NS_OIDC_PROVIDER) || this.routeName === NS_OIDC_PROVIDER) {
return NS_OIDC_PROVIDER;
}
if ((transition && transition.targetName === OIDC_CALLBACK) || this.routeName === OIDC_CALLBACK) {
return OIDC_CALLBACK;
}
return AUTH;
}
if (
(!cluster.needsInit && this.routeName === INIT) ||
(!cluster.sealed && this.routeName === UNSEAL) ||
(!cluster?.dr?.isSecondary && this.routeName === DR_REPLICATION_SECONDARY)
) {
return CLUSTER;
}
if (isAuthed && this.routeName === AUTH) {
// if you're already authed and you wanna go to auth, you probably want to redirect
return REDIRECT;
}
return null;
},
});

View file

@ -1,73 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
// meant for use mixed-in to a Route file
//
// When a route is deactivated, this mixin clears the Ember Data store of
// models of type specified by the required param `modelType`.
//
// example:
// Using this as with a modelType of `datacenter` on the infrastructure
// route will cause all `datacenter` models to get unloaded when the
// infrastructure route is navigated away from.
import Route from '@ember/routing/route';
import { isPresent } from '@ember/utils';
import { warn } from '@ember/debug';
import { on } from '@ember/object/evented';
import Mixin from '@ember/object/mixin';
export default Mixin.create({
modelType: null,
modelTypes: null,
verifyProps: on('init', function () {
var modelType = this.modelType;
var modelTypes = this.modelTypes;
warn(
'No `modelType` or `modelTypes` specified for `' +
this.toString() +
'`. Check to make sure you still need to use the `model-boundary-route` mixin.',
isPresent(modelType) || isPresent(modelTypes),
{ id: 'model-boundary-init' }
);
warn(
'Expected `model-boundary-route` to be used on an Ember.Route, not `' + this.toString() + '`.',
this instanceof Route,
{ id: 'mode-boundary-is-route' }
);
}),
clearModelCache: on('deactivate', function () {
var modelType = this.modelType;
var modelTypes = this.modelTypes;
if (!modelType && !modelTypes) {
warn(
'Attempted to clear store clear store cache when leaving `' +
this.routeName +
'`, but no `modelType` or `modelTypes` was specified.',
isPresent(modelType),
{ id: 'model-boundary-clear' }
);
return;
}
if (this.store.isDestroyed || this.store.isDestroying) {
// Prevent unload attempt after test teardown, resulting in test failure
return;
}
if (modelType) {
this.store.unloadAll(modelType);
}
if (modelTypes) {
modelTypes.forEach((type) => {
this.store.unloadAll(type);
});
}
}),
});

View file

@ -223,10 +223,7 @@ Router.map(function () {
this.route('replication-dr-promote', function () {
this.route('details');
});
if (config.addRootMounts) {
config.addRootMounts.call(this);
}
this.mount('replication');
this.route('not-found', { path: '/*path' });
});
this.route('not-found', { path: '/*path' });

View file

@ -4,19 +4,37 @@
*/
import { service } from '@ember/service';
import { computed } from '@ember/object';
import { reject } from 'rsvp';
import Route from '@ember/routing/route';
import { reject } from 'rsvp';
import { task, timeout } from 'ember-concurrency';
import Ember from 'ember';
import getStorage from '../../lib/token-storage';
import localStorage from 'vault/lib/local-storage';
import ClusterRoute from 'vault/mixins/cluster-route';
import ModelBoundaryRoute from 'vault/mixins/model-boundary-route';
import clearModelCache from 'vault/utils/shared-model-boundary';
import { assert } from '@ember/debug';
import { v4 as uuidv4 } from 'uuid';
import {
INIT,
UNSEAL,
AUTH,
CLUSTER_INDEX,
OIDC_CALLBACK,
OIDC_PROVIDER,
NS_OIDC_PROVIDER,
DR_REPLICATION_SECONDARY,
DR_REPLICATION_SECONDARY_DETAILS,
EXCLUDED_REDIRECT_URLS,
} from 'vault/lib/route-paths';
const CLUSTER_STATE = {
UNINITIALIZED: 'uninitialized',
SEALED: 'sealed',
DR_SECONDARY: 'dr-secondary',
ACTIVE: 'active',
};
const POLL_INTERVAL_MS = 10000;
export const getManagedNamespace = (nsParam, root) => {
@ -29,34 +47,33 @@ export const getManagedNamespace = (nsParam, root) => {
return `${root}/${nsParam}`;
};
export default Route.extend(ModelBoundaryRoute, ClusterRoute, {
auth: service(),
api: service(),
analytics: service(),
currentCluster: service(),
customMessages: service(),
flagsService: service('flags'),
namespaceService: service('namespace'),
permissions: service(),
router: service(),
store: service(),
version: service(),
modelTypes: computed(function () {
return ['node', 'secret', 'secret-engine'];
}),
export default class ClusterRoute extends Route {
@service auth;
@service api;
@service analytics;
@service currentCluster;
@service customMessages;
@service('flags') flagsService;
@service('namespace') namespaceService;
@service permissions;
@service router;
@service store;
@service version;
queryParams: {
modelTypes = ['node', 'secret', 'secret-engine'];
queryParams = {
namespaceQueryParam: {
refreshModel: true,
},
},
};
getClusterId(params) {
const { cluster_name } = params;
const records = this.store.peekAll('cluster');
const cluster = records.find((record) => record.name === cluster_name);
return cluster?.id ?? null;
},
}
async beforeModel() {
const params = this.paramsFor(this.routeName);
@ -98,7 +115,7 @@ export default Route.extend(ModelBoundaryRoute, ClusterRoute, {
} else {
return reject({ httpStatus: 404, message: 'not found', path: params.cluster_name });
}
},
}
model(params) {
// if a user's browser settings block localStorage they will be unable to use Vault. The method will throw the error and the rest of the application will not load.
@ -106,9 +123,10 @@ export default Route.extend(ModelBoundaryRoute, ClusterRoute, {
const id = this.getClusterId(params);
return this.store.findRecord('cluster', id);
},
}
poll: task(function* () {
poll = task({ keepLatest: true }, async () => {
// eslint-disable-next-line no-constant-condition
while (true) {
// In test mode, polling causes acceptance tests to hang due to never-settling promises.
// To avoid this, polling is disabled during tests.
@ -117,22 +135,20 @@ export default Route.extend(ModelBoundaryRoute, ClusterRoute, {
if (Ember.testing) {
return;
}
yield timeout(POLL_INTERVAL_MS);
await timeout(POLL_INTERVAL_MS);
try {
/* eslint-disable-next-line ember/no-controller-access-in-routes */
yield this.controller.model.reload();
yield this.transitionToTargetRoute();
await this.controller.model.reload();
await this.transitionToTargetRoute();
} catch (e) {
// we want to keep polling here
}
}
})
.cancelOn('deactivate')
.keepLatest(),
});
// Note: do not make this afterModel hook async, it will break the DR secondary flow.
afterModel(model, transition) {
this._super(...arguments);
super.afterModel(...arguments);
this.currentCluster.setCluster(model);
if (model.needsInit && this.auth.currentToken) {
@ -156,7 +172,7 @@ export default Route.extend(ModelBoundaryRoute, ClusterRoute, {
this.addAnalyticsService(model);
return this.transitionToTargetRoute(transition);
},
}
async addAnalyticsService(model) {
// identify user for analytics service
@ -187,19 +203,91 @@ export default Route.extend(ModelBoundaryRoute, ClusterRoute, {
console.error('unable to start analytics', e);
}
}
},
}
deactivate() {
clearModelCache(this.store, this.modelTypes);
this.poll.cancelAll();
}
setupController() {
this._super(...arguments);
super.setupController(...arguments);
this.poll.perform();
},
}
actions: {
error(e) {
if (e.httpStatus === 503 && e.errors[0] === 'Vault is sealed') {
this.refresh();
error = (e) => {
if (e.httpStatus === 503 && e.errors[0] === 'Vault is sealed') {
this.refresh();
}
return true;
};
get clusterState() {
const cluster = this.modelFor(this.routeName);
switch (true) {
case cluster.needsInit:
return CLUSTER_STATE.UNINITIALIZED;
case cluster.sealed:
return CLUSTER_STATE.SEALED;
case cluster.dr?.isSecondary:
return CLUSTER_STATE.DR_SECONDARY;
default:
return CLUSTER_STATE.ACTIVE;
}
}
get allowedRedirectRoutes() {
const state = this.clusterState;
const isAuthenticated = !!this.auth.currentToken;
switch (true) {
case state === CLUSTER_STATE.UNINITIALIZED:
return [INIT];
case state === CLUSTER_STATE.SEALED:
// here we allow init so that we don't redirect users who haven't downloaded keys after init
return [UNSEAL, INIT];
case state === CLUSTER_STATE.DR_SECONDARY:
return [DR_REPLICATION_SECONDARY, DR_REPLICATION_SECONDARY_DETAILS];
case isAuthenticated === false:
return [AUTH, OIDC_PROVIDER, NS_OIDC_PROVIDER, OIDC_CALLBACK];
default:
return [null];
}
}
targetRouteName(transition) {
const currentRouteName = this.router.currentRouteName;
const allowedRoutes = this.allowedRedirectRoutes;
// if the target route or current route is in the allowed routes, go there, otherwise choose the first allowed route
return (
allowedRoutes?.find((r) => transition?.targetName === r || currentRouteName === r) || allowedRoutes[0]
);
}
transitionToTargetRoute(transition = {}) {
const targetRoute = this.targetRouteName(transition);
if (
targetRoute &&
targetRoute !== this.routeName &&
targetRoute !== transition.targetName &&
targetRoute !== this.router.currentRouteName
) {
// there may be query params so check for inclusion rather than exact match
const isExcluded = EXCLUDED_REDIRECT_URLS.find((url) => this.router.currentURL?.includes(url));
if (
// only want to redirect if we're going to authenticate
targetRoute === AUTH &&
transition.targetName !== CLUSTER_INDEX &&
!isExcluded
) {
return this.router.transitionTo(targetRoute, {
queryParams: { redirect_to: this.router.currentURL },
});
}
return true;
},
},
});
return this.router.transitionTo(targetRoute);
}
return Promise.resolve();
}
}

View file

@ -3,16 +3,20 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import { computed } from '@ember/object';
import { service } from '@ember/service';
import Route from '@ember/routing/route';
import ClusterRoute from 'vault/mixins/cluster-route';
import ModelBoundaryRoute from 'vault/mixins/model-boundary-route';
import clearModelCache from 'vault/utils/shared-model-boundary';
export default class AccessRoute extends Route {
@service store;
modelTypes = ['capabilities', 'control-group', 'identity/group', 'identity/group-alias', 'identity/alias'];
export default Route.extend(ModelBoundaryRoute, ClusterRoute, {
modelTypes: computed(function () {
return ['capabilities', 'control-group', 'identity/group', 'identity/group-alias', 'identity/alias'];
}),
model() {
return {};
},
});
}
deactivate() {
clearModelCache(this.store, this.modelTypes);
}
}

View file

@ -3,14 +3,13 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import ClusterRoute from 'vault/mixins/cluster-route';
import { service } from '@ember/service';
import Route from '@ember/routing/route';
export default Route.extend(ClusterRoute, {
store: service(),
export default class LeasesRoute extends Route {
@service store;
model() {
return this.store.findRecord('capabilities', 'sys/leases/lookup/');
},
});
}
}

View file

@ -4,13 +4,14 @@
*/
import { service } from '@ember/service';
import ClusterRouteBase from './cluster-route-base';
import Route from '@ember/routing/route';
import config from 'vault/config/environment';
import { supportedTypes } from 'vault/utils/auth-form-helpers';
import { sanitizePath } from 'core/utils/sanitize-path';
import AuthMethodResource from 'vault/resources/auth/method';
import { REDIRECT } from 'vault/lib/route-paths';
export default class AuthRoute extends ClusterRouteBase {
export default class AuthRoute extends Route {
queryParams = {
authMount: { replace: true, refreshModel: true },
wrapped_token: { refreshModel: true },
@ -20,13 +21,15 @@ export default class AuthRoute extends ClusterRouteBase {
@service auth;
@service flashMessages;
@service namespace;
@service router;
@service store;
@service version;
beforeModel() {
return super.beforeModel().then(() => {
return this.version.fetchFeatures();
});
if (this.auth.currentToken) {
return this.router.replaceWith(REDIRECT);
}
return this.version.fetchFeatures();
}
async model(params) {
@ -62,13 +65,14 @@ export default class AuthRoute extends ClusterRouteBase {
redirect(model, transition) {
if (model?.unwrapResponse) {
// handles the transition
/* eslint-disable-next-line ember/no-controller-access-in-routes */
return this.controllerFor('vault.cluster.auth').loginAndTransition.perform(model.unwrapResponse);
}
const hasQueryParam = transition.to?.queryParams?.with;
const isInvalid = !model.directLinkData;
if (hasQueryParam && isInvalid) {
// redirect user and clear out the query param if it's invalid
this.router.replaceWith(this.routeName, { queryParams: { authMount: null } });
return this.router.replaceWith(this.routeName, { queryParams: { authMount: '' } });
}
}
@ -80,6 +84,7 @@ export default class AuthRoute extends ClusterRouteBase {
return await this.auth.authSuccess(clusterId, authData);
} catch (e) {
const { message } = await this.api.parseError(e);
/* eslint-disable-next-line ember/no-controller-access-in-routes */
this.controllerFor('vault.cluster.auth').unwrapTokenError = message;
}
}
@ -125,7 +130,7 @@ export default class AuthRoute extends ClusterRouteBase {
/*
In older versions of Vault, the "with" query param could refer to either the auth mount path or the type
(which may be the same, since the default mount path *is* the type).
(which may be the same, since the default mount path *is* the type).
For backward compatibility, we handle both scenarios.
If `authMount` matches a visible auth mount the method will assume that mount path to login and render as the default in the login form.
If `authMount` matches a supported auth type (and the mount does not have `listing_visibility="unauth"`), that type is preselected in the login form.

View file

@ -7,17 +7,16 @@
// all of the CLUSTER_ROUTES that are states before you can use vault
//
import Route from '@ember/routing/route';
import ClusterRoute from 'vault/mixins/cluster-route';
/**
* @type Class
*/
export default Route.extend(ClusterRoute, {
export default class ClusterBaseRoute extends Route {
model() {
return this.modelFor('vault.cluster');
},
}
resetController(controller) {
controller.reset && controller.reset();
},
});
}
}

View file

@ -3,14 +3,12 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { service } from '@ember/service';
// eslint-disable-next-line ember/no-mixins
import ClusterRoute from 'vault/mixins/cluster-route';
import { action } from '@ember/object';
import Route from '@ember/routing/route';
import SecretsEngineResource from 'vault/resources/secrets/engine';
export default class VaultClusterDashboardRoute extends Route.extend(ClusterRoute) {
export default class VaultClusterDashboardRoute extends Route {
@service store;
@service namespace;
@service version;

View file

@ -3,6 +3,18 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import ClusterRoute from './cluster-route-base';
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { CLUSTER } from 'vault/lib/route-paths';
export default ClusterRoute;
export default class InitRoute extends Route {
@service router;
beforeModel() {
const cluster = this.modelFor(CLUSTER);
// if it doesn't need init, nav to cluster route
if (!cluster.needsInit) {
return this.router.replaceWith(CLUSTER);
}
}
}

View file

@ -3,22 +3,21 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import ClusterRoute from 'vault/mixins/cluster-route';
import { service } from '@ember/service';
import Route from '@ember/routing/route';
export default Route.extend(ClusterRoute, {
store: service(),
version: service(),
router: service(),
export default class LicenseRoute extends Route {
@service store;
@service version;
@service router;
beforeModel() {
if (this.version.isCommunity) {
this.router.transitionTo('vault.cluster');
}
},
}
model() {
return this.store.queryRecord('license', {});
},
});
}
}

View file

@ -4,25 +4,23 @@
*/
import Ember from 'ember';
import { computed } from '@ember/object';
import { service } from '@ember/service';
import Route from '@ember/routing/route';
import ModelBoundaryRoute from 'vault/mixins/model-boundary-route';
import clearModelCache from 'vault/utils/shared-model-boundary';
export default Route.extend(ModelBoundaryRoute, {
auth: service(),
controlGroup: service(),
flashMessages: service(),
console: service(),
permissions: service(),
namespaceService: service('namespace'),
router: service(),
version: service(),
customMessages: service(),
export default class LogoutRoute extends Route {
@service auth;
@service store;
@service controlGroup;
@service flashMessages;
@service console;
@service permissions;
@service('namespace') namespaceService;
@service router;
@service version;
@service customMessages;
modelTypes: computed(function () {
return ['secret', 'secret-engine'];
}),
modelTypes = ['secret', 'secret-engine'];
beforeModel({ to: { queryParams } }) {
const ns = this.namespaceService.path;
@ -49,5 +47,8 @@ export default Route.extend(ModelBoundaryRoute, {
const { cluster_name } = this.paramsFor('vault.cluster');
location.assign(this.router.urlFor('vault.cluster.auth', cluster_name, { queryParams }));
}
},
});
}
deactivate() {
clearModelCache(this.store, this.modelTypes);
}
}

View file

@ -5,18 +5,17 @@
import { service } from '@ember/service';
import Route from '@ember/routing/route';
import ClusterRoute from 'vault/mixins/cluster-route';
const ALLOWED_TYPES = ['acl', 'egp', 'rgp'];
export default Route.extend(ClusterRoute, {
version: service(),
export default class PoliciesRoute extends Route {
@service version;
@service router;
beforeModel() {
return this.version.fetchFeatures().then(() => {
return this._super(...arguments);
return super.beforeModel(...arguments);
});
},
}
model(params) {
const policyType = params.type;
@ -24,5 +23,5 @@ export default Route.extend(ClusterRoute, {
return this.router.transitionTo(this.routeName, ALLOWED_TYPES[0]);
}
return {};
},
});
}
}

View file

@ -5,10 +5,9 @@
import { service } from '@ember/service';
import Route from '@ember/routing/route';
import ClusterRoute from 'vault/mixins/cluster-route';
import ListRoute from 'core/mixins/list-route';
export default Route.extend(ClusterRoute, ListRoute, {
export default Route.extend(ListRoute, {
pagination: service(),
version: service(),

View file

@ -5,17 +5,17 @@
import { service } from '@ember/service';
import Route from '@ember/routing/route';
import ClusterRoute from 'vault/mixins/cluster-route';
const ALLOWED_TYPES = ['acl', 'egp', 'rgp'];
export default Route.extend(ClusterRoute, {
version: service(),
export default class PolicyRoute extends Route {
@service version;
@service router;
beforeModel() {
return this.version.fetchFeatures().then(() => {
return this._super(...arguments);
return super.beforeModel(...arguments);
});
},
}
model(params) {
const policyType = params.type;
if (!ALLOWED_TYPES.includes(policyType)) {
@ -25,5 +25,5 @@ export default Route.extend(ClusterRoute, {
return this.router.transitionTo('vault.cluster.policies', policyType);
}
return {};
},
});
}
}

View file

@ -0,0 +1,20 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { CLUSTER } from 'vault/lib/route-paths';
export default class ReplicationDrPromote extends Route {
@service router;
beforeModel() {
const cluster = this.modelFor(CLUSTER);
// if it's not a dr secondary, go back to cluster
if (!cluster?.dr?.isSecondary) {
return this.router.replaceWith(CLUSTER);
}
}
}

View file

@ -4,12 +4,11 @@
*/
import { service } from '@ember/service';
import Base from '../cluster-route-base';
import Route from '@ember/routing/route';
export default Base.extend({
replicationMode: service(),
export default class ReplicationDetailsRoute extends Route {
@service replicationMode;
beforeModel() {
this._super(...arguments);
this.replicationMode.setMode('dr');
},
});
}
}

View file

@ -4,12 +4,11 @@
*/
import { service } from '@ember/service';
import Base from '../cluster-route-base';
import Route from '@ember/routing/route';
export default Base.extend({
replicationMode: service(),
export default class ReplicationIndexRoute extends Route {
@service replicationMode;
beforeModel() {
this._super(...arguments);
this.replicationMode.setMode('dr');
},
});
}
}

View file

@ -1,9 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import ClusterRoute from 'vault/mixins/cluster-route';
export default Route.extend(ClusterRoute);

View file

@ -4,10 +4,9 @@
*/
import Route from '@ember/routing/route';
import ClusterRoute from 'vault/mixins/cluster-route';
export default Route.extend(ClusterRoute, {
export default class SettingsRoute extends Route {
model() {
return {};
},
});
}
}

View file

@ -4,22 +4,15 @@
*/
import Route from '@ember/routing/route';
import ClusterRoute from 'vault/mixins/cluster-route';
import { service } from '@ember/service';
export default Route.extend(ClusterRoute, {
store: service(),
export default class StorageRoute extends Route {
@service store;
model() {
// findAll method will return all records in store as well as response from server
// when removing a peer via the cli, stale records would continue to appear until refresh
// query method will only return records from response
return this.store.query('server', {});
},
actions: {
doRefresh() {
this.refresh();
},
},
});
}
}

View file

@ -4,10 +4,9 @@
*/
import Route from '@ember/routing/route';
import ClusterRoute from 'vault/mixins/cluster-route';
export default Route.extend(ClusterRoute, {
export default class ToolsRoute extends Route {
model() {
return this.modelFor('vault.cluster');
},
});
}
}

View file

@ -3,6 +3,18 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import ClusterRoute from './cluster-route-base';
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { CLUSTER } from 'vault/lib/route-paths';
export default ClusterRoute.extend({});
export default class UnsealRoute extends Route {
@service router;
beforeModel() {
const cluster = this.modelFor(CLUSTER);
// if it's not sealed, we don't need to unseal
if (!cluster.sealed) {
return this.router.replaceWith(CLUSTER);
}
}
}

View file

@ -0,0 +1,18 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
export default function clearModelCache(store, modelType) {
if (!modelType) {
return;
}
const modelTypes = Array.isArray(modelType) ? modelType : [modelType];
if (store.isDestroyed || store.isDestroying) {
// Prevent unload attempt after test teardown, resulting in test failure
return;
}
modelTypes.forEach((type) => {
store.unloadAll(type);
});
}

View file

@ -4,16 +4,14 @@
*/
import { service } from '@ember/service';
import { setProperties } from '@ember/object';
import Route from '@ember/routing/route';
import ClusterRoute from 'vault/mixins/cluster-route';
export default Route.extend(ClusterRoute, {
version: service(),
store: service(),
auth: service(),
router: service('app-router'),
capabilities: service(),
export default class ApplicationRoute extends Route {
@service version;
@service store;
@service auth;
@service('app-router') router;
@service capabilities;
async fetchCapabilities() {
const enablePath = (type, cluster) => `sys/replication/${type}/${cluster}/enable`;
@ -29,21 +27,19 @@ export default Route.extend(ClusterRoute, {
canEnablePrimaryPerformance: perms[enablePath('performance', 'secondary')].canUpdate,
canEnableSecondaryPerformance: perms[enablePath('performance', 'secondary')].canUpdate,
};
},
}
beforeModel() {
if (this.auth.activeCluster.replicationRedacted) {
// disallow replication access if endpoints are redacted
return this.router.transitionTo('vault.cluster');
}
return this.version.fetchFeatures().then(() => {
return this._super(...arguments);
});
},
return this.version.fetchFeatures();
}
model() {
return this.auth.activeCluster;
},
}
async afterModel(model) {
const {
@ -53,17 +49,10 @@ export default Route.extend(ClusterRoute, {
canEnableSecondaryPerformance,
} = await this.fetchCapabilities();
setProperties(model, {
canEnablePrimaryDr,
canEnableSecondaryDr,
canEnablePrimaryPerformance,
canEnableSecondaryPerformance,
});
model.canEnablePrimaryDr = canEnablePrimaryDr;
model.canEnableSecondaryDr = canEnableSecondaryDr;
model.canEnablePrimaryPerformance = canEnablePrimaryPerformance;
model.canEnableSecondaryPerformance = canEnableSecondaryPerformance;
return model;
},
actions: {
refresh() {
this.refresh();
},
},
});
}
}

View file

@ -30,12 +30,4 @@ export default Route.extend({
return cluster;
});
},
afterModel(model) {
const replicationMode = this.paramsFor('mode').replication_mode;
const cluster = model[replicationMode];
if (!cluster.isPrimary || cluster.replicationDisabled || cluster.replicationUnsupported) {
return this.router.transitionTo('vault.cluster.replication.mode', replicationMode);
}
},
});

View file

@ -291,7 +291,7 @@ module('Acceptance | auth login', function (hooks) {
assert.expect(6);
this.authType = 'oidc';
this.expectedPayload = {
redirect_uri: 'http://localhost:7357/ui/vault/auth/custom-oidc/oidc/callback',
redirect_uri: `${window.location.origin}/ui/vault/auth/custom-oidc/oidc/callback`,
role: 'some-dev',
};
this.server.post('/auth/custom-oidc/oidc/auth_url', (schema, req) => {

View file

@ -3,7 +3,16 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import { click, fillIn, findAll, currentURL, visit, settled, waitFor } from '@ember/test-helpers';
import {
click,
fillIn,
findAll,
currentURL,
currentRouteName,
visit,
settled,
waitFor,
} from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { login } from 'vault/tests/helpers/auth/auth-helpers';
@ -84,7 +93,7 @@ module('Acceptance | Enterprise | replication', function (hooks) {
assert.ok(findAll('[data-test-demote-warning]').length, 'displays the demotion warning');
});
test('DR primary: shows empty state when secondary mode is not enabled and we navigated to the secondary details page', async function (assert) {
test('DR primary: redirects to cluster route when navigating to the secondary details page', async function (assert) {
// enable dr replication
await visit('/vault/replication/dr');
@ -94,16 +103,7 @@ module('Acceptance | Enterprise | replication', function (hooks) {
await settled(); // eslint-disable-line
await pollCluster(this.owner);
await visit('/vault/replication-dr-promote/details');
assert
.dom('[data-test-component="empty-state"]')
.exists('Empty state is shown when no secondary is configured');
assert
.dom('[data-test-empty-state-message]')
.hasText(
'This Disaster Recovery secondary has not been enabled. You can do so from the Disaster Recovery Primary.',
'Renders the correct message for when a primary is enabled but no secondary is configured and we have navigated to the secondary details page.'
);
assert.strictEqual(currentRouteName(), 'vault.cluster.dashboard', 'redirects to the cluster route');
});
test('DR primary: runs analytics service when enabled', async function (assert) {
@ -126,22 +126,6 @@ module('Acceptance | Enterprise | replication', function (hooks) {
addAnalyticsSpy.restore();
});
test('DR secondary: shows empty state when replication is not enabled', async function (assert) {
await visit('/vault/replication-dr-promote/details');
assert.dom('[data-test-component="empty-state"]').exists();
assert
.dom(GENERAL.emptyStateTitle)
.includesText('Disaster Recovery secondary not set up', 'shows the correct title of the empty state');
assert
.dom(GENERAL.emptyStateMessage)
.hasText(
'This cluster has not been enabled as a Disaster Recovery Secondary. You can do so by enabling replication and adding a secondary from the Disaster Recovery Primary.',
'renders default message specific to when no replication is enabled'
);
});
test('Performance primary: add secondary and delete config', async function (assert) {
const secondaryName = `performanceSecondary`;
const mode = 'deny';

View file

@ -5,7 +5,7 @@
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { click, currentURL, visit } from '@ember/test-helpers';
import { click, currentURL } from '@ember/test-helpers';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { login } from 'vault/tests/helpers/auth/auth-helpers';
@ -16,12 +16,12 @@ module('Acceptance | Enterprise | sidebar navigation', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function () {
return login();
hooks.beforeEach(async function () {
await login();
});
// common links are tested in the sidebar-nav test and will not be covered here
test('it should render enterprise only navigation links', async function (assert) {
test(`it should render enterprise only navigation links`, async function (assert) {
assert.dom(panel('Cluster')).exists('Cluster nav panel renders');
await click(link('Secrets Sync'));
@ -41,12 +41,9 @@ module('Acceptance | Enterprise | sidebar navigation', function (hooks) {
'Replication performance route renders'
);
// for some reason clicking this link would cause the testing browser locally
// to navigate to 'vault/replication/dr' and halt the test runner
assert
.dom(link('Disaster Recovery'))
.hasAttribute('href', '/ui/vault/replication/dr', 'Replication dr route renders');
await visit('/vault');
await click(link('Disaster Recovery'));
assert.strictEqual(currentURL(), '/vault/replication/dr', 'Replication DR route renders');
await click(link('Back to main navigation'));
await click(link('Client Count'));
assert.dom(panel('Client Count')).exists('Client Count nav panel renders');

View file

@ -51,7 +51,10 @@ module('Integration | Component | open-api-explorer | swagger-ui', function (hoo
this.totalApiPaths = Object.keys(this.openApiResponse.paths).length;
this.renderComponent = () => render(hbs`<SwaggerUi/>`, { owner: this.engine });
this.renderComponent = async () => {
await render(hbs`<SwaggerUi/>`, { owner: this.engine });
await waitFor(SELECTORS.searchInput);
};
});
test('it renders', async function (assert) {

View file

@ -0,0 +1,125 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import ClusterRoute from 'vault/routes/vault/cluster';
import { INIT, UNSEAL, AUTH, CLUSTER, CLUSTER_INDEX, DR_REPLICATION_SECONDARY } from 'vault/lib/route-paths';
import { setupTest } from 'ember-qunit';
import { module, test } from 'qunit';
import sinon from 'sinon';
module('Integration | Route | ClusterRoute', function (hooks) {
setupTest(hooks);
function createClusterRoute(
context,
clusterModel = {},
methods = {
auth: { currentToken: null },
}
) {
context.owner.register('route:cluster-route-test', ClusterRoute);
const instance = context.owner.lookup('route:cluster-route-test');
for (const key of Object.keys(methods)) {
if (typeof methods[key] === 'function') {
sinon.stub(instance, key).callsFake(methods[key]);
}
}
if (methods.auth) {
instance.auth = methods.auth;
}
instance.modelFor = () => clusterModel;
instance.router = { transitionTo: () => {} };
return instance;
}
const INIT_TESTS = [
{
clusterState: { needsInit: true },
expected: INIT,
description: 'forwards to INIT when cluster needs init',
},
{
clusterState: { needsInit: false, sealed: true },
expected: UNSEAL,
description: 'forwards to UNSEAL if sealed and initialized',
},
{
clusterState: { needsInit: false, sealed: false },
expected: AUTH,
description: 'forwards to AUTH if unsealed and initialized',
},
{
clusterState: { dr: { isSecondary: true } },
expected: DR_REPLICATION_SECONDARY,
description: 'forwards to DR_REPLICATION_SECONDARY if is a dr secondary',
},
];
for (const { clusterState, expected, description } of INIT_TESTS) {
test(`#targetRouteName init case: ${expected}`, function (assert) {
const subject = createClusterRoute(this, clusterState);
subject.routeName = CLUSTER;
assert.strictEqual(subject.targetRouteName(), expected, description);
});
}
test('#targetRouteName happy path when not authed forwards to AUTH', function (assert) {
const subject = createClusterRoute(
this,
{ needsInit: false, sealed: false, dr: { isSecondary: false } },
{ auth: { currentToken: null } }
);
subject.router.currentRouteName = INIT;
assert.strictEqual(subject.targetRouteName(), AUTH, 'forwards when inited and navigating to INIT');
subject.router.currentRouteName = UNSEAL;
assert.strictEqual(subject.targetRouteName(), AUTH, 'forwards when unsealed and navigating to UNSEAL');
subject.router.currentRouteName = AUTH;
assert.strictEqual(
subject.targetRouteName(),
AUTH,
'forwards when non-authenticated and navigating to AUTH'
);
subject.router.currentRouteName = DR_REPLICATION_SECONDARY;
assert.strictEqual(
subject.targetRouteName(),
AUTH,
'forwards when not a DR secondary and navigating to DR_REPLICATION_SECONDARY'
);
});
test('#transitionToTargetRoute', function (assert) {
const redirectRouteURL = '/vault/secrets-engines/secret/create';
const subject = createClusterRoute(this, { needsInit: false, sealed: false });
subject.router.currentURL = redirectRouteURL;
const spy = sinon.stub(subject.router, 'transitionTo');
subject.transitionToTargetRoute();
assert.ok(
spy.calledWithExactly(AUTH, { queryParams: { redirect_to: redirectRouteURL } }),
'calls transitionTo with the expected args'
);
spy.restore();
});
test('#transitionToTargetRoute with auth as a target', function (assert) {
const subject = createClusterRoute(this, { needsInit: false, sealed: false });
const spy = sinon.stub(subject.router, 'transitionTo');
// in this case it's already transitioning to the AUTH route so we don't need to call transitionTo again
subject.transitionToTargetRoute({ targetName: AUTH });
assert.ok(spy.notCalled, 'transitionTo is not called');
spy.restore();
});
test('#transitionToTargetRoute with auth target, coming from cluster route', function (assert) {
const subject = createClusterRoute(this, { needsInit: false, sealed: false });
const spy = sinon.stub(subject.router, 'transitionTo');
subject.transitionToTargetRoute({ targetName: CLUSTER_INDEX });
assert.ok(spy.calledWithExactly(AUTH), 'calls transitionTo without redirect_to');
spy.restore();
});
});

View file

@ -1,166 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import EmberObject from '@ember/object';
import ClusterRouteMixin from 'vault/mixins/cluster-route';
import {
INIT,
UNSEAL,
AUTH,
CLUSTER,
CLUSTER_INDEX,
DR_REPLICATION_SECONDARY,
REDIRECT,
} from 'vault/lib/route-paths';
import { module, test } from 'qunit';
import sinon from 'sinon';
module('Unit | Mixin | cluster route', function () {
function createClusterRoute(
clusterModel = {},
methods = {
router: { transitionTo: () => {} },
hasKeyData: () => false,
authToken: () => null,
transitionTo: () => {},
}
) {
const ClusterRouteObject = EmberObject.extend(
ClusterRouteMixin,
Object.assign(methods, { clusterModel: () => clusterModel })
);
return ClusterRouteObject.create();
}
test('#targetRouteName init', function (assert) {
let subject = createClusterRoute({ needsInit: true });
subject.routeName = CLUSTER;
assert.strictEqual(subject.targetRouteName(), INIT, 'forwards to INIT when cluster needs init');
subject = createClusterRoute({ needsInit: false, sealed: true });
subject.routeName = CLUSTER;
assert.strictEqual(subject.targetRouteName(), UNSEAL, 'forwards to UNSEAL if sealed and initialized');
subject = createClusterRoute({ needsInit: false });
subject.routeName = CLUSTER;
assert.strictEqual(subject.targetRouteName(), AUTH, 'forwards to AUTH if unsealed and initialized');
subject = createClusterRoute({ dr: { isSecondary: true } });
subject.routeName = CLUSTER;
assert.strictEqual(
subject.targetRouteName(),
DR_REPLICATION_SECONDARY,
'forwards to DR_REPLICATION_SECONDARY if is a dr secondary'
);
});
test('#targetRouteName when #hasDataKey is true', function (assert) {
let subject = createClusterRoute(
{ needsInit: false, sealed: true },
{ hasKeyData: () => true, authToken: () => null }
);
subject.routeName = CLUSTER;
assert.strictEqual(
subject.targetRouteName(),
INIT,
'still land on INIT if there are keys on the controller'
);
subject.routeName = UNSEAL;
assert.strictEqual(subject.targetRouteName(), UNSEAL, 'allowed to proceed to unseal');
subject = createClusterRoute(
{ needsInit: false, sealed: false },
{ hasKeyData: () => true, authToken: () => null }
);
subject.routeName = AUTH;
assert.strictEqual(subject.targetRouteName(), AUTH, 'allowed to proceed to auth');
});
test('#targetRouteName happy path forwards to CLUSTER route', function (assert) {
const subject = createClusterRoute(
{ needsInit: false, sealed: false, dr: { isSecondary: false } },
{ hasKeyData: () => false, authToken: () => 'a token' }
);
subject.routeName = INIT;
assert.strictEqual(subject.targetRouteName(), CLUSTER, 'forwards when inited and navigating to INIT');
subject.routeName = UNSEAL;
assert.strictEqual(subject.targetRouteName(), CLUSTER, 'forwards when unsealed and navigating to UNSEAL');
subject.routeName = AUTH;
assert.strictEqual(
subject.targetRouteName(),
REDIRECT,
'forwards when authenticated and navigating to AUTH'
);
subject.routeName = DR_REPLICATION_SECONDARY;
assert.strictEqual(
subject.targetRouteName(),
CLUSTER,
'forwards when not a DR secondary and navigating to DR_REPLICATION_SECONDARY'
);
});
test('#targetRouteName happy path when not authed forwards to AUTH', function (assert) {
const subject = createClusterRoute(
{ needsInit: false, sealed: false, dr: { isSecondary: false } },
{ hasKeyData: () => false, authToken: () => null }
);
subject.routeName = INIT;
assert.strictEqual(subject.targetRouteName(), AUTH, 'forwards when inited and navigating to INIT');
subject.routeName = UNSEAL;
assert.strictEqual(subject.targetRouteName(), AUTH, 'forwards when unsealed and navigating to UNSEAL');
subject.routeName = AUTH;
assert.strictEqual(
subject.targetRouteName(),
AUTH,
'forwards when non-authenticated and navigating to AUTH'
);
subject.routeName = DR_REPLICATION_SECONDARY;
assert.strictEqual(
subject.targetRouteName(),
AUTH,
'forwards when not a DR secondary and navigating to DR_REPLICATION_SECONDARY'
);
});
test('#transitionToTargetRoute', function (assert) {
const redirectRouteURL = '/vault/secrets-engines/secret/create';
const subject = createClusterRoute({ needsInit: false, sealed: false });
subject.router.currentURL = redirectRouteURL;
const spy = sinon.spy(subject.router, 'transitionTo');
subject.transitionToTargetRoute();
assert.ok(
spy.calledWithExactly(AUTH, { queryParams: { redirect_to: redirectRouteURL } }),
'calls transitionTo with the expected args'
);
spy.restore();
});
test('#transitionToTargetRoute with auth as a target', function (assert) {
const subject = createClusterRoute({ needsInit: false, sealed: false });
const spy = sinon.spy(subject, 'transitionTo');
// in this case it's already transitioning to the AUTH route so we don't need to call transitionTo again
subject.transitionToTargetRoute({ targetName: AUTH });
assert.ok(spy.notCalled, 'transitionTo is not called');
spy.restore();
});
test('#transitionToTargetRoute with auth target, coming from cluster route', function (assert) {
const subject = createClusterRoute({ needsInit: false, sealed: false });
const spy = sinon.spy(subject.router, 'transitionTo');
subject.transitionToTargetRoute({ targetName: CLUSTER_INDEX });
assert.ok(spy.calledWithExactly(AUTH), 'calls transitionTo without redirect_to');
spy.restore();
});
});