Compare commits

..

5 Commits

Author SHA1 Message Date
Gusram
4156551b24 feat(desktop): implement backend wrapper auth 2024-05-31 00:39:19 +08:00
Nivedin
f8ac6dfeb1 chore: add workspace switcher login A/B testing flow (#4053)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2024-05-10 16:35:42 +05:30
Andrew Bastin
7d2d335b37 chore: revert back default interceptor for sh app to browser 2024-05-10 16:13:51 +05:30
Andrew Bastin
76875db865 chore: bump selfhost-desktop lockfile version 2024-05-10 15:04:16 +05:30
Balu Babu
96e2d87b57 feat: update node version to node20-apline3.19 (#4040) 2024-05-10 14:24:34 +05:30
15 changed files with 453 additions and 24 deletions

View File

@@ -47,6 +47,7 @@ RATE_LIMIT_MAX=100 # Max requests per IP
# Base URLs
VITE_BACKEND_LOGIN_API_URL=http://localhost:5444
VITE_BASE_URL=http://localhost:3000
VITE_SHORTCODE_BASE_URL=http://localhost:3000
VITE_ADMIN_URL=http://localhost:3100

View File

@@ -43,12 +43,19 @@
@click="invokeAction('modals.support.toggle')"
/>
</div>
<div class="flex">
<div
class="flex"
:class="{
'flex-row-reverse gap-2':
workspaceSelectorFlagEnabled && !currentUser,
}"
>
<div
v-if="currentUser === null"
class="inline-flex items-center space-x-2"
>
<HoppButtonSecondary
v-if="!workspaceSelectorFlagEnabled"
:icon="IconUploadCloud"
:label="t('header.save_workspace')"
class="!focus-visible:text-emerald-600 !hover:text-emerald-600 hidden h-8 border border-emerald-600/25 bg-emerald-500/10 !text-emerald-500 hover:border-emerald-600/20 hover:bg-emerald-600/20 focus-visible:border-emerald-600/20 focus-visible:bg-emerald-600/20 md:flex"
@@ -60,18 +67,22 @@
@click="invokeAction('modals.login.toggle')"
/>
</div>
<div v-else class="inline-flex items-center space-x-2">
<TeamsMemberStack
v-if="
workspace.type === 'team' &&
selectedTeam &&
selectedTeam.teamMembers.length > 1
"
:team-members="selectedTeam.teamMembers"
show-count
class="mx-2"
@handle-click="handleTeamEdit()"
/>
<TeamsMemberStack
v-else-if="
currentUser !== null &&
workspace.type === 'team' &&
selectedTeam &&
selectedTeam.teamMembers.length > 1
"
:team-members="selectedTeam.teamMembers"
show-count
class="mx-2"
@handle-click="handleTeamEdit()"
/>
<div
v-if="workspaceSelectorFlagEnabled || currentUser"
class="inline-flex items-center space-x-2"
>
<div
class="flex h-8 divide-x divide-emerald-600/25 rounded border border-emerald-600/25 bg-emerald-500/10 focus-within:divide-emerald-600/20 focus-within:border-emerald-600/20 focus-within:bg-emerald-600/20 hover:divide-emerald-600/20 hover:border-emerald-600/20 hover:bg-emerald-600/20"
>
@@ -84,6 +95,7 @@
/>
<HoppButtonSecondary
v-if="
currentUser &&
workspace.type === 'team' &&
selectedTeam &&
selectedTeam?.myRole === 'OWNER'
@@ -124,7 +136,7 @@
</div>
</template>
</tippy>
<span class="px-2">
<span v-if="currentUser" class="px-2">
<tippy
interactive
trigger="click"
@@ -259,6 +271,13 @@ import {
const t = useI18n()
const toast = useToast()
/**
* Feature flag to enable the workspace selector login conversion
*/
const workspaceSelectorFlagEnabled = computed(
() => !!platform.platformFeatureFlags.workspaceSwitcherLogin?.value
)
/**
* Once the PWA code is initialized, this holds a method
* that can be called to show the user the installation
@@ -380,6 +399,8 @@ const inviteTeam = (team: { name: string }, teamID: string) => {
// Show the workspace selected team invite modal if the user is an owner of the team else show the default invite modal
const handleInvite = () => {
if (!currentUser.value) return invokeAction("modals.login.toggle")
if (
workspace.value.type === "team" &&
workspace.value.teamID &&

View File

@@ -59,7 +59,7 @@
/>
</div>
<div
v-if="!loading && teamListAdapterError"
v-else-if="teamListAdapterError"
class="flex flex-col items-center py-4"
>
<icon-lucide-help-circle class="svg-icons mb-4" />
@@ -85,7 +85,7 @@ import { useColorMode } from "@composables/theming"
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
import IconDone from "~icons/lucide/check"
import { useLocalState } from "~/newstore/localstate"
import { defineActionHandler } from "~/helpers/actions"
import { defineActionHandler, invokeAction } from "~/helpers/actions"
import { WorkspaceService } from "~/services/workspace.service"
import { useService } from "dioc/vue"
import { useElementVisibility, useIntervalFn } from "@vueuse/core"
@@ -157,8 +157,8 @@ const switchToTeamWorkspace = (team: GetMyTeamsQuery["myTeams"][number]) => {
workspaceService.changeWorkspace({
teamID: team.id,
teamName: team.name,
role: team.myRole,
type: "team",
role: team.myRole,
})
}
@@ -174,12 +174,16 @@ watch(
(user) => {
if (!user) {
switchToPersonalWorkspace()
teamListadapter.dispose()
}
}
)
const displayModalAdd = (shouldDisplay: boolean) => {
if (!currentUser.value) return invokeAction("modals.login.toggle")
showModalAdd.value = shouldDisplay
teamListadapter.fetchList()
}
defineActionHandler("modals.team.new", () => {

View File

@@ -50,6 +50,7 @@ export default class TeamListAdapter {
}
public dispose() {
this.teamList$.next([])
this.isDispose = true
clearTimeout(this.timeoutHandle as any)
this.timeoutHandle = null

View File

@@ -11,6 +11,7 @@ import { InspectorsPlatformDef } from "./inspectors"
import { ServiceClassInstance } from "dioc"
import { IOPlatformDef } from "./io"
import { SpotlightPlatformDef } from "./spotlight"
import { Ref } from "vue"
export type PlatformDef = {
ui?: UIPlatformDef
@@ -45,6 +46,11 @@ export type PlatformDef = {
* If a value is not given, then the value is assumed to be true
*/
promptAsUsingCookies?: boolean
/**
* Whether to show the A/B testing workspace switcher click login flow or not
*/
workspaceSwitcherLogin?: Ref<boolean>
}
}

