feat: add local app login

This commit is contained in:
thibaud-leclere 2026-05-06 08:47:37 +02:00
parent b17375eb16
commit 892f69817b
7 changed files with 119 additions and 7 deletions

View file

@ -192,6 +192,7 @@
"continue_with_github_enterprise": "Continue with GitHub Enterprise", "continue_with_github_enterprise": "Continue with GitHub Enterprise",
"continue_with_google": "Continue with Google", "continue_with_google": "Continue with Google",
"continue_with_microsoft": "Continue with Microsoft", "continue_with_microsoft": "Continue with Microsoft",
"continue_with_username": "Continue with username",
"email": "Email", "email": "Email",
"logged_out": "Logged out", "logged_out": "Logged out",
"login": "Login", "login": "Login",
@ -200,7 +201,9 @@
"logout": "Logout", "logout": "Logout",
"re_enter_email": "Re-enter email", "re_enter_email": "Re-enter email",
"send_magic_link": "Send a magic link", "send_magic_link": "Send a magic link",
"password": "Password",
"sync": "Sync", "sync": "Sync",
"username": "Username",
"we_sent_magic_link": "We sent you a magic link!", "we_sent_magic_link": "We sent you a magic link!",
"we_sent_magic_link_description": "Check your inbox - we sent an email to {email}. It contains a magic link that will log you in." "we_sent_magic_link_description": "Check your inbox - we sent an email to {email}. It contains a magic link that will log you in."
}, },

View file

@ -60,6 +60,32 @@
:label="`${t('auth.send_magic_link')}`" :label="`${t('auth.send_magic_link')}`"
/> />
</form> </form>
<form
v-if="mode === 'local'"
class="flex flex-col space-y-2"
@submit.prevent="signInWithUsernamePassword"
>
<HoppSmartInput
v-model="form.username"
type="text"
placeholder=" "
:label="t('auth.username')"
input-styles="floating-input"
/>
<HoppSmartInput
v-model="form.password"
type="password"
placeholder=" "
:label="t('auth.password')"
input-styles="floating-input"
/>
<HoppButtonPrimary
:loading="signingInWithLocal"
type="submit"
:label="`${t('auth.login')}`"
/>
</form>
<div <div
v-if="!allowedAuthProviders?.length && !additionalLoginItems.length" v-if="!allowedAuthProviders?.length && !additionalLoginItems.length"
@ -116,7 +142,7 @@
label="Privacy Policy" label="Privacy Policy"
/> />
</div> </div>
<div v-if="mode === 'email'"> <div v-if="mode === 'email' || mode === 'local'">
<HoppButtonSecondary <HoppButtonSecondary
:label="t('auth.all_sign_in_options')" :label="t('auth.all_sign_in_options')"
:icon="IconArrowLeft" :icon="IconArrowLeft"
@ -145,7 +171,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Ref, onMounted, ref } from "vue" import { Component, Ref, onMounted, ref } from "vue"
import { useI18n } from "@composables/i18n" import { useI18n } from "@composables/i18n"
import { useStreamSubscriber } from "@composables/stream" import { useStreamSubscriber } from "@composables/stream"
@ -159,6 +185,7 @@ import IconGoogle from "~icons/auth/google"
import IconMicrosoft from "~icons/auth/microsoft" import IconMicrosoft from "~icons/auth/microsoft"
import IconArrowLeft from "~icons/lucide/arrow-left" import IconArrowLeft from "~icons/lucide/arrow-left"
import IconFileText from "~icons/lucide/file-text" import IconFileText from "~icons/lucide/file-text"
import IconKeyRound from "~icons/lucide/key-round"
import { useService } from "dioc/vue" import { useService } from "dioc/vue"
import { LoginItemDef } from "~/platform/auth" import { LoginItemDef } from "~/platform/auth"
@ -178,6 +205,8 @@ const persistenceService = useService(PersistenceService)
const form = { const form = {
email: "", email: "",
username: "",
password: "",
} }
const isLoadingAllowedAuthProviders = ref(true) const isLoadingAllowedAuthProviders = ref(true)
@ -186,6 +215,7 @@ const signingInWithGoogle = ref(false)
const signingInWithGitHub = ref(false) const signingInWithGitHub = ref(false)
const signingInWithMicrosoft = ref(false) const signingInWithMicrosoft = ref(false)
const signingInWithEmail = ref(false) const signingInWithEmail = ref(false)
const signingInWithLocal = ref(false)
const mode = ref("sign-in") const mode = ref("sign-in")
const tosLink = import.meta.env.VITE_APP_TOS_LINK const tosLink = import.meta.env.VITE_APP_TOS_LINK
@ -193,7 +223,7 @@ const privacyPolicyLink = import.meta.env.VITE_APP_PRIVACY_POLICY_LINK
type AuthProviderItem = { type AuthProviderItem = {
id: string id: string
icon: typeof IconGithub icon: Component
label: string label: string
action: (...args: any[]) => any action: (...args: any[]) => any
isLoading: Ref<boolean> isLoading: Ref<boolean>
@ -356,6 +386,24 @@ const signInWithEmail = async () => {
}) })
} }
const signInWithUsernamePassword = async () => {
signingInWithLocal.value = true
await platform.auth
.signInWithUsernamePassword(form.username, form.password)
.then(() => {
showLoginSuccess()
hideModal()
})
.catch((e) => {
console.error(e)
toast.error(`${t("error.something_went_wrong")}`)
})
.finally(() => {
signingInWithLocal.value = false
})
}
const authProvidersAvailable: AuthProviderItem[] = [ const authProvidersAvailable: AuthProviderItem[] = [
{ {
id: "GITHUB", id: "GITHUB",
@ -395,6 +443,15 @@ const authProvidersAvailable: AuthProviderItem[] = [
}, },
isLoading: signingInWithEmail, isLoading: signingInWithEmail,
}, },
{
id: "LOCAL",
icon: IconKeyRound,
label: t("auth.continue_with_username"),
action: () => {
mode.value = "local"
},
isLoading: signingInWithLocal,
},
] ]
const hideModal = () => { const hideModal = () => {

View file

@ -186,6 +186,17 @@ export type AuthPlatformDef = {
*/ */
signInWithEmail: (email: string) => Promise<void> signInWithEmail: (email: string) => Promise<void>
/**
* Called to sign in user with username and password.
* @param username The username that is logging in.
* @param password The password for the local account.
* @returns An empty promise that is resolved when the operation is complete
*/
signInWithUsernamePassword: (
username: string,
password: string
) => Promise<void>
/** /**
* Check whether a given link is a valid sign in with email, magic link response url. * Check whether a given link is a valid sign in with email, magic link response url.
* (i.e, a URL that COULD be from a magic link email) * (i.e, a URL that COULD be from a magic link email)
@ -261,7 +272,7 @@ export type AuthPlatformDef = {
) => Promise<E.Either<GQLError<string>, undefined>> ) => Promise<E.Either<GQLError<string>, undefined>>
/** /**
* Returns the list of allowed auth providers for the platform ( the currently supported ones are GOOGLE, GITHUB, EMAIL, MICROSOFT, SAML ) * Returns the list of allowed auth providers for the platform ( the currently supported ones are GOOGLE, GITHUB, EMAIL, MICROSOFT, SAML, LOCAL )
*/ */
getAllowedAuthProviders: () => Promise<E.Either<string, string[]>> getAllowedAuthProviders: () => Promise<E.Either<string, string[]>>

View file

@ -9,7 +9,7 @@ import {
} from "@app/api/generated/graphql" } from "@app/api/generated/graphql"
const expectedAllowedProvidersSchema = z.object({ const expectedAllowedProvidersSchema = z.object({
// currently supported values are "GOOGLE", "GITHUB", "EMAIL", "MICROSOFT", "SAML" // currently supported values are "GOOGLE", "GITHUB", "EMAIL", "MICROSOFT", "SAML", "LOCAL"
// keeping it as string to avoid backend accidentally breaking frontend when adding new providers // keeping it as string to avoid backend accidentally breaking frontend when adding new providers
providers: z.array(z.string()), providers: z.array(z.string()),
}) })

View file

@ -429,6 +429,25 @@ export const def: AuthPlatformDef = {
await sendMagicLink(email) await sendMagicLink(email)
}, },
async signInWithUsernamePassword(username: string, password: string) {
const { response } = interceptorService.execute({
id: Date.now(),
url: `${import.meta.env.VITE_BACKEND_API_URL}/auth/local/signin`,
version: "HTTP/1.1",
method: "POST",
headers: {
"Content-Type": "application/json",
},
content: content.json({ username, password }),
})
const res = await response
if (E.isLeft(res)) throw new Error("Failed to sign in")
await setAuthCookies(res.right.headers)
await setInitialUser()
},
async verifyEmailAddress() { async verifyEmailAddress() {
return return
}, },

View file

@ -9,7 +9,7 @@ import {
} from "@app/api/generated/graphql" } from "@app/api/generated/graphql"
const expectedAllowedProvidersSchema = z.object({ const expectedAllowedProvidersSchema = z.object({
// currently supported values are "GOOGLE", "GITHUB", "EMAIL", "MICROSOFT", "SAML" // currently supported values are "GOOGLE", "GITHUB", "EMAIL", "MICROSOFT", "SAML", "LOCAL"
// keeping it as string to avoid backend accidentally breaking frontend when adding new providers // keeping it as string to avoid backend accidentally breaking frontend when adding new providers
providers: z.array(z.string()), providers: z.array(z.string()),
}) })
@ -35,6 +35,19 @@ export const getAllowedAuthProviders = async () => {
} }
} }
export const signInLocal = async (username: string, password: string) => {
await axios.post(
`${import.meta.env.VITE_BACKEND_API_URL}/auth/local/signin`,
{
username,
password,
},
{
withCredentials: true,
}
)
}
export const updateUserDisplayName = (updatedDisplayName: string) => export const updateUserDisplayName = (updatedDisplayName: string) =>
runMutation< runMutation<
UpdateUserDisplayNameMutation, UpdateUserDisplayNameMutation,

View file

@ -11,7 +11,11 @@ import {
} from "@hoppscotch/common/platform/auth" } from "@hoppscotch/common/platform/auth"
import { PersistenceService } from "@hoppscotch/common/services/persistence" import { PersistenceService } from "@hoppscotch/common/services/persistence"
import { getAllowedAuthProviders, updateUserDisplayName } from "./api" import {
getAllowedAuthProviders,
signInLocal,
updateUserDisplayName,
} from "./api"
export const authEvents$ = new Subject<AuthEvent | { event: "token_refresh" }>() export const authEvents$ = new Subject<AuthEvent | { event: "token_refresh" }>()
const currentUser$ = new BehaviorSubject<HoppUser | null>(null) const currentUser$ = new BehaviorSubject<HoppUser | null>(null)
@ -276,6 +280,11 @@ export const def: AuthPlatformDef = {
await sendMagicLink(email) await sendMagicLink(email)
}, },
async signInWithUsernamePassword(username: string, password: string) {
await signInLocal(username, password)
await setInitialUser()
},
isSignInWithEmailLink(url: string) { isSignInWithEmailLink(url: string) {
const urlObject = new URL(url) const urlObject = new URL(url)
const searchParams = new URLSearchParams(urlObject.search) const searchParams = new URLSearchParams(urlObject.search)