From 4c30592ae463198e42b03d8b09c81d56c0b0112a Mon Sep 17 00:00:00 2001 From: thibaud-leclere Date: Wed, 6 May 2026 08:41:02 +0200 Subject: [PATCH] feat: add local auth onboarding --- .../src/auth/local-auth.service.spec.ts | 34 ++++++++ .../src/auth/local-auth.service.ts | 21 ++++- packages/hoppscotch-sh-admin/locales/en.json | 13 +++ packages/hoppscotch-sh-admin/locales/fr.json | 16 +++- .../src/components/onboarding/AuthSetup.vue | 60 ++++++++++++- .../composables/useOnboardingConfigHandler.ts | 84 +++++++++++++++++-- .../hoppscotch-sh-admin/src/helpers/auth.ts | 12 ++- .../src/helpers/backend/rest/authQuery.ts | 7 +- 8 files changed, 233 insertions(+), 14 deletions(-) diff --git a/packages/hoppscotch-backend/src/auth/local-auth.service.spec.ts b/packages/hoppscotch-backend/src/auth/local-auth.service.spec.ts index bba047d3..12f1ac0c 100644 --- a/packages/hoppscotch-backend/src/auth/local-auth.service.spec.ts +++ b/packages/hoppscotch-backend/src/auth/local-auth.service.spec.ts @@ -112,6 +112,40 @@ describe('LocalAuthService', () => { expect(result).toEqualRight(tokens); }); + it('reuses an existing first local admin before onboarding is completed', async () => { + mockPrisma.infraConfig.findUnique.mockResolvedValue({ + id: 'onboarding', + name: InfraConfigEnum.ONBOARDING_COMPLETED, + value: 'false', + lastSyncedEnvFileValue: null, + isEncrypted: false, + createdOn: currentTime, + updatedOn: currentTime, + }); + mockPrisma.user.findFirst.mockResolvedValue({ + ...adminUser, + localCredential: { + id: 'credential-1', + userUid: adminUser.uid, + passwordHash: 'hashed-password', + createdOn: currentTime, + updatedOn: currentTime, + }, + } as any); + jest.mocked(argon2.verify).mockResolvedValue(true); + + const result = await localAuthService.setupFirstAdmin({ + username: 'Admin', + password: 'strong-password', + }); + + expect(mockPrisma.user.create).not.toHaveBeenCalled(); + expect(mockAuthService.generateAuthTokens).toHaveBeenCalledWith( + adminUser.uid, + ); + expect(result).toEqualRight(tokens); + }); + it('refuses setup admin after onboarding is completed', async () => { mockPrisma.infraConfig.findUnique.mockResolvedValue({ id: 'onboarding', diff --git a/packages/hoppscotch-backend/src/auth/local-auth.service.ts b/packages/hoppscotch-backend/src/auth/local-auth.service.ts index 4e639606..715aed68 100644 --- a/packages/hoppscotch-backend/src/auth/local-auth.service.ts +++ b/packages/hoppscotch-backend/src/auth/local-auth.service.ts @@ -140,8 +140,25 @@ export class LocalAuthService { } const username = this.normalizeUsername(dto.username); - const usernameAvailable = await this.ensureUsernameAvailable(username); - if (E.isLeft(usernameAvailable)) return usernameAvailable; + const existingUser = await this.findUserByUsername(username); + + if (existingUser) { + if (existingUser.isAdmin && existingUser.localCredential?.passwordHash) { + const passwordMatches = await argon2.verify( + existingUser.localCredential.passwordHash, + dto.password, + ); + + if (passwordMatches) { + return this.authService.generateAuthTokens(existingUser.uid); + } + } + + return E.left({ + message: AUTH_USERNAME_ALREADY_EXISTS, + statusCode: HttpStatus.CONFLICT, + }); + } const user = await this.createLocalUserRecord( username, diff --git a/packages/hoppscotch-sh-admin/locales/en.json b/packages/hoppscotch-sh-admin/locales/en.json index f3f6b40b..a4d58bfd 100644 --- a/packages/hoppscotch-sh-admin/locales/en.json +++ b/packages/hoppscotch-sh-admin/locales/en.json @@ -220,6 +220,19 @@ "description_accordian": "Select the OAuth providers you want to enable and provide the necessary configurations.", "title": "OAuth" }, + "local": { + "confirm_password": "Confirm password", + "description": "Use usernames and passwords without an external service.", + "description_accordian": "Create the first local administrator account.", + "password": "Password", + "password_min_length": "Password must be at least 12 characters.", + "password_mismatch": "Passwords do not match.", + "setup_failed": "Failed to create the local administrator account.", + "title": "Username and password", + "username": "Username", + "username_format": "Username can only contain letters, numbers, dots, dashes, and underscores.", + "username_min_length": "Username must be at least 3 characters." + }, "onboarding_incomplete": { "description": "You have not completed the onboarding process. Please set up at least one authentication provider to continue.", "title": "Onboarding Incomplete" diff --git a/packages/hoppscotch-sh-admin/locales/fr.json b/packages/hoppscotch-sh-admin/locales/fr.json index 0db3279e..13ebdc2d 100644 --- a/packages/hoppscotch-sh-admin/locales/fr.json +++ b/packages/hoppscotch-sh-admin/locales/fr.json @@ -1,3 +1,17 @@ { - + "onboarding": { + "local": { + "confirm_password": "Confirmer le mot de passe", + "description": "Utiliser des identifiants sans service externe.", + "description_accordian": "Créer le premier compte administrateur local.", + "password": "Mot de passe", + "password_min_length": "Le mot de passe doit contenir au moins 12 caractères.", + "password_mismatch": "Les mots de passe ne correspondent pas.", + "setup_failed": "Impossible de créer le compte administrateur local.", + "title": "Identifiant et mot de passe", + "username": "Identifiant", + "username_format": "L’identifiant ne peut contenir que des lettres, chiffres, points, tirets et underscores.", + "username_min_length": "L’identifiant doit contenir au moins 3 caractères." + } + } } diff --git a/packages/hoppscotch-sh-admin/src/components/onboarding/AuthSetup.vue b/packages/hoppscotch-sh-admin/src/components/onboarding/AuthSetup.vue index 6d8ca185..955a6b7a 100644 --- a/packages/hoppscotch-sh-admin/src/components/onboarding/AuthSetup.vue +++ b/packages/hoppscotch-sh-admin/src/components/onboarding/AuthSetup.vue @@ -67,6 +67,17 @@ class="relative inline-block h-6 w-6 rounded-full border-2 border-primary object-cover object-center hover:z-10 focus:z-10" /> + + + + + + +
+ + + +
+
@@ -163,6 +209,7 @@ import SmtpSetup from './SmtpSetup.vue'; import IconLucideArrowRight from '~icons/lucide/arrow-right'; import IconLucideArrowLeft from '~icons/lucide/arrow-left'; import IconLucideSave from '~icons/lucide/save'; +import IconLucideKeyRound from '~icons/lucide/key-round'; import AuthProviderCard from './AuthProviderCard.vue'; const t = useI18n(); @@ -176,7 +223,7 @@ const emit = defineEmits<{ ): void; }>(); -type SelectedOption = 'OAUTH' | 'SMTP'; +type SelectedOption = 'OAUTH' | 'SMTP' | 'LOCAL'; const authConfigStep = ref(1); const selectedOptions = ref([]); @@ -184,6 +231,7 @@ const selectedOptions = ref([]); const { currentConfigs, enabledConfigs, + localAdminCredentials, isProvidersLoading, submittingConfigs, onBoardingSummary, @@ -240,6 +288,7 @@ const isSmtpEnabled = computed( enabledConfigs.value.includes('MAILER') || enabledConfigs.value.includes('EMAIL'), ); +const isLocalEnabled = computed(() => enabledConfigs.value.includes('LOCAL')); const updateOAuthEnabled = () => { isOAuthEnabled.value = OAuthProviders.some((provider) => @@ -263,6 +312,10 @@ const toggleSelectedOption = (option: SelectedOption) => { toggleConfig('EMAIL'); toggleSmtpConfig(); } + } else if (option === 'LOCAL') { + if (willBeSelected !== isLocalEnabled.value) { + toggleConfig('LOCAL'); + } } else if (willBeSelected !== isOAuthEnabled.value) { toggleConfig(option); } @@ -277,7 +330,10 @@ const proceedToConfig = () => { }; const submitConfigs = async () => { - const res = await addOnBoardingConfigs(); + const res = await addOnBoardingConfigs({ + setupLocalAdmin: + props.isFirstTimeSetup === true && enabledConfigs.value.includes('LOCAL'), + }); if (res?.token) { emit('complete-onboarding', { submittingConfigs: submittingConfigs.value, diff --git a/packages/hoppscotch-sh-admin/src/composables/useOnboardingConfigHandler.ts b/packages/hoppscotch-sh-admin/src/composables/useOnboardingConfigHandler.ts index 542c6ef4..b9e59255 100644 --- a/packages/hoppscotch-sh-admin/src/composables/useOnboardingConfigHandler.ts +++ b/packages/hoppscotch-sh-admin/src/composables/useOnboardingConfigHandler.ts @@ -7,7 +7,18 @@ import { getLocalConfig, setLocalConfig } from '~/helpers/localpersistence'; import { makeReadableKey } from '~/helpers/utils/readableKey'; export type OAuthProvider = 'GOOGLE' | 'GITHUB' | 'MICROSOFT'; -export type EnabledConfig = OAuthProvider | 'OAUTH' | 'MAILER' | 'EMAIL'; +export type EnabledConfig = + | OAuthProvider + | 'OAUTH' + | 'MAILER' + | 'EMAIL' + | 'LOCAL'; + +export type LocalAdminCredentials = { + username: string; + password: string; + confirmPassword: string; +}; // common OAuth keys used across providers type OAuthKeys = 'CLIENT_ID' | 'CLIENT_SECRET' | 'CALLBACK_URL' | 'SCOPE'; @@ -124,6 +135,11 @@ export function useOnboardingConfigHandler() { const enabledConfigs = ref([]); const isProvidersLoading = ref(false); const submittingConfigs = ref(false); + const localAdminCredentials = ref({ + username: '', + password: '', + confirmPassword: '', + }); const onBoardingSummary = ref({ type: 'success', @@ -334,6 +350,36 @@ export function useOnboardingConfigHandler() { ); }; + const validateLocalAdminCredentials = () => { + if (!enabledConfigs.value.includes('LOCAL')) return true; + + const username = localAdminCredentials.value.username.trim(); + const password = localAdminCredentials.value.password; + const confirmPassword = localAdminCredentials.value.confirmPassword; + + if (username.length < 3) { + toast.error(t('onboarding.local.username_min_length')); + return false; + } + + if (!/^[a-zA-Z0-9_.-]+$/.test(username)) { + toast.error(t('onboarding.local.username_format')); + return false; + } + + if (password.length < 12) { + toast.error(t('onboarding.local.password_min_length')); + return false; + } + + if (password !== confirmPassword) { + toast.error(t('onboarding.local.password_mismatch')); + return false; + } + + return true; + }; + /** * Adds the onboarding configs to the backend. * It validates the configs, prepares the payload, @@ -341,7 +387,9 @@ export function useOnboardingConfigHandler() { * We set the token in localStorage for re-fetching configs later. * @returns The token for re-fetching configs or undefined if failed */ - const addOnBoardingConfigs = async () => { + const addOnBoardingConfigs = async (options?: { + setupLocalAdmin?: boolean; + }) => { submittingConfigs.value = true; const payload = { ...currentConfigs.value.oAuthProviders.GOOGLE, @@ -350,25 +398,46 @@ export function useOnboardingConfigHandler() { ...currentConfigs.value.mailerConfigs, }; + const filteredEnabledConfigs = enabledConfigs.value.filter( + (config) => config !== 'OAUTH' && config !== 'MAILER', + ); + const hasLocalAuth = filteredEnabledConfigs.includes('LOCAL'); const validated = validateConfigs(payload); - if (!validated || Object.keys(validated).length === 0) { + if (!validated) { + submittingConfigs.value = false; + return; + } + + if (Object.keys(validated).length === 0 && !hasLocalAuth) { toast.error(t('onboarding.add_atleast_one_auth_provider')); submittingConfigs.value = false; return; } - const filteredEnabledConfigs = enabledConfigs.value.filter( - (config) => config !== 'OAUTH' && config !== 'MAILER', - ); + if (options?.setupLocalAdmin && !validateLocalAdminCredentials()) { + submittingConfigs.value = false; + return; + } const configWithAuth = { - ...validated, + ...(validated ?? {}), [InfraConfigEnum.ViteAllowedAuthProviders]: filteredEnabledConfigs.join(','), }; try { + if (hasLocalAuth && options?.setupLocalAdmin) { + const isLocalAdminCreated = await auth.setupLocalAdmin( + localAdminCredentials.value.username.trim(), + localAdminCredentials.value.password, + ); + + if (!isLocalAdminCreated) { + throw new Error(t('onboarding.local.setup_failed')); + } + } + const res = await auth.addOnBoardingConfigs(configWithAuth); if (res?.token) { setLocalConfig('access_token', res.token); @@ -453,6 +522,7 @@ export function useOnboardingConfigHandler() { return { currentConfigs, enabledConfigs, + localAdminCredentials, isProvidersLoading, onBoardingSummary, submittingConfigs, diff --git a/packages/hoppscotch-sh-admin/src/helpers/auth.ts b/packages/hoppscotch-sh-admin/src/helpers/auth.ts index 48e4291a..098d72f1 100644 --- a/packages/hoppscotch-sh-admin/src/helpers/auth.ts +++ b/packages/hoppscotch-sh-admin/src/helpers/auth.ts @@ -212,6 +212,16 @@ export const auth = { return authQuery.signInWithEmailLink(token, deviceIdentifier); }, + setupLocalAdmin: async (username: string, password: string) => { + try { + await authQuery.setupLocalAdmin(username, password); + return true; + } catch (err) { + console.error(err); + return false; + } + }, + performAuthRefresh: async () => { const isRefreshSuccess = await refreshToken(); @@ -233,7 +243,7 @@ export const auth = { if (!deviceIdentifier) { throw new Error( - 'Device Identifier not found, you can only signin from the browser you generated the magic link' + 'Device Identifier not found, you can only signin from the browser you generated the magic link', ); } diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/rest/authQuery.ts b/packages/hoppscotch-sh-admin/src/helpers/backend/rest/authQuery.ts index 40f573e4..00d8af07 100644 --- a/packages/hoppscotch-sh-admin/src/helpers/backend/rest/authQuery.ts +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/rest/authQuery.ts @@ -23,12 +23,17 @@ export default { }), signInWithEmailLink: ( token: string | null, - deviceIdentifier: string | null + deviceIdentifier: string | null, ) => restApi.post('/auth/verify', { token, deviceIdentifier, }), + setupLocalAdmin: (username: string, password: string) => + restApi.post('/auth/local/setup-admin', { + username, + password, + }), getFirstTimeInfraSetupStatus: () => restApi.get('/site/setup'), updateFirstTimeInfraSetupStatus: () => restApi.put('/site/setup'), addOnBoardingConfigs: (config: Record) =>