chore: add proper error msg and disable email updation in SH (#5247)

Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
Nivedin 2025-07-25 12:42:51 +05:30 committed by GitHub
parent f52349e734
commit cfa2caa1db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 152 additions and 80 deletions

View file

@ -533,6 +533,7 @@
"delete_account_description": "You must either remove yourself, transfer ownership, or delete these workspaces before you can delete your account.", "delete_account_description": "You must either remove yourself, transfer ownership, or delete these workspaces before you can delete your account.",
"delete_activity_log": "Failed to delete activity log", "delete_activity_log": "Failed to delete activity log",
"delete_all_activity_logs": "Failed to delete all activity logs", "delete_all_activity_logs": "Failed to delete all activity logs",
"email_already_exists": "This email already exists with a different account. Please set a different email address.",
"empty_email_address": "Email Address cannot be empty", "empty_email_address": "Email Address cannot be empty",
"empty_profile_name": "Profile name cannot be empty", "empty_profile_name": "Profile name cannot be empty",
"empty_req_name": "Empty Request Name", "empty_req_name": "Empty Request Name",
@ -856,7 +857,8 @@
"roles_description": "Roles are used to control access to the shared collections.", "roles_description": "Roles are used to control access to the shared collections.",
"updated": "Profile updated", "updated": "Profile updated",
"viewer": "Viewer", "viewer": "Viewer",
"viewer_description": "Viewers can only view and use requests." "viewer_description": "Viewers can only view and use requests.",
"verified_email_sent": "A verification email has been sent to your email address. Please refresh the page after verifying your email address. You will receive an email if this email is not associated with any other account."
}, },
"remove": { "remove": {
"star": "Remove star" "star": "Remove star"

View file

@ -117,9 +117,11 @@
:autofocus="false" :autofocus="false"
styles="flex mt-2 md:max-w-sm" styles="flex mt-2 md:max-w-sm"
:placeholder="`${t('settings.profile_email')}`" :placeholder="`${t('settings.profile_email')}`"
:disabled="!isEmailEditable"
> >
<template #button> <template #button>
<HoppButtonSecondary <HoppButtonSecondary
v-if="isEmailEditable"
filled filled
outline outline
:label="t('action.save')" :label="t('action.save')"
@ -238,6 +240,10 @@ const probableUser = useReadonlyStream(
platform.auth.getProbableUser() platform.auth.getProbableUser()
) )
const isEmailEditable = computed(() => {
return platform.auth.isEmailEditable ?? false
})
const loadingCurrentUser = computed(() => { const loadingCurrentUser = computed(() => {
if (!probableUser.value) return false if (!probableUser.value) return false
else if (!currentUser.value) return true else if (!currentUser.value) return true
@ -277,7 +283,7 @@ const emailAddress = ref(currentUser.value?.email || "")
const updatingEmailAddress = ref(false) const updatingEmailAddress = ref(false)
watchEffect(() => (emailAddress.value = currentUser.value?.email || "")) watchEffect(() => (emailAddress.value = currentUser.value?.email || ""))
const updateEmailAddress = () => { const updateEmailAddress = async () => {
const inputEmailAddress = emailAddress.value.trim() const inputEmailAddress = emailAddress.value.trim()
if (!inputEmailAddress) { if (!inputEmailAddress) {
toast.error(`${t("error.empty_email_address")}`) toast.error(`${t("error.empty_email_address")}`)
@ -290,17 +296,25 @@ const updateEmailAddress = () => {
} }
updatingEmailAddress.value = true updatingEmailAddress.value = true
platform.auth
.setEmailAddress(inputEmailAddress as string) const result = await platform.auth.setEmailAddress(inputEmailAddress)
.then(() => {
toast.success(`${t("profile.updated")}`) if (!result) {
}) toast.error(`${t("error.something_went_wrong")}`)
.catch(() => { updatingEmailAddress.value = false
toast.error(`${t("error.something_went_wrong")}`) return
}) }
.finally(() => {
updatingEmailAddress.value = false if (result.type === "success") {
}) toast.success(`${t("profile.verified_email_sent")}`)
} else if (result.type === "email-already-in-use") {
toast.error(`${t("error.email_already_exists")}`)
} else if (result.type === "requires-recent-login") {
await result.link()
} else {
toast.error(`${t("error.something_went_wrong")}`)
}
updatingEmailAddress.value = false
} }
const verifyingEmailAddress = ref(false) const verifyingEmailAddress = ref(false)

View file

@ -44,6 +44,13 @@ export type GithubSignInResult =
| { type: "account-exists-with-different-cred"; link: () => Promise<void> } // We authenticated correctly, but the provider didn't match, so we give the user the opportunity to link to continue completing auth | { type: "account-exists-with-different-cred"; link: () => Promise<void> } // We authenticated correctly, but the provider didn't match, so we give the user the opportunity to link to continue completing auth
| { type: "error"; err: unknown } // Auth failed completely and we don't know why | { type: "error"; err: unknown } // Auth failed completely and we don't know why
export type SetEmailAddressResult =
| { type: "success" } // The email address was set successfully
| { type: "email-already-in-use" } // The email address is already in use by another account
| { type: "requires-recent-login"; link: () => Promise<void> } // The user needs to re-authenticate to set the email address
| { type: "no-user-logged-in" } // No user is currently logged in, so we can't set the email address
| { type: "error"; err: unknown } // An error occurred while setting the email address
export type LoginItemDef = { export type LoginItemDef = {
id: string id: string
icon: Component icon: Component
@ -230,10 +237,18 @@ export type AuthPlatformDef = {
/** /**
* Updates the email address of the user * Updates the email address of the user
*
* NOTES:
* 1. This will return an error if the email is already in use by another account
* 2. This will return an error if the user needs to re-authenticate
* 3. This will return undefined if no user is logged in, so check for that before calling this
*
* @param email The new email to set this to. * @param email The new email to set this to.
* @returns An empty promise that is resolved when the operation is complete * @returns A promise that resolves with the email update status when the operation is complete
*/ */
setEmailAddress: (email: string) => Promise<void> setEmailAddress: (
email: string
) => Promise<SetEmailAddressResult> | Promise<void>
/** /**
* Updates the display name of the user * Updates the display name of the user
@ -253,4 +268,11 @@ export type AuthPlatformDef = {
* Defines the additional login items that should be shown in the login screen * Defines the additional login items that should be shown in the login screen
*/ */
additionalLoginItems?: LoginItemDef[] additionalLoginItems?: LoginItemDef[]
/**
* Whether the email address is editable by the user or not.
* This is used to determine whether the email address field should disabled in the user settings.
* If a value is not given, then the value is assumed to be false.
*/
isEmailEditable?: boolean
} }

View file

@ -4,12 +4,10 @@ import {
AuthPlatformDef, AuthPlatformDef,
HoppUser, HoppUser,
} from "@hoppscotch/common/platform/auth" } from "@hoppscotch/common/platform/auth"
import { import { PersistenceService } from "@hoppscotch/common/services/persistence"
PersistenceService import { listen } from "@tauri-apps/api/event"
} from "@hoppscotch/common/services/persistence" import { Body, getClient } from "@tauri-apps/api/http"
import { listen } from '@tauri-apps/api/event' import { open } from "@tauri-apps/api/shell"
import { Body, getClient } from '@tauri-apps/api/http'
import { open } from '@tauri-apps/api/shell'
import { BehaviorSubject, Subject } from "rxjs" import { BehaviorSubject, Subject } from "rxjs"
import { Store } from "tauri-plugin-store-api" import { Store } from "tauri-plugin-store-api"
import { Ref, ref, watch } from "vue" import { Ref, ref, watch } from "vue"
@ -23,7 +21,7 @@ const APP_DATA_PATH = "~/.hopp-desktop-app-data.dat"
const persistenceService = getService(PersistenceService) const persistenceService = getService(PersistenceService)
async function logout() { async function logout() {
let client = await getClient(); let client = await getClient()
await client.get(`${import.meta.env.VITE_BACKEND_API_URL}/auth/logout`) await client.get(`${import.meta.env.VITE_BACKEND_API_URL}/auth/logout`)
const store = new Store(APP_DATA_PATH) const store = new Store(APP_DATA_PATH)
@ -33,19 +31,27 @@ async function logout() {
} }
async function signInUserWithGithubFB() { async function signInUserWithGithubFB() {
await open(`${import.meta.env.VITE_BACKEND_API_URL}/auth/github?redirect_uri=desktop`); await open(
`${import.meta.env.VITE_BACKEND_API_URL}/auth/github?redirect_uri=desktop`
)
} }
async function signInUserWithGoogleFB() { async function signInUserWithGoogleFB() {
await open(`${import.meta.env.VITE_BACKEND_API_URL}/auth/google?redirect_uri=desktop`); await open(
`${import.meta.env.VITE_BACKEND_API_URL}/auth/google?redirect_uri=desktop`
)
} }
async function signInUserWithMicrosoftFB() { async function signInUserWithMicrosoftFB() {
await open(`${import.meta.env.VITE_BACKEND_API_URL}/auth/microsoft?redirect_uri=desktop`); await open(
`${
import.meta.env.VITE_BACKEND_API_URL
}/auth/microsoft?redirect_uri=desktop`
)
} }
async function getInitialUserDetails() { async function getInitialUserDetails() {
const store = new Store(APP_DATA_PATH); const store = new Store(APP_DATA_PATH)
try { try {
const accessToken = await store.get("access_token") const accessToken = await store.get("access_token")
@ -60,20 +66,23 @@ async function getInitialUserDetails() {
isAdmin isAdmin
createdOn createdOn
} }
}`} }`,
let res = await client.post(`${import.meta.env.VITE_BACKEND_GQL_URL}`,
Body.json(body), {
headers: {
"Cookie": `access_token=${accessToken.value}`,
}
} }
let res = await client.post(
`${import.meta.env.VITE_BACKEND_GQL_URL}`,
Body.json(body),
{
headers: {
Cookie: `access_token=${accessToken.value}`,
},
}
) )
return res.data return res.data
} catch (error) { } catch (error) {
let res = { let res = {
error: "auth/cookies_not_found" error: "auth/cookies_not_found",
} }
return res return res
@ -149,14 +158,17 @@ async function setInitialUser() {
} }
async function refreshToken() { async function refreshToken() {
const store = new Store(APP_DATA_PATH); const store = new Store(APP_DATA_PATH)
try { try {
const refreshToken = await store.get("refresh_token") const refreshToken = await store.get("refresh_token")
let client = await getClient() let client = await getClient()
let res = await client.get(`${import.meta.env.VITE_BACKEND_API_URL}/auth/refresh`, { let res = await client.get(
headers: { "Cookie": `refresh_token=${refreshToken.value}` } `${import.meta.env.VITE_BACKEND_API_URL}/auth/refresh`,
}) {
headers: { Cookie: `refresh_token=${refreshToken.value}` },
}
)
setAuthCookies(res.rawHeaders) setAuthCookies(res.rawHeaders)
@ -175,13 +187,16 @@ async function refreshToken() {
} }
async function sendMagicLink(email: string) { async function sendMagicLink(email: string) {
const client = await getClient(); const client = await getClient()
let url = `${import.meta.env.VITE_BACKEND_API_URL}/auth/signin?origin=desktop`; let url = `${import.meta.env.VITE_BACKEND_API_URL}/auth/signin?origin=desktop`
const res = await client.post(url, Body.json({ email })); const res = await client.post(url, Body.json({ email }))
if (res.data && res.data.deviceIdentifier) { if (res.data && res.data.deviceIdentifier) {
persistenceService.setLocalConfig("deviceIdentifier", res.data.deviceIdentifier) persistenceService.setLocalConfig(
"deviceIdentifier",
res.data.deviceIdentifier
)
} else { } else {
throw new Error("test: does not get device identifier") throw new Error("test: does not get device identifier")
} }
@ -190,27 +205,26 @@ async function sendMagicLink(email: string) {
} }
async function setAuthCookies(rawHeaders: Array<String>) { async function setAuthCookies(rawHeaders: Array<String>) {
let cookies = rawHeaders['set-cookie'].join("|") let cookies = rawHeaders["set-cookie"].join("|")
const accessTokenMatch = cookies.match(/access_token=([^;]+)/); const accessTokenMatch = cookies.match(/access_token=([^;]+)/)
const refreshTokenMatch = cookies.match(/refresh_token=([^;]+)/); const refreshTokenMatch = cookies.match(/refresh_token=([^;]+)/)
const store = new Store(APP_DATA_PATH) const store = new Store(APP_DATA_PATH)
if (accessTokenMatch) { if (accessTokenMatch) {
const accessToken = accessTokenMatch[1]; const accessToken = accessTokenMatch[1]
await store.set("access_token", { value: accessToken }) await store.set("access_token", { value: accessToken })
} }
if (refreshTokenMatch) { if (refreshTokenMatch) {
const refreshToken = refreshTokenMatch[1]; const refreshToken = refreshTokenMatch[1]
await store.set("refresh_token", { value: refreshToken }) await store.set("refresh_token", { value: refreshToken })
} }
await store.save() await store.save()
} }
export const def: AuthPlatformDef = { export const def: AuthPlatformDef = {
getCurrentUserStream: () => currentUser$, getCurrentUserStream: () => currentUser$,
getAuthEventsStream: () => authEvents$, getAuthEventsStream: () => authEvents$,
@ -257,31 +271,36 @@ export const def: AuthPlatformDef = {
return null return null
}, },
async performAuthInit() { async performAuthInit() {
const probableUser = JSON.parse(persistenceService.getLocalConfig("login_state") ?? "null") const probableUser = JSON.parse(
persistenceService.getLocalConfig("login_state") ?? "null"
)
probableUser$.next(probableUser) probableUser$.next(probableUser)
await setInitialUser() await setInitialUser()
await listen('scheme-request-received', async (event: any) => { await listen("scheme-request-received", async (event: any) => {
let deep_link = event.payload as string; let deep_link = event.payload as string
const params = new URLSearchParams(deep_link.split('?')[1]); const params = new URLSearchParams(deep_link.split("?")[1])
const accessToken = params.get('access_token'); const accessToken = params.get("access_token")
const refreshToken = params.get('refresh_token'); const refreshToken = params.get("refresh_token")
const token = params.get('token'); const token = params.get("token")
function isNotNullOrUndefined(x: any) { function isNotNullOrUndefined(x: any) {
return x !== null && x !== undefined; return x !== null && x !== undefined
} }
if (isNotNullOrUndefined(accessToken) && isNotNullOrUndefined(refreshToken)) { if (
isNotNullOrUndefined(accessToken) &&
isNotNullOrUndefined(refreshToken)
) {
const store = new Store(APP_DATA_PATH) const store = new Store(APP_DATA_PATH)
await store.set("access_token", { value: accessToken }); await store.set("access_token", { value: accessToken })
await store.set("refresh_token", { value: refreshToken } ); await store.set("refresh_token", { value: refreshToken })
await store.save() await store.save()
window.location.href = "/" window.location.href = "/"
return; return
} }
if (isNotNullOrUndefined(token)) { if (isNotNullOrUndefined(token)) {
@ -289,7 +308,7 @@ export const def: AuthPlatformDef = {
await this.signInWithEmailLink("", "") await this.signInWithEmailLink("", "")
await setInitialUser() await setInitialUser()
} }
}); })
}, },
waitProbableLoginToConfirm() { waitProbableLoginToConfirm() {
@ -327,7 +346,8 @@ export const def: AuthPlatformDef = {
await signInUserWithMicrosoftFB() await signInUserWithMicrosoftFB()
}, },
async signInWithEmailLink(_email, _url) { async signInWithEmailLink(_email, _url) {
const deviceIdentifier = persistenceService.getLocalConfig("deviceIdentifier") const deviceIdentifier =
persistenceService.getLocalConfig("deviceIdentifier")
if (!deviceIdentifier) { if (!deviceIdentifier) {
throw new Error( throw new Error(
@ -337,11 +357,14 @@ export const def: AuthPlatformDef = {
let verifyToken = persistenceService.getLocalConfig("verifyToken") let verifyToken = persistenceService.getLocalConfig("verifyToken")
const client = await getClient(); const client = await getClient()
let res = await client.post(`${import.meta.env.VITE_BACKEND_API_URL}/auth/verify`, Body.json({ let res = await client.post(
token: verifyToken, `${import.meta.env.VITE_BACKEND_API_URL}/auth/verify`,
deviceIdentifier Body.json({
})); token: verifyToken,
deviceIdentifier,
})
)
setAuthCookies(res.rawHeaders) setAuthCookies(res.rawHeaders)

View file

@ -62,19 +62,25 @@ async function logout() {
async function signInUserWithGithubFB() { async function signInUserWithGithubFB() {
await Io.openExternalLink({ await Io.openExternalLink({
url: `${import.meta.env.VITE_BACKEND_API_URL}/auth/github?redirect_uri=desktop`, url: `${
import.meta.env.VITE_BACKEND_API_URL
}/auth/github?redirect_uri=desktop`,
}) })
} }
async function signInUserWithGoogleFB() { async function signInUserWithGoogleFB() {
await Io.openExternalLink({ await Io.openExternalLink({
url: `${import.meta.env.VITE_BACKEND_API_URL}/auth/google?redirect_uri=desktop`, url: `${
import.meta.env.VITE_BACKEND_API_URL
}/auth/google?redirect_uri=desktop`,
}) })
} }
async function signInUserWithMicrosoftFB() { async function signInUserWithMicrosoftFB() {
await Io.openExternalLink({ await Io.openExternalLink({
url: `${import.meta.env.VITE_BACKEND_API_URL}/auth/microsoft?redirect_uri=desktop`, url: `${
import.meta.env.VITE_BACKEND_API_URL
}/auth/microsoft?redirect_uri=desktop`,
}) })
} }
@ -83,8 +89,9 @@ async function getInitialUserDetails(): Promise<
> { > {
try { try {
const accessToken = await persistenceService.getLocalConfig("access_token") const accessToken = await persistenceService.getLocalConfig("access_token")
const refreshToken = const refreshToken = await persistenceService.getLocalConfig(
await persistenceService.getLocalConfig("refresh_token") "refresh_token"
)
if (!accessToken || !refreshToken) { if (!accessToken || !refreshToken) {
return { error: "auth/cookies_not_found" } return { error: "auth/cookies_not_found" }
@ -217,8 +224,9 @@ export async function setInitialUser() {
async function refreshToken() { async function refreshToken() {
try { try {
const refreshToken = const refreshToken = await persistenceService.getLocalConfig(
await persistenceService.getLocalConfig("refresh_token") "refresh_token"
)
if (!refreshToken) return null if (!refreshToken) return null
const { response } = interceptorService.execute({ const { response } = interceptorService.execute({
@ -447,8 +455,9 @@ export const def: AuthPlatformDef = {
}, },
async signInWithEmailLink(_email: string, url: string) { async signInWithEmailLink(_email: string, url: string) {
const deviceIdentifier = const deviceIdentifier = await persistenceService.getLocalConfig(
await persistenceService.getLocalConfig("deviceIdentifier") "deviceIdentifier"
)
if (!deviceIdentifier) { if (!deviceIdentifier) {
throw new Error( throw new Error(

View file

@ -302,8 +302,9 @@ export const def: AuthPlatformDef = {
const token = searchParams.get("token") const token = searchParams.get("token")
const deviceIdentifier = const deviceIdentifier = await persistenceService.getLocalConfig(
await persistenceService.getLocalConfig("deviceIdentifier") "deviceIdentifier"
)
await axios.post( await axios.post(
`${import.meta.env.VITE_BACKEND_API_URL}/auth/verify`, `${import.meta.env.VITE_BACKEND_API_URL}/auth/verify`,
@ -355,8 +356,9 @@ export const def: AuthPlatformDef = {
async processMagicLink() { async processMagicLink() {
if (this.isSignInWithEmailLink(window.location.href)) { if (this.isSignInWithEmailLink(window.location.href)) {
const deviceIdentifier = const deviceIdentifier = await persistenceService.getLocalConfig(
await persistenceService.getLocalConfig("deviceIdentifier") "deviceIdentifier"
)
if (!deviceIdentifier) { if (!deviceIdentifier) {
throw new Error( throw new Error(