View File

@@ -14,3 +14,20 @@ Since TypeScript cannot handle type information for `.vue` imports, they are shi
2. Reload the VS Code window by running `Developer: Reload Window` from the command palette.
You can learn more about Take Over mode [here](https://github.com/johnsoncodehk/volar/discussions/471).
## Building
### Prequisites
- Install Rust: `curl https://sh.rustup.rs -sSf | sh`
- libsoup2.4-dev installed if Linux: `sudo apt install libsoup2.4-dev`
- Node v18.20 installed and currently active if you're using nvm
### Build Instruction
1. Install latest pnpm `curl -fsSL https://get.pnpm.io/install.sh | sh -` or upgrade `pnpm add -g pnpm`
2. Setup the .env of the root project folder, you should deploy the self hosted backend first
3. Run `pnpm install` on root project folder
4. Run `pnpm dev:gql-codegen` on this folder
5. Run `pnpm tauri dev` to run debug mode (optional)
6. Run `pnpm tauri build` to build release mode
- `pnpm tauri build --target aarch64-apple-darwin` for Apple Sillicon

View File

@@ -17,6 +17,7 @@
"@fontsource-variable/material-symbols-rounded": "5.0.16",
"@fontsource-variable/roboto-mono": "5.0.16",
"@hoppscotch/common": "workspace:^",
"@hoppscotch/data": "workspace:^",
"@platform/auth": "0.1.106",
"@tauri-apps/api": "1.5.1",
"@tauri-apps/cli": "1.5.6",
@@ -36,7 +37,13 @@
"tauri-plugin-store-api": "0.0.0",
"util": "0.12.5",
"vue": "3.3.9",
"workbox-window": "6.6.0"
"workbox-window": "6.6.0",
"zod": "3.22.4",
"@urql/core": "^4.1.1",
"cookie": "^0.5.0",
"subscriptions-transport-ws": "^0.11.0",
"tauri-plugin-websocket-api": "github:tauri-apps/tauri-plugin-websocket#v1",
"wonka": "^6.3.4"
},
"devDependencies": {
"@graphql-codegen/add": "5.0.0",
@@ -76,6 +83,8 @@
"vite-plugin-pwa": "0.13.1",
"vite-plugin-static-copy": "0.12.0",
"vite-plugin-vue-layouts": "0.7.0",
"vue-tsc": "1.8.8"
"vue-tsc": "1.8.8",
"@types/cookie": "^0.5.1"
}
}

View File

@@ -1260,7 +1260,7 @@ dependencies = [
[[package]]
name = "hoppscotch-desktop"
version = "24.3.2"
version = "24.3.3"
dependencies = [
"cocoa 0.25.0",
"hex_color",

View File

@@ -25,6 +25,7 @@ tauri = { version = "1.5.3", features = [
] }
tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tauri-plugin-deep-link = { git = "https://github.com/FabianLars/tauri-plugin-deep-link", branch = "main" }
tauri-plugin-websocket = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tauri-plugin-window-state = "0.1.0"
reqwest = "0.11.22"
serde_json = "1.0.108"

View File

@@ -21,6 +21,7 @@ fn main() {
tauri_plugin_deep_link::prepare("io.hoppscotch.desktop");
tauri::Builder::default()
.plugin(tauri_plugin_websocket::init())
.plugin(tauri_plugin_window_state::Builder::default().build())
.plugin(tauri_plugin_store::Builder::default().build())
.setup(|app| {

View File

@@ -49,7 +49,7 @@
"targets": "all"
},
"security": {
"csp": "none"
"csp": null
},
"updater": {
"active": false

View File

@@ -0,0 +1,168 @@
import { ref } from "vue"
import { makeResult, makeErrorResult, Exchange, Operation } from "@urql/core"
import { makeFetchBody, makeFetchOptions } from "@urql/core/internal"
import { filter, make, merge, mergeMap, pipe, takeUntil, map } from "wonka"
import { gqlClientError$ } from "@hoppscotch/common/helpers/backend/GQLClient"
import { Store } from "tauri-plugin-store-api"
import { Body, getClient } from "@tauri-apps/api/http"
import { parse, serialize } from "cookie"
import { SubscriptionClient } from "subscriptions-transport-ws"
import { platform } from "@hoppscotch/common/platform"
import WSWrapper from "./ws_wrapper"
const APP_DATA_PATH = "~/.hopp-desktop-app-data.dat"
export async function addCookieToFetchHeaders(
store: Store,
headers: HeadersInit = {}
) {
try {
const accessToken = await store.get<{ value: string }>("access_token")
const refreshToken = await store.get<{ value: string }>("refresh_token")
if (accessToken?.value && refreshToken?.value) {
// Assert headers as an indexable type
const headersIndexable = headers as { [key: string]: string }
const existingCookies = parse(headersIndexable["Cookie"] || "")
if (!existingCookies.access_token) {
existingCookies.access_token = accessToken.value
}
if (!existingCookies.refresh_token) {
existingCookies.refresh_token = refreshToken.value
}
// Serialize the cookies back into the headers
const serializedCookies = Object.entries(existingCookies)
.map(([name, value]) => serialize(name, value))
.join("; ")
headersIndexable["Cookie"] = serializedCookies
}
return headers
} catch (error) {
console.error("error while injecting cookie")
}
}
function createHttpSource(operation: Operation, store: Store) {
return make(({ next, complete }) => {
getClient().then(async (httpClient) => {
const fetchOptions = makeFetchOptions(operation)
let headers = fetchOptions.headers
headers = await addCookieToFetchHeaders(store, headers)
const fetchBody = makeFetchBody(operation)
httpClient
.post(operation.context.url, Body.json(fetchBody), {
headers,
})
.then((result) => {
next(result.data)
complete()
})
.catch((error) => {
next(makeErrorResult(operation, error))
complete()
})
})
return () => {}
})
}
export const tauriGQLFetchExchange =
(store: Store): Exchange =>
({ forward }) =>
(ops$) => {
const subscriptionResults$ = pipe(
ops$,
filter((op) => op.kind === "query" || op.kind === "mutation"),
mergeMap((operation) => {
const { key, context } = operation
const teardown$ = pipe(
ops$,
filter((op: Operation) => op.kind === "teardown" && op.key === key)
)
const source = createHttpSource(operation, store)
return pipe(
source,
takeUntil(teardown$),
map((result) => makeResult(operation, result as any))
)
})
)
const forward$ = pipe(
ops$,
filter(
(op: Operation) => op.kind === "teardown" || op.kind != "subscription"
),
forward
)
return merge([subscriptionResults$, forward$])
}
const createSubscriptionClient = () => {
return new SubscriptionClient(
import.meta.env.VITE_BACKEND_WS_URL,
{
reconnect: true,
connectionParams: () => platform.auth.getBackendHeaders(),
connectionCallback(error) {
if (error?.length > 0) {
gqlClientError$.next({
type: "SUBSCRIPTION_CONN_CALLBACK_ERR_REPORT",
errors: error,
})
}
},
wsOptionArguments: [
{
store: new Store(APP_DATA_PATH),
},
],
},
WSWrapper
)
}
let subscriptionClient: SubscriptionClient | null
const isBackendGQLEventAdded = ref(false)
const resetSubscriptionClient = () => {
if (subscriptionClient) {
subscriptionClient.close()
}
subscriptionClient = createSubscriptionClient()
if (!isBackendGQLEventAdded.value) {
subscriptionClient.onConnected(() => {
platform.auth.onBackendGQLClientShouldReconnect(() => {
const currentUser = platform.auth.getCurrentUser()
if (currentUser && subscriptionClient) {
subscriptionClient?.client?.close()
}
if (currentUser && !subscriptionClient) {
resetSubscriptionClient()
}
if (!currentUser && subscriptionClient) {
subscriptionClient.close()
resetSubscriptionClient()
}
})
})
isBackendGQLEventAdded.value = true
}
}
export const getSubscriptionClient = () => {
if (!subscriptionClient) resetSubscriptionClient()
return subscriptionClient
}

View File

@@ -0,0 +1,154 @@
import { Store } from "tauri-plugin-store-api"
import TauriWebSocket, {
Message,
ConnectionConfig,
} from "tauri-plugin-websocket-api"
import { addCookieToFetchHeaders } from "./GQLClient"
/**
* This is a wrapper around tauri-plugin-websocket-api with cookie injection support. This is required because
* subscriptions-transport-ws client expects a custom websocket implementation in the shape of native browser WebSocket.
*/
export default class WebSocketWrapper extends EventTarget implements WebSocket {
public client: TauriWebSocket | undefined
private tauriWebSocketConfig:
| (ConnectionConfig & { store: Store })
| undefined
private isConnected: boolean = false
binaryType: BinaryType = "blob"
extensions = ""
onclose: ((this: WebSocket, ev: CloseEvent) => any) | null = null
onerror: ((this: WebSocket, ev: Event) => any) | null = null
onmessage: ((this: WebSocket, ev: MessageEvent) => any) | null = null
onopen: ((this: WebSocket, ev: Event) => any) | null = null
protocol = ""
url: string
public static readonly CONNECTING = 0
public static readonly OPEN = 1
public static readonly CLOSING = 2
public static readonly CLOSED = 3
readonly CONNECTING = 0
readonly OPEN = 1
readonly CLOSING = 2
readonly CLOSED = 3
constructor(
url: string,
protocols?: string | string[],
config?: ConnectionConfig & { store: Store }
) {
super()
this.url = url
this.tauriWebSocketConfig = config
this.setup()
}
private async setup() {
if (this.tauriWebSocketConfig?.store) {
const headersStringified =
this.tauriWebSocketConfig.headers || ("{}" as any)
let headers = JSON.parse(headersStringified)
headers = await addCookieToFetchHeaders(
this.tauriWebSocketConfig.store,
headers
)
this.tauriWebSocketConfig = {
...this.tauriWebSocketConfig,
headers,
}
}
this.client = await TauriWebSocket.connect(this.url, {
headers: {
"sec-websocket-protocol": "graphql-ws",
...this.tauriWebSocketConfig?.headers,
},
}).catch((error) => {
this.isConnected = false
if (this.onerror) {
this.onerror(new Event("error"))
}
throw new Error(error)
})
this.isConnected = true
this.client.addListener(this.handleMessage.bind(this))
if (this.onopen) {
this.onopen(new Event("open"))
}
}
get readyState(): number {
return this.client
? this.isConnected
? this.OPEN
: this.CLOSED
: this.CONNECTING
}
get bufferedAmount(): number {
// TODO implement
return 0
}
close(code?: number, reason?: string): void {
this.client?.disconnect().then(() => {
if (this.onclose) {
this.onclose(new CloseEvent("close"))
}
})
}
send(data: string | ArrayBufferLike | Blob | ArrayBufferView) {
if (
typeof data === "string" ||
data instanceof ArrayBuffer ||
data instanceof Blob
) {
this.client?.send(data as string).catch((error) => {
console.error("error while sending data", data)
if (this.onerror) {
this.onerror(new Event("error"))
}
})
} else {
// TODO implement, drop the record for now
console.warn(
"WebSocketWrapper.send() not implemented for non-string data"
)
}
}
private handleMessage(message: Message): void {
switch (message.type) {
case "Close": {
if (this.onclose) {
this.onclose(new CloseEvent("close"))
}
return
}
case "Ping": {
this.client?.send("Pong").catch((error) => {
console.error("error while sending Pong data", message)
if (this.onerror) {
this.onerror(new Event("error"))
}
})
return
}
default: {
if (this.onmessage) {
this.onmessage(
new MessageEvent("message", {
data: message.data,
origin: this.url,
})
)
}
}
}
}
}

View File

@@ -1,4 +1,5 @@
import { getService } from "@hoppscotch/common/modules/dioc"
import axios from "axios"
import {
AuthEvent,
AuthPlatformDef,
@@ -13,15 +14,50 @@ import { open } from '@tauri-apps/api/shell'
import { BehaviorSubject, Subject } from "rxjs"
import { Store } from "tauri-plugin-store-api"
import { Ref, ref, watch } from "vue"
import { z } from "zod"
import * as E from "fp-ts/Either"
import { subscriptionExchange } from "@urql/core"
import {
getSubscriptionClient,
tauriGQLFetchExchange,
} from "../helpers/GQLClient"
export const authEvents$ = new Subject<AuthEvent | { event: "token_refresh" }>()
const currentUser$ = new BehaviorSubject<HoppUser | null>(null)
export const probableUser$ = new BehaviorSubject<HoppUser | null>(null)
const APP_DATA_PATH = "~/.hopp-desktop-app-data.dat"
export const APP_DATA_PATH = "~/.hopp-desktop-app-data.dat"
const persistenceService = getService(PersistenceService)
const expectedAllowedProvidersSchema = z.object({
// currently supported values are "GOOGLE", "GITHUB", "EMAIL", "MICROSOFT", "SAML"
// keeping it as string to avoid backend accidentally breaking frontend when adding new providers
providers: z.array(z.string()),
})
export const getAllowedAuthProviders = async () => {
try {
const res = await axios.get(
`${import.meta.env.VITE_BACKEND_API_URL}/auth/providers`,
{
withCredentials: true,
}
)
const parseResult = expectedAllowedProvidersSchema.safeParse(res.data)
if (!parseResult.success) {
return E.left("SOMETHING_WENT_WRONG")
}
return E.right(parseResult.data.providers)
} catch (_) {
return E.left("SOMETHING_WENT_WRONG")
}
}
async function logout() {
let client = await getClient();
await client.get(`${import.meta.env.VITE_BACKEND_API_URL}/auth/logout`)
@@ -37,7 +73,7 @@ async function signInUserWithGithubFB() {
}
async function signInUserWithGoogleFB() {
await open(`${import.meta.env.VITE_BACKEND_API_URL}/auth/google?redirect_uri=desktop`);
await open(`${import.meta.env.VITE_BACKEND_LOGIN_API_URL}/authenticate`);
}
async function signInUserWithMicrosoftFB() {
@@ -224,6 +260,15 @@ export const def: AuthPlatformDef = {
},
getGQLClientOptions() {
return {
exchanges: [
subscriptionExchange({
forwardSubscription(fetchBody) {
const subscriptionClient = getSubscriptionClient()
return subscriptionClient!.request(fetchBody)
},
}),
tauriGQLFetchExchange(new Store(APP_DATA_PATH)),
],
fetchOptions: {
credentials: "include",
},
@@ -371,4 +416,5 @@ export const def: AuthPlatformDef = {
event: "logout",
})
},
getAllowedAuthProviders,
}

View File

@@ -26,7 +26,7 @@ createHoppApp("#app", {
history: historyDef,
},
interceptors: {
default: "proxy",
default: "browser",
interceptors: [
{ type: "standalone", interceptor: browserInterceptor },
{ type: "standalone", interceptor: proxyInterceptor },