feat: add local auth onboarding
This commit is contained in:
parent
60cf156230
commit
4c30592ae4
8 changed files with 233 additions and 14 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>) =>
|
||||
|
|
|
|||
Loading…
Reference in a new issue