diff --git a/frontend/src/component/admin/auth/OidcAuth/OidcAuth.tsx b/frontend/src/component/admin/auth/OidcAuth/OidcAuth.tsx
index 1e8bb0d494..7ed804466d 100644
--- a/frontend/src/component/admin/auth/OidcAuth/OidcAuth.tsx
+++ b/frontend/src/component/admin/auth/OidcAuth/OidcAuth.tsx
@@ -94,7 +94,7 @@ export const OidcAuth = () => {
return (
-
+
Please read the{' '}
diff --git a/frontend/src/component/admin/auth/PasswordAuth/PasswordAuth.tsx b/frontend/src/component/admin/auth/PasswordAuth/PasswordAuth.tsx
index 2aef86c5cd..07361bdd7c 100644
--- a/frontend/src/component/admin/auth/PasswordAuth/PasswordAuth.tsx
+++ b/frontend/src/component/admin/auth/PasswordAuth/PasswordAuth.tsx
@@ -10,15 +10,22 @@ import useAuthSettingsApi, {
} from 'hooks/api/actions/useAuthSettingsApi/useAuthSettingsApi';
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
+import { useAdminCount } from 'hooks/api/getters/useAdminCount/useAdminCount';
+import { Link } from 'react-router-dom';
+import { useApiTokens } from 'hooks/api/getters/useApiTokens/useApiTokens';
+import { PasswordAuthDialog } from './PasswordAuthDialog';
export const PasswordAuth = () => {
const { setToastData, setToastApiError } = useToast();
- const { config } = useAuthSettings('simple');
+ const { config, refetch } = useAuthSettings('simple');
const [disablePasswordAuth, setDisablePasswordAuth] =
useState(false);
const { updateSettings, errors, loading } =
useAuthSettingsApi('simple');
const { hasAccess } = useContext(AccessContext);
+ const [confirmationOpen, setConfirmationOpen] = useState(false);
+ const { data: adminCount } = useAdminCount();
+ const { tokens } = useApiTokens();
useEffect(() => {
setDisablePasswordAuth(!!config.disabled);
@@ -39,11 +46,20 @@ export const PasswordAuth = () => {
const onSubmit = async (event: React.SyntheticEvent) => {
event.preventDefault();
+ if (!config.disabled && disablePasswordAuth) {
+ setConfirmationOpen(true);
+ } else {
+ onConfirm();
+ }
+ };
+
+ const onConfirm = async () => {
try {
const settings: ISimpleAuthSettings = {
disabled: disablePasswordAuth,
};
await updateSettings(settings);
+ refetch();
setToastData({
title: 'Successfully saved',
text: 'Password authentication settings stored.',
@@ -56,9 +72,30 @@ export const PasswordAuth = () => {
setDisablePasswordAuth(config.disabled);
}
};
+
return (
+ {
+ setConfirmationOpen(false);
+ onConfirm();
+ }}
+ adminCount={adminCount!}
+ tokens={tokens}
+ />
);
diff --git a/frontend/src/component/admin/auth/PasswordAuth/PasswordAuthDialog.tsx b/frontend/src/component/admin/auth/PasswordAuth/PasswordAuthDialog.tsx
new file mode 100644
index 0000000000..99544c4526
--- /dev/null
+++ b/frontend/src/component/admin/auth/PasswordAuth/PasswordAuthDialog.tsx
@@ -0,0 +1,53 @@
+import { Alert, Typography } from '@mui/material';
+import { Dialogue } from 'component/common/Dialogue/Dialogue';
+import { IAdminCount } from 'hooks/api/getters/useAdminCount/useAdminCount';
+import { IApiToken } from 'hooks/api/getters/useApiTokens/useApiTokens';
+
+interface IPasswordAuthDialogProps {
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ onClick: () => void;
+ adminCount: IAdminCount;
+ tokens: IApiToken[];
+}
+
+export const PasswordAuthDialog = ({
+ open,
+ setOpen,
+ onClick,
+ adminCount,
+ tokens,
+}: IPasswordAuthDialogProps) => (
+ {
+ setOpen(false);
+ }}
+ onClick={onClick}
+ title="Disable password based login?"
+ primaryButtonText="Disable password based login"
+ secondaryButtonText="Cancel"
+ >
+
+ Warning! Disabling password based login may lock
+ you out of the system permanently if you do not have any alternative
+ admin credentials (such as an admin SSO account or admin API token)
+ secured beforehand.
+
+
+ Password based administrators: {' '}
+ {adminCount?.password}
+
+ Other administrators: {adminCount?.noPassword}
+
+ Admin service accounts: {adminCount?.service}
+
+ Admin API tokens: {' '}
+ {tokens.filter(({ type }) => type === 'admin').length}
+
+
+ You are about to disable password based login. Are you sure you want
+ to proceed?
+
+
+);
diff --git a/frontend/src/component/admin/auth/SamlAuth/SamlAuth.tsx b/frontend/src/component/admin/auth/SamlAuth/SamlAuth.tsx
index dcaaa5c6e5..a7a4a8a58d 100644
--- a/frontend/src/component/admin/auth/SamlAuth/SamlAuth.tsx
+++ b/frontend/src/component/admin/auth/SamlAuth/SamlAuth.tsx
@@ -85,7 +85,7 @@ export const SamlAuth = () => {
return (
-
+
Please read the{' '}
diff --git a/frontend/src/hooks/api/getters/useAdminCount/useAdminCount.ts b/frontend/src/hooks/api/getters/useAdminCount/useAdminCount.ts
new file mode 100644
index 0000000000..75bb1f9d75
--- /dev/null
+++ b/frontend/src/hooks/api/getters/useAdminCount/useAdminCount.ts
@@ -0,0 +1,29 @@
+import useSWR from 'swr';
+import { formatApiPath } from 'utils/formatPath';
+import handleErrorResponses from '../httpErrorResponseHandler';
+
+export interface IAdminCount {
+ password: number;
+ noPassword: number;
+ service: number;
+}
+
+export const useAdminCount = () => {
+ const { data, error, mutate } = useSWR(
+ formatApiPath(`api/admin/user-admin/admin-count`),
+ fetcher
+ );
+
+ return {
+ data,
+ loading: !error && !data,
+ refetch: () => mutate(),
+ error,
+ };
+};
+
+const fetcher = (path: string) => {
+ return fetch(path)
+ .then(handleErrorResponses('Admin count'))
+ .then(res => res.json());
+};
diff --git a/src/lib/db/account-store.ts b/src/lib/db/account-store.ts
index d8de4dba18..658a459f81 100644
--- a/src/lib/db/account-store.ts
+++ b/src/lib/db/account-store.ts
@@ -3,6 +3,7 @@ import User from '../types/user';
import NotFoundError from '../error/notfound-error';
import { IUserLookup } from '../types/stores/user-store';
+import { IAdminCount } from '../types/stores/account-store';
import { IAccountStore } from '../types';
import { Db } from './db';
@@ -169,4 +170,31 @@ export class AccountStore implements IAccountStore {
this.logger.error('Could not update lastSeen, error: ', err);
}
}
+
+ async getAdminCount(): Promise {
+ const adminCount = await this.activeAccounts()
+ .join('role_user as ru', 'users.id', 'ru.user_id')
+ .where(
+ 'ru.role_id',
+ '=',
+ this.db.raw('(SELECT id FROM roles WHERE name = ?)', ['Admin']),
+ )
+ .select(
+ this.db.raw(
+ 'COUNT(CASE WHEN users.password_hash IS NOT NULL AND users.is_service = false THEN 1 END)::integer AS password',
+ ),
+ this.db.raw(
+ 'COUNT(CASE WHEN users.password_hash IS NULL AND users.is_service = false THEN 1 END)::integer AS no_password',
+ ),
+ this.db.raw(
+ 'COUNT(CASE WHEN users.is_service = true THEN 1 END)::integer AS service',
+ ),
+ );
+
+ return {
+ password: adminCount[0].password,
+ noPassword: adminCount[0].no_password,
+ service: adminCount[0].service,
+ };
+ }
}
diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts
index ad87046618..b919c91c56 100644
--- a/src/lib/openapi/index.ts
+++ b/src/lib/openapi/index.ts
@@ -5,6 +5,7 @@ import {
addonSchema,
addonsSchema,
addonTypeSchema,
+ adminCountSchema,
adminFeaturesQuerySchema,
apiTokenSchema,
apiTokensSchema,
@@ -182,6 +183,7 @@ interface OpenAPIV3DocumentWithServers extends OpenAPIV3.Document {
// All schemas in `openapi/spec` should be listed here.
export const schemas: UnleashSchemas = {
+ adminCountSchema,
adminFeaturesQuerySchema,
addonParameterSchema,
addonSchema,
diff --git a/src/lib/openapi/spec/admin-count-schema.ts b/src/lib/openapi/spec/admin-count-schema.ts
new file mode 100644
index 0000000000..7861f37281
--- /dev/null
+++ b/src/lib/openapi/spec/admin-count-schema.ts
@@ -0,0 +1,28 @@
+import { FromSchema } from 'json-schema-to-ts';
+
+export const adminCountSchema = {
+ $id: '#/components/schemas/adminCountSchema',
+ type: 'object',
+ additionalProperties: false,
+ description: 'Contains total admin counts for an Unleash instance.',
+ required: ['password', 'noPassword', 'service'],
+ properties: {
+ password: {
+ type: 'number',
+ description: 'Total number of admins that have a password set.',
+ },
+ noPassword: {
+ type: 'number',
+ description:
+ 'Total number of admins that do not have a password set. May be SSO, but may also be users that did not set a password yet.',
+ },
+ service: {
+ type: 'number',
+ description:
+ 'Total number of service accounts that have the admin root role.',
+ },
+ },
+ components: {},
+} as const;
+
+export type AdminCountSchema = FromSchema;
diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts
index 3cb523723f..e5793bcee8 100644
--- a/src/lib/openapi/spec/index.ts
+++ b/src/lib/openapi/spec/index.ts
@@ -136,3 +136,4 @@ export * from './upsert-segment-schema';
export * from './batch-features-schema';
export * from './token-string-list-schema';
export * from './bulk-toggle-features-schema';
+export * from './admin-count-schema';
diff --git a/src/lib/routes/admin-api/user-admin.ts b/src/lib/routes/admin-api/user-admin.ts
index 611041c571..2a8dec5c33 100644
--- a/src/lib/routes/admin-api/user-admin.ts
+++ b/src/lib/routes/admin-api/user-admin.ts
@@ -41,6 +41,10 @@ import { IGroup } from '../../types/group';
import { IFlagResolver } from '../../types/experimental';
import rateLimit from 'express-rate-limit';
import { minutesToMilliseconds } from 'date-fns';
+import {
+ AdminCountSchema,
+ adminCountSchema,
+} from '../../openapi/spec/admin-count-schema';
export default class UserAdminController extends Controller {
private flagResolver: IFlagResolver;
@@ -192,6 +196,22 @@ export default class UserAdminController extends Controller {
],
});
+ this.route({
+ method: 'get',
+ path: '/admin-count',
+ handler: this.getAdminCount,
+ permission: ADMIN,
+ middleware: [
+ openApiService.validPath({
+ tags: ['Users'],
+ operationId: 'getAdminCount',
+ responses: {
+ 200: createResponseSchema('adminCountSchema'),
+ },
+ }),
+ ],
+ });
+
this.route({
method: 'post',
path: '',
@@ -498,4 +518,19 @@ export default class UserAdminController extends Controller {
await this.userService.changePassword(+id, password);
res.status(200).send();
}
+
+ async getAdminCount(
+ req: Request,
+ res: Response,
+ ): Promise {
+ console.log('user-admin controller');
+ const adminCount = await this.accountService.getAdminCount();
+
+ this.openApiService.respondWithValidation(
+ 200,
+ res,
+ adminCountSchema.$id,
+ adminCount,
+ );
+ }
}
diff --git a/src/lib/services/account-service.ts b/src/lib/services/account-service.ts
index 8204493f99..9dc145c869 100644
--- a/src/lib/services/account-service.ts
+++ b/src/lib/services/account-service.ts
@@ -5,6 +5,7 @@ import { IAccountStore, IUnleashStores } from '../types/stores';
import { minutesToMilliseconds } from 'date-fns';
import { AccessService } from './access-service';
import { RoleName } from '../types/model';
+import { IAdminCount } from 'lib/types/stores/account-store';
interface IUserWithRole extends IUser {
rootRole: number;
@@ -52,6 +53,10 @@ export class AccountService {
return this.store.getAccountByPersonalAccessToken(secret);
}
+ async getAdminCount(): Promise {
+ return this.store.getAdminCount();
+ }
+
async updateLastSeen(): Promise {
if (this.lastSeenSecrets.size > 0) {
const toStore = [...this.lastSeenSecrets];
diff --git a/src/lib/types/stores/account-store.ts b/src/lib/types/stores/account-store.ts
index 4501109915..cfb754efac 100644
--- a/src/lib/types/stores/account-store.ts
+++ b/src/lib/types/stores/account-store.ts
@@ -7,6 +7,12 @@ export interface IUserLookup {
email?: string;
}
+export interface IAdminCount {
+ password: number;
+ noPassword: number;
+ service: number;
+}
+
export interface IAccountStore extends Store {
hasAccount(idQuery: IUserLookup): Promise;
search(query: string): Promise;
@@ -15,4 +21,5 @@ export interface IAccountStore extends Store {
count(): Promise;
getAccountByPersonalAccessToken(secret: string): Promise;
markSeenAt(secrets: string[]): Promise;
+ getAdminCount(): Promise;
}
diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap
index 081499c35a..1c53163698 100644
--- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap
+++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap
@@ -608,6 +608,30 @@ The provider you choose for your addon dictates what properties the \`parameters
],
"type": "object",
},
+ "adminCountSchema": {
+ "additionalProperties": false,
+ "description": "Contains total admin counts for an Unleash instance.",
+ "properties": {
+ "noPassword": {
+ "description": "Total number of admins that do not have a password set. May be SSO, but may also be users that did not set a password yet.",
+ "type": "number",
+ },
+ "password": {
+ "description": "Total number of admins that have a password set.",
+ "type": "number",
+ },
+ "service": {
+ "description": "Total number of service accounts that have the admin root role.",
+ "type": "number",
+ },
+ },
+ "required": [
+ "password",
+ "noPassword",
+ "service",
+ ],
+ "type": "object",
+ },
"adminFeaturesQuerySchema": {
"additionalProperties": false,
"properties": {
@@ -12522,6 +12546,26 @@ If the provided project does not exist, the list of events will be empty.",
],
},
},
+ "/api/admin/user-admin/admin-count": {
+ "get": {
+ "operationId": "getAdminCount",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/adminCountSchema",
+ },
+ },
+ },
+ "description": "adminCountSchema",
+ },
+ },
+ "tags": [
+ "Users",
+ ],
+ },
+ },
"/api/admin/user-admin/reset-password": {
"post": {
"operationId": "resetUserPassword",
diff --git a/src/test/fixtures/fake-account-store.ts b/src/test/fixtures/fake-account-store.ts
index 284a797c0c..3b0c99471b 100644
--- a/src/test/fixtures/fake-account-store.ts
+++ b/src/test/fixtures/fake-account-store.ts
@@ -3,6 +3,7 @@ import {
// ICreateUser,
IUserLookup,
IAccountStore,
+ IAdminCount,
} from '../../lib/types/stores/account-store';
export class FakeAccountStore implements IAccountStore {
@@ -93,4 +94,8 @@ export class FakeAccountStore implements IAccountStore {
async markSeenAt(secrets: string[]): Promise {
throw new Error('Not implemented');
}
+
+ async getAdminCount(): Promise {
+ throw new Error('Not implemented');
+ }
}