feat: add local auth onboarding

This commit is contained in:
thibaud-leclere 2026-05-06 08:41:02 +02:00
parent 60cf156230
commit 4c30592ae4
8 changed files with 233 additions and 14 deletions

View file

@ -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',

View file

@ -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(<RESTError>{
message: AUTH_USERNAME_ALREADY_EXISTS,
statusCode: HttpStatus.CONFLICT,
});
}
const user = await this.createLocalUserRecord(
username,

View file

@ -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"

View file

@ -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": "Lidentifiant ne peut contenir que des lettres, chiffres, points, tirets et underscores.",
"username_min_length": "Lidentifiant doit contenir au moins 3 caractères."
}
}
}

View file

@ -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"
/>
</AuthProviderCard>
<AuthProviderCard
title="onboarding.local.title"
description="onboarding.local.description"
:selected="isSelected('LOCAL') && isLocalEnabled"
@click="toggleSelectedOption('LOCAL')"
>
<IconLucideKeyRound
class="relative inline-block h-6 w-6 rounded-full border-2 border-primary bg-primaryDark p-1 text-accent hover:z-10 focus:z-10"
/>
</AuthProviderCard>
</div>
<HoppButtonPrimary
@ -115,6 +126,41 @@
v-model:enabledConfigs="enabledConfigs"
/>
</UiAccordion>
<UiAccordion
v-if="isSelected('LOCAL') && isFirstTimeSetup"
:initial-open="isLocalEnabled"
title="onboarding.local.title"
description="onboarding.local.description_accordian"
@toggle="toggleConfig('LOCAL')"
class="bg-primary rounded-lg border-primaryDark shadow p-4 border flex flex-col"
>
<div class="flex flex-col space-y-2 py-4">
<HoppSmartInput
v-model="localAdminCredentials.username"
:label="t('onboarding.local.username')"
input-styles="floating-input"
:autofocus="false"
class="!my-2 !bg-primaryLight flex-1"
/>
<HoppSmartInput
v-model="localAdminCredentials.password"
:label="t('onboarding.local.password')"
input-styles="floating-input"
:autofocus="false"
type="password"
class="!my-2 !bg-primaryLight flex-1"
/>
<HoppSmartInput
v-model="localAdminCredentials.confirmPassword"
:label="t('onboarding.local.confirm_password')"
input-styles="floating-input"
:autofocus="false"
type="password"
class="!my-2 !bg-primaryLight flex-1"
/>
</div>
</UiAccordion>
</div>
<div class="flex items-center space-x-4 mt-6">
@ -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<SelectedOption[]>([]);
@ -184,6 +231,7 @@ const selectedOptions = ref<SelectedOption[]>([]);
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,

View file

@ -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<EnabledConfig[]>([]);
const isProvidersLoading = ref(false);
const submittingConfigs = ref(false);
const localAdminCredentials = ref<LocalAdminCredentials>({
username: '',
password: '',
confirmPassword: '',
});
const onBoardingSummary = ref<OnBoardingSummary>({
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,

View file

@ -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',
);
}

View file

@ -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<string, any>) =>