feat: public apps can require login (#3825)

This commit is contained in:
Ruben Fiszel
2024-05-27 13:03:41 +02:00
committed by GitHub
parent bf73e4e9ed
commit f725e5b361
31 changed files with 496 additions and 358 deletions
+28 -4
View File
@@ -425,6 +425,8 @@ async fn get_app_by_id(
}
async fn get_public_app_by_secret(
OptAuthed(opt_authed): OptAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, secret)): Path<(String, String)>,
) -> JsonResult<AppWithLastVersion> {
@@ -454,10 +456,32 @@ async fn get_public_app_by_secret(
let policy = serde_json::from_str::<Policy>(app.policy.0.get()).map_err(to_anyhow)?;
if !matches!(policy.execution_mode, ExecutionMode::Anonymous) {
return Err(Error::NotAuthorized(
"App visibility does not allow public access".to_string(),
));
if matches!(policy.execution_mode, ExecutionMode::Anonymous) {
return Ok(Json(app));
}
if opt_authed.is_none() {
{
return Err(Error::NotAuthorized(
"App visibility does not allow public access and you are not logged in".to_string(),
));
}
} else {
let authed = opt_authed.unwrap();
let mut tx = user_db.begin(&authed).await?;
let is_visible = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM app WHERE id = $1 AND workspace_id = $2)",
id,
&w_id
)
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
if !is_visible.unwrap_or(false) {
return Err(Error::NotAuthorized(
"App visibility does not allow public access and you are logged in but you have no read-access to that app".to_string(),
));
}
}
Ok(Json(app))
@@ -1,13 +1,14 @@
<script lang="ts">
import { ResourceService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { createEventDispatcher } from 'svelte'
import { createEventDispatcher, getContext } from 'svelte'
import Select from './apps/svelte-select/lib/index'
import { SELECT_INPUT_DEFAULT_STYLE } from '../defaults'
import DarkModeObserver from './DarkModeObserver.svelte'
import { Button, Drawer, DrawerContent } from './common'
import { Plus } from 'lucide-svelte'
import type { AppViewerContext } from './apps/types'
const dispatch = createEventDispatcher()
@@ -16,6 +17,8 @@
export let resourceType: string | undefined = undefined
export let disablePortal = false
const appViewerContext = getContext<AppViewerContext>('AppViewerContext')
let valueSelect =
initialValue || value
? {
@@ -77,7 +80,8 @@
<iframe
title="App connection"
class="w-full h-full"
src="/embed_connect?resource_type={resourceType}"
src="/embed_connect?resource_type={resourceType}&workspace={appViewerContext?.workspace ??
$workspaceStore}"
/>
</DrawerContent>
</Drawer>
+304
View File
@@ -0,0 +1,304 @@
<script lang="ts">
import { goto } from '$app/navigation'
import Github from '$lib/components/icons/brands/Github.svelte'
import Gitlab from '$lib/components/icons/brands/Gitlab.svelte'
import Google from '$lib/components/icons/brands/Google.svelte'
import Microsoft from '$lib/components/icons/brands/Microsoft.svelte'
import Okta from '$lib/components/icons/brands/Okta.svelte'
import { OauthService, UserService, WorkspaceService } from '$lib/gen'
import { usersWorkspaceStore, workspaceStore, userStore } from '$lib/stores'
import { classNames, emptyString, parseQueryParams } from '$lib/utils'
import { getUserExt } from '$lib/user'
import { Button, Skeleton } from '$lib/components/common'
import { sendUserToast } from '$lib/toast'
import { isCloudHosted } from '$lib/cloud'
import { refreshSuperadmin } from '$lib/refreshUser'
export let rd: string | undefined = undefined
export let email: string | undefined = undefined
export let password: string | undefined = undefined
export let error: string | undefined = undefined
const providers = [
{
type: 'github',
name: 'GitHub',
icon: Github
},
{
type: 'gitlab',
name: 'GitLab',
icon: Gitlab
},
{
type: 'google',
name: 'Google',
icon: Google
},
{
type: 'microsoft',
name: 'Microsoft',
icon: Microsoft
},
{
type: 'okta',
name: 'Okta',
icon: Okta
}
] as const
const providersType = providers.map((p) => p.type as string)
let showPassword = false
let logins: string[] | undefined = undefined
let saml: string | undefined = undefined
async function login(): Promise<void> {
if (!email || !password) {
sendUserToast('Please fill in both email and password', true)
return
}
const requestBody = {
email,
password
}
try {
await UserService.login({ requestBody })
} catch (err) {
sendUserToast('Invalid credentials', true)
return
}
// Once logged in, we can fetch the workspaces
$usersWorkspaceStore = await WorkspaceService.listUserWorkspaces()
// trigger a reload of the user
if ($workspaceStore) {
$userStore = await getUserExt($workspaceStore)
}
// Finally, we check whether the user is a superadmin
refreshSuperadmin()
redirectUser()
}
async function redirectUser() {
const firstTimeCookie =
document.cookie.match('(^|;)\\s*first_time\\s*=\\s*([^;]+)')?.pop() || '0'
if (Number(firstTimeCookie) > 0 && email === 'admin@windmill.dev') {
goto('/user/first-time')
return
}
if (rd?.startsWith('http')) {
window.location.href = rd
return
}
if ($workspaceStore) {
goto(rd ?? '/')
} else {
let workspaceTarget = parseQueryParams(rd ?? undefined)['workspace']
if (rd && workspaceTarget) {
$workspaceStore = workspaceTarget
goto(rd)
return
}
if (!$usersWorkspaceStore) {
try {
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
} catch {}
}
const allWorkspaces = $usersWorkspaceStore?.workspaces.filter((x) => x.id != 'admins')
if (allWorkspaces?.length == 1) {
workspaceStore.set(allWorkspaces[0].id)
$userStore = await getUserExt($workspaceStore!)
if (!$userStore?.is_super_admin && $userStore?.operator) {
let defaultApp = await WorkspaceService.getWorkspaceDefaultApp({
workspace: $workspaceStore!
})
if (!emptyString(defaultApp.default_app_path)) {
goto(`/apps/get/${defaultApp.default_app_path}`)
} else {
goto(rd ?? '/')
}
} else {
goto(rd ?? '/')
}
} else if (rd?.startsWith('/user/workspaces')) {
goto(rd)
} else if (rd == '/#user-settings') {
goto(`/user/workspaces#user-settings`)
} else {
goto(`/user/workspaces${rd ? `?rd=${encodeURIComponent(rd)}` : ''}`)
}
}
}
async function loadLogins() {
const allLogins = await OauthService.listOauthLogins()
logins = allLogins.oauth
saml = allLogins.saml
showPassword = (logins.length == 0 && !saml) || (email != undefined && email.length > 0)
}
loadLogins()
function handleKeyUp(event: KeyboardEvent) {
const key = event.key
if (key === 'Enter') {
event.preventDefault()
login()
}
}
function storeRedirect(provider: string) {
if (rd) {
try {
localStorage.setItem('rd', rd)
} catch (e) {
console.error('Could not persist redirection to local storage', e)
}
}
window.location.href = window.location.origin + '/api/oauth/login/' + provider
}
$: error && sendUserToast(error, true)
</script>
<div class="bg-surface px-4 py-8 shadow md:border sm:rounded-lg sm:px-10">
<div class="grid {logins && logins.length > 2 ? 'grid-cols-2' : ''} gap-4">
{#if !logins}
{#each Array(4) as _}
<Skeleton layout={[0.5, [2.375]]} />
{/each}
{:else}
{#each providers as { type, icon, name }}
{#if logins?.includes(type)}
<Button
color="light"
variant="border"
startIcon={{ icon, classes: 'h-4' }}
on:click={() => storeRedirect(type)}
>
{name}
</Button>
{/if}
{/each}
{#each logins.filter((x) => !providersType?.includes(x)) as login}
<Button
color="dark"
variant="border"
btnClasses="mt-2 w-full !border-gray-300"
on:click={() => storeRedirect(login)}
>
{login}
</Button>
{/each}
{/if}
{#if saml}
<Button
color="dark"
variant="border"
btnClasses="mt-2 w-full !border-gray-300"
on:click={() => {
if (saml) {
window.location.href = saml
} else {
sendUserToast('No SAML login available', true)
}
}}
>
SSO
</Button>
{/if}
</div>
{#if saml || (logins && logins.length > 0)}
<div class={classNames('center-center', logins && logins.length > 0 ? 'mt-6' : '')}>
<Button
size="xs"
color="blue"
variant="border"
btnClasses="!border-none"
on:click={() => {
showPassword = !showPassword
}}
>
Log in without third-party
</Button>
</div>
{/if}
{#if showPassword}
<div>
<div class="space-y-6">
{#if isCloudHosted()}
<p class="text-xs text-tertiary italic pb-6">
To get credentials without the OAuth providers above, send an email at
contact@windmill.dev
</p>
{/if}
<div>
<label for="email" class="block text-sm font-medium leading-6 text-primary">
Email
</label>
<div>
<input
type="email"
bind:value={email}
id="email"
autocomplete="email"
class="block w-full rounded-md border-0 py-1.5 text-primary shadow-sm ring-1 ring-inset placeholder:text-secondary focus:ring-2 focus:ring-inset focus:ring-frost-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div>
<label for="password" class="block text-sm font-medium leading-6 text-primary">
Password
</label>
<div>
<input
on:keyup={handleKeyUp}
bind:value={password}
id="password"
type="password"
autocomplete="current-password"
class="block w-full rounded-md border-0 py-1.5 text-shadow shadow-sm ring-1 ring-inset placeholder:text-secondary focus:ring-2 focus:ring-inset focus:ring-frost-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div class="pt-2">
<button
on:click={login}
disabled={!email || !password}
class="flex w-full justify-center rounded-md bg-frost-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-frost-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-frost-600"
>
Sign in
</button>
</div>
</div>
{#if isCloudHosted()}
<p class="text-2xs text-tertiary italic mt-10 text-center">
By logging in, you agree to our
<a href="https://windmill.dev/terms_of_service" target="_blank" rel="noreferrer">
Terms of Service
</a>
and
<a href="https://windmill.dev/privacy_policy" target="_blank" rel="noreferrer">
Privacy Policy
</a>
</p>
{/if}
</div>
{/if}
</div>
@@ -9,7 +9,7 @@
import { twMerge } from 'tailwind-merge'
import InitializeComponent from '../helpers/InitializeComponent.svelte'
import { Alert } from '$lib/components/common'
import { AlignWrapper } from '../helpers'
import AlignWrapper from '../helpers/AlignWrapper.svelte'
export let id: string
export let configuration: RichConfigurations
@@ -18,9 +18,9 @@
import InitializeComponent from '../helpers/InitializeComponent.svelte'
import { sendUserToast } from '$lib/toast'
import type { AppInput } from '../../inputType'
import { RunnableWrapper } from '../helpers'
import type { CustomComponentConfig } from '../../editor/component'
import { Loader2 } from 'lucide-svelte'
import RunnableWrapper from '../helpers/RunnableWrapper.svelte'
export let id: string
export let render: boolean
@@ -7,11 +7,11 @@
import { initCss, transformBareBase64IfNecessary } from '../../utils'
import ResolveConfig from '../helpers/ResolveConfig.svelte'
import InitializeComponent from '../helpers/InitializeComponent.svelte'
import { AlignWrapper } from '../helpers'
import { Button } from '$lib/components/common'
import { loadIcon } from '../icon'
import ComponentErrorHandler from '../helpers/ComponentErrorHandler.svelte'
import ResolveStyle from '../helpers/ResolveStyle.svelte'
import AlignWrapper from '../helpers/AlignWrapper.svelte'
export let id: string
export let configuration: RichConfigurations
@@ -3,13 +3,13 @@
import { initConfig, initOutput } from '../../editor/appUtils'
import type { AppViewerContext, ComponentCustomCSS, RichConfigurations } from '../../types'
import { initCss } from '../../utils'
import { AlignWrapper } from '../helpers'
import { loadIcon } from '../icon'
import InitializeComponent from '../helpers/InitializeComponent.svelte'
import { components } from '../../editor/component'
import ResolveConfig from '../helpers/ResolveConfig.svelte'
import { twMerge } from 'tailwind-merge'
import ResolveStyle from '../helpers/ResolveStyle.svelte'
import AlignWrapper from '../helpers/AlignWrapper.svelte'
export let id: string
export let horizontalAlignment: 'left' | 'center' | 'right' | undefined = 'left'
@@ -12,12 +12,12 @@
import { initCss } from '../../utils'
import ResolveConfig from '../helpers/ResolveConfig.svelte'
import InitializeComponent from '../helpers/InitializeComponent.svelte'
import { AlignWrapper } from '../helpers'
import { Button } from '$lib/components/common'
import { loadIcon } from '../icon'
import ResolveStyle from '../helpers/ResolveStyle.svelte'
import Menu from '$lib/components/common/menu/MenuV2.svelte'
import { AppButton } from '../buttons'
import AlignWrapper from '../helpers/AlignWrapper.svelte'
export let id: string
export let configuration: RichConfigurations
@@ -31,7 +31,6 @@
import DbExplorerCount from './DbExplorerCount.svelte'
import AppAggridExplorerTable from '../table/AppAggridExplorerTable.svelte'
import type { IDatasource } from 'ag-grid-community'
import { RunnableWrapper } from '../../helpers'
import type RunnableComponent from '../../helpers/RunnableComponent.svelte'
import InsertRowRunnable from './InsertRowRunnable.svelte'
import DeleteRow from './DeleteRow.svelte'
@@ -40,6 +39,7 @@
import DebouncedInput from '../../helpers/DebouncedInput.svelte'
import { CancelablePromise } from '$lib/gen'
import RefreshButton from '$lib/components/apps/components/helpers/RefreshButton.svelte'
import RunnableWrapper from '../../helpers/RunnableWrapper.svelte'
export let id: string
export let configuration: RichConfigurations
@@ -1,17 +0,0 @@
export { default as AppTable } from './table/AppTable.svelte'
export { default as AppAggridTable } from './table/AppAggridTable.svelte'
export { default as AppBarChart } from './AppBarChart.svelte'
export { default as AppDisplayComponent } from './AppDisplayComponent.svelte'
export { default as AppHtml } from './AppHtml.svelte'
export { default as AppIcon } from './AppIcon.svelte'
export { default as AppImage } from './AppImage.svelte'
export { default as AppMap } from './AppMap.svelte'
export { default as AppPdf } from './AppPdf.svelte'
export { default as AppPieChart } from './AppPieChart.svelte'
export { default as AppScatterChart } from './AppScatterChart.svelte'
export { default as AppText } from './AppText.svelte'
export { default as AppTimeseries } from './AppTimeseries.svelte'
export { default as PlotlyHtml } from './PlotlyHtml.svelte'
export { default as VegaLiteHtml } from './VegaLiteHtml.svelte'
export { default as AppMarkdown } from './AppMarkdown.svelte'
export { default as PlotlyHtmlV2 } from './PlotlyHtmlV2.svelte'
@@ -16,9 +16,10 @@
import { initCss } from '$lib/components/apps/utils'
import ResolveStyle from '../../helpers/ResolveStyle.svelte'
import AppAggridExplorerTable from './AppAggridExplorerTable.svelte'
import { DebouncedInput, type RunnableComponent } from '../..'
import { getPrimaryKeys } from '../dbtable/utils'
import InitializeComponent from '../../helpers/InitializeComponent.svelte'
import DebouncedInput from '../../helpers/DebouncedInput.svelte'
import RunnableComponent from '../../helpers/RunnableComponent.svelte'
export let id: string
export let componentInput: AppInput | undefined
@@ -7,7 +7,9 @@
import 'ag-grid-community/styles/ag-theme-alpine.css'
import AppButton from '../../buttons/AppButton.svelte'
import { AppCheckbox, AppSelect } from '../..'
import AppCheckbox from '../../inputs/AppCheckbox.svelte'
import AppSelect from '../../inputs/AppSelect.svelte'
import { twMerge } from 'tailwind-merge'
import { Popup } from '$lib/components/common'
import { Plug2 } from 'lucide-svelte'
@@ -1,5 +1,5 @@
<script lang="ts">
import { InputValue } from '.'
import InputValue from './InputValue.svelte'
import type { RichConfiguration } from '../../types'
export let id: string
@@ -1,10 +0,0 @@
export { default as AlignWrapper } from './AlignWrapper.svelte'
export { default as DebouncedInput } from './DebouncedInput.svelte'
export { default as HiddenComponent } from './HiddenComponent.svelte'
export { default as InputDefaultValue } from './InputDefaultValue.svelte'
export { default as InputValue } from './InputValue.svelte'
export { default as MissingConnectionWarning } from './MissingConnectionWarning.svelte'
export { default as NonRunnableComponent } from './NonRunnableComponent.svelte'
export { default as RefreshButton } from './RefreshButton.svelte'
export { default as RunnableComponent } from './RunnableComponent.svelte'
export { default as RunnableWrapper } from './RunnableWrapper.svelte'
@@ -1,6 +0,0 @@
export * from './buttons'
export * from './display'
export * from './inputs'
export * from './layout'
export * from './helpers'
@@ -5,9 +5,9 @@
import type { AppViewerContext, ComponentCustomCSS, RichConfiguration } from '../../types'
import { initCss } from '../../utils'
import InitializeComponent from '../helpers/InitializeComponent.svelte'
import { InputValue } from '../helpers'
import { twMerge } from 'tailwind-merge'
import ResolveStyle from '../helpers/ResolveStyle.svelte'
import InputValue from '../helpers/InputValue.svelte'
export let id: string
export let componentContainerHeight: number
@@ -9,8 +9,8 @@
import ResolveStyle from '../helpers/ResolveStyle.svelte'
// import type { EvalV2AppInput, StaticAppInput } from '../../inputType'
import { writable } from 'svelte/store'
import { InputValue } from '../helpers'
import GroupWrapper from '../GroupWrapper.svelte'
import InputValue from '../helpers/InputValue.svelte'
export let id: string
export let componentContainerHeight: number
@@ -4,7 +4,6 @@
import type { AppViewerContext, ComponentCustomCSS } from '../../types'
import { initCss } from '../../utils'
import InitializeComponent from '../helpers/InitializeComponent.svelte'
import { InputValue } from '../helpers'
import { twMerge } from 'tailwind-merge'
import ResolveStyle from '../helpers/ResolveStyle.svelte'
import type { DecisionTreeNode } from '../../editor/component'
@@ -13,6 +12,7 @@
import { initOutput } from '../../editor/appUtils'
import Badge from '$lib/components/common/badge/Badge.svelte'
import { getFirstNode, isDebugging } from '../../editor/settingsPanel/decisionTree/utils'
import InputValue from '../helpers/InputValue.svelte'
export let id: string
export let componentContainerHeight: number
@@ -6,12 +6,12 @@
import { initCss } from '../../utils'
import { Button, Drawer, DrawerContent } from '$lib/components/common'
import { twMerge } from 'tailwind-merge'
import { AlignWrapper } from '../helpers'
import { initConfig, initOutput } from '../../editor/appUtils'
import InitializeComponent from '../helpers/InitializeComponent.svelte'
import { components } from '../../editor/component'
import ResolveConfig from '../helpers/ResolveConfig.svelte'
import ResolveStyle from '../helpers/ResolveStyle.svelte'
import AlignWrapper from '../helpers/AlignWrapper.svelte'
export let customCss: ComponentCustomCSS<'drawercomponent'> | undefined = undefined
export let id: string
@@ -5,7 +5,6 @@
import { initCss } from '../../utils'
import { Button } from '$lib/components/common'
import { twMerge } from 'tailwind-merge'
import { AlignWrapper } from '../helpers'
import { initConfig, initOutput } from '../../editor/appUtils'
import InitializeComponent from '../helpers/InitializeComponent.svelte'
import Portal from 'svelte-portal'
@@ -15,6 +14,7 @@
import ResolveConfig from '../helpers/ResolveConfig.svelte'
import ResolveStyle from '../helpers/ResolveStyle.svelte'
import Disposable from '$lib/components/common/drawer/Disposable.svelte'
import AlignWrapper from '../helpers/AlignWrapper.svelte'
export let customCss: ComponentCustomCSS<'modalcomponent'> | undefined = undefined
export let id: string
@@ -6,12 +6,13 @@
import { initCss } from '../../utils'
import InitializeComponent from '../helpers/InitializeComponent.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { RunnableComponent, RunnableWrapper } from '../helpers'
import type { AppInput } from '../../inputType'
import { ArrowLeftIcon, ArrowRightIcon, Loader2 } from 'lucide-svelte'
import Stepper from '$lib/components/common/stepper/Stepper.svelte'
import { twMerge } from 'tailwind-merge'
import ResolveStyle from '../helpers/ResolveStyle.svelte'
import RunnableComponent from '../helpers/RunnableComponent.svelte'
import RunnableWrapper from '../helpers/RunnableWrapper.svelte'
export let id: string
export let componentContainerHeight: number
@@ -177,7 +177,8 @@
movingcomponents: writable(undefined),
selectedComponentInEditor: writable(undefined),
jobsDrawerOpen: writable(false),
scale
scale,
stylePanel: () => StylePanel
})
let timeout: NodeJS.Timeout | undefined = undefined
@@ -277,7 +277,6 @@
const ntriggerables: Record<string, TriggerableV2> = Object.fromEntries(
allTriggers.filter(Boolean) as [string, TriggerableV2][]
)
console.log(ntriggerables)
policy.triggerables_v2 = ntriggerables
}
@@ -389,7 +388,7 @@
let secretUrl: string | undefined = undefined
$: secretUrl == undefined && policy.execution_mode == 'anonymous' && getSecretUrl()
$: appPath != '' && secretUrl == undefined && getSecretUrl()
async function getSecretUrl() {
secretUrl = await AppService.getPublicSecretOfApp({
@@ -399,15 +398,16 @@
}
async function setPublishState() {
await computeTriggerables()
await AppService.updateApp({
workspace: $workspaceStore!,
path: appPath,
requestBody: { policy }
})
if (policy.execution_mode == 'anonymous') {
sendUserToast('App made visible publicly at the secret URL.')
sendUserToast('App require no login to be accessed')
} else {
sendUserToast('App made unaccessible publicly')
sendUserToast('App require login and read-access')
}
}
@@ -923,22 +923,26 @@
</Alert>
{:else}
<Alert title="App executed on behalf of you">
A viewer of the app will execute the runnables of the app on behalf of the publisher (you).
A viewer of the app will execute the runnables of the app on behalf of the publisher (you)
<Tooltip>
This is to ensure that all resources/runnable available at time of creating the app would
prevent the good execution of the app. To guarantee tight security, a policy is computed
at time of deployment of the app which only allow the scripts/flows referred to in the app
to be called on behalf of, and the resources are passed by reference so that their actual
value is . Furthermore, static parameters are not overridable. Hence, users will only be
able to use the app as intended by the publisher without risk for leaking resources not
used in the app.
It ensures that all required resources/runnable visible for publisher but not for viewer
at time of creating the app would prevent the execution of the app. To guarantee tight
security, a policy is computed at time of deployment of the app which only allow the
scripts/flows referred to in the app to be called on behalf of. Furthermore, static
parameters are not overridable. Hence, users will only be able to use the app as intended
by the publisher without risk for leaking resources not used in the app.
</Tooltip>
</Alert>
<div class="mt-10" />
<h2>Secret public URL</h2>
<div class="mt-4" />
<Toggle
options={{
left: `Require read-access`,
right: `Publish publicly for anyone knowing the secret url`
left: `Require login and read-access`,
right: `No login required`
}}
checked={policy.execution_mode == 'anonymous'}
on:change={(e) => {
@@ -947,11 +951,11 @@
}}
/>
{#if policy.execution_mode == 'anonymous' && secretUrl}
{@const url = `${$page.url.hostname}/public/${$workspaceStore}/${secretUrl}`}
{@const href = $page.url.protocol + '//' + url}
<div class="my-6 box">
Public url:
<div class="my-6 box">
Secret public url:
{#if secretUrl}
{@const url = `${$page.url.hostname}/public/${$workspaceStore}/${secretUrl}`}
{@const href = $page.url.protocol + '//' + url}
<a
on:click={(e) => {
e.preventDefault()
@@ -965,12 +969,16 @@
<Clipboard />
</span>
</a>
</div>
{:else}<Loader2 class="animate-spin" />
{/if}
<div class="text-xs text-secondary"
>You may share this url directly or embed it using an iframe</div
>
</div>
<Alert type="info" title="Only latest saved app is publicly available">
Once made public, you will still need to deploy the app to make visible the latest changes
</Alert>
{/if}
<Alert type="info" title="Only latest deployed app is publicly available">
You will still need to deploy the app to make visible the latest changes
</Alert>
{/if}
</DrawerContent>
</Drawer>
@@ -18,13 +18,13 @@
import Component from './component/Component.svelte'
import { twMerge } from 'tailwind-merge'
import { columnConfiguration } from '../gridUtils'
import { HiddenComponent } from '../components'
import { deepEqual } from 'fast-equals'
import { dfs, maxHeight } from './appUtils'
import { BG_PREFIX, migrateApp } from '../utils'
import { workspaceStore, enterpriseLicense } from '$lib/stores'
import DarkModeObserver from '$lib/components/DarkModeObserver.svelte'
import { getTheme } from './componentsPanel/themeUtils'
import HiddenComponent from '../components/helpers/HiddenComponent.svelte'
export let app: App
export let appPath: string = ''
@@ -19,7 +19,6 @@
import type { AppEditorContext, AppViewerContext } from '../types'
import DeleteComponent from './settingsPanel/DeleteComponent.svelte'
import { secondaryMenuLeft } from './settingsPanel/secondaryMenu'
import StylePanel from './settingsPanel/StylePanel.svelte'
import { clickOutside } from '$lib/utils'
import Portal from 'svelte-portal'
import { twMerge } from 'tailwind-merge'
@@ -60,7 +59,7 @@
let componentCallbacks: ComponentCallbacks | undefined = undefined
const { selectedComponent } = getContext<AppViewerContext>('AppViewerContext')
const { movingcomponents } = getContext<AppEditorContext>('AppEditorContext')
const { movingcomponents, stylePanel } = getContext<AppEditorContext>('AppEditorContext')
let deleteComponent: DeleteComponent | undefined = undefined
@@ -85,7 +84,7 @@
{
label: 'Show style panel',
onClick: () => {
secondaryMenuLeft?.toggle(StylePanel, { type: 'style' })
secondaryMenuLeft?.toggle(stylePanel(), { type: 'style' })
},
icon: Paintbrush2,
disabled: $secondaryMenuLeft.isOpen
@@ -8,42 +8,7 @@
import type { AppEditorContext, AppViewerContext } from '../../types'
import ComponentHeader from '../ComponentHeader.svelte'
import type { AppComponent } from './components'
import {
AppBarChart,
AppDisplayComponent,
AppTable,
AppText,
AppButton,
AppPieChart,
AppSelect,
AppCheckbox,
AppTextInput,
AppNumberInput,
AppDateInput,
AppForm,
AppScatterChart,
AppTimeseries,
AppHtml,
AppMarkdown,
AppSliderInputs,
AppFormButton,
VegaLiteHtml,
PlotlyHtml,
PlotlyHtmlV2,
AppRangeInput,
AppTabs,
AppIcon,
AppCurrencyInput,
AppDivider,
AppFileInput,
AppImage,
AppContainer,
AppAggridTable,
AppDrawer,
AppMap,
AppSplitpanes,
AppPdf
} from '../../components'
import AppMultiSelect from '../../components/inputs/AppMultiSelect.svelte'
import AppMultiSelectV2 from '../../components/inputs/AppMultiSelectV2.svelte'
import AppModal from '../../components/layout/AppModal.svelte'
@@ -76,6 +41,40 @@
import AppDateTimeInput from '../../components/inputs/AppDateTimeInput.svelte'
import AppAggridInfiniteTable from '../../components/display/table/AppAggridInfiniteTable.svelte'
import AppAggridInfiniteTableEe from '../../components/display/table/AppAggridInfiniteTableEe.svelte'
import AppDisplayComponent from '../../components/display/AppDisplayComponent.svelte'
import AppTimeseries from '../../components/display/AppTimeseries.svelte'
import AppHtml from '../../components/display/AppHtml.svelte'
import AppMarkdown from '../../components/display/AppMarkdown.svelte'
import VegaLiteHtml from '../../components/display/VegaLiteHtml.svelte'
import PlotlyHtml from '../../components/display/PlotlyHtml.svelte'
import PlotlyHtmlV2 from '../../components/display/PlotlyHtmlV2.svelte'
import AppScatterChart from '../../components/display/AppScatterChart.svelte'
import AppPieChart from '../../components/display/AppPieChart.svelte'
import AppTable from '../../components/display/table/AppTable.svelte'
import AppAggridTable from '../../components/display/table/AppAggridTable.svelte'
import AppText from '../../components/display/AppText.svelte'
import AppButton from '../../components/buttons/AppButton.svelte'
import AppForm from '../../components/buttons/AppForm.svelte'
import AppFormButton from '../../components/buttons/AppFormButton.svelte'
import AppCheckbox from '../../components/inputs/AppCheckbox.svelte'
import AppTextInput from '../../components/inputs/AppTextInput.svelte'
import AppDateInput from '../../components/inputs/AppDateInput.svelte'
import AppSelect from '../../components/inputs/AppSelect.svelte'
import AppBarChart from '../../components/display/AppBarChart.svelte'
import AppDivider from '../../components/layout/AppDivider.svelte'
import AppRangeInput from '../../components/inputs/AppRangeInput.svelte'
import AppTabs from '../../components/layout/AppTabs.svelte'
import AppContainer from '../../components/layout/AppContainer.svelte'
import AppSplitpanes from '../../components/layout/AppSplitpanes.svelte'
import AppIcon from '../../components/display/AppIcon.svelte'
import AppFileInput from '../../components/inputs/AppFileInput.svelte'
import AppImage from '../../components/display/AppImage.svelte'
import AppDrawer from '../../components/layout/AppDrawer.svelte'
import AppMap from '../../components/display/AppMap.svelte'
import AppPdf from '../../components/display/AppPdf.svelte'
import AppCurrencyInput from '../../components/inputs/currency/AppCurrencyInput.svelte'
import AppSliderInputs from '../../components/inputs/AppSliderInputs.svelte'
import AppNumberInput from '../../components/inputs/AppNumberInput.svelte'
export let component: AppComponent
export let selected: boolean
@@ -192,16 +192,20 @@
<span class="text-2xs italic text-tertiary">Field's value is set by the user</span>
{/if}
{#if (componentInput?.type === 'evalv2' || componentInput?.type === 'connected' || componentInput?.type === 'user') && fieldType == 'object' && format?.startsWith('resource-')}
<div class="flex flex-row">
<div class="flex flex-row items-center">
<Toggle
size="xs"
bind:checked={componentInput.allowUserResources}
options={{ right: 'Allow resources from users' }}
options={{
left: 'static resource select only',
right: 'resources from users allowed'
}}
/>
<Tooltip
>Apps are executed on behalf of publishers and by default cannot access viewer's
resources. If you use a non-static resource picker and connect it here, you need to toggle
this.</Tooltip
resources. If the resource passed here as a reference does not come from a static
'Resource Select' component (which will be whitelisted by the auto-generated policy), you
need to toggle this.</Tooltip
>
</div>
{/if}
@@ -288,6 +288,7 @@ export type AppEditorContext = {
movingcomponents: Writable<string[] | undefined>
jobsDrawerOpen: Writable<boolean>
scale: Writable<number>
stylePanel: () => any
}
export type FocusedGrid = { parentComponentId: string; subGridIndex: number }
@@ -1,88 +1,24 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { page } from '$app/stores'
import Github from '$lib/components/icons/brands/Github.svelte'
import Gitlab from '$lib/components/icons/brands/Gitlab.svelte'
import Google from '$lib/components/icons/brands/Google.svelte'
import Microsoft from '$lib/components/icons/brands/Microsoft.svelte'
import Okta from '$lib/components/icons/brands/Okta.svelte'
import { OauthService, UserService, WorkspaceService } from '$lib/gen'
import { UserService, WorkspaceService } from '$lib/gen'
import { usersWorkspaceStore, workspaceStore, userStore, enterpriseLicense } from '$lib/stores'
import { classNames, emptyString, parseQueryParams } from '$lib/utils'
import { getUserExt } from '$lib/user'
import { Button, Skeleton } from '$lib/components/common'
import { WindmillIcon } from '$lib/components/icons'
import { sendUserToast } from '$lib/toast'
import { isCloudHosted } from '$lib/cloud'
import { refreshSuperadmin } from '$lib/refreshUser'
import LoginPageHeader from '$lib/components/LoginPageHeader.svelte'
import DarkModeToggle from '$lib/components/sidebar/DarkModeToggle.svelte'
import { clearStores } from '$lib/storeUtils'
import { setLicense } from '$lib/enterpriseUtils'
import Login from '$lib/components/Login.svelte'
let email = $page.url.searchParams.get('email') ?? ''
let password = $page.url.searchParams.get('password') ?? ''
const email = $page.url.searchParams.get('email') ?? ''
const password = $page.url.searchParams.get('password') ?? ''
const error = $page.url.searchParams.get('error') ?? undefined
const rd = $page.url.searchParams.get('rd')
const providers = [
{
type: 'github',
name: 'GitHub',
icon: Github
},
{
type: 'gitlab',
name: 'GitLab',
icon: Gitlab
},
{
type: 'google',
name: 'Google',
icon: Google
},
{
type: 'microsoft',
name: 'Microsoft',
icon: Microsoft
},
{
type: 'okta',
name: 'Okta',
icon: Okta
}
] as const
const providersType = providers.map((p) => p.type as string)
const rd = $page.url.searchParams.get('rd') ?? undefined
let showPassword = false
let logins: string[] | undefined = undefined
let saml: string | undefined = undefined
async function login(): Promise<void> {
const requestBody = {
email,
password
}
try {
await UserService.login({ requestBody })
} catch (err) {
sendUserToast('Invalid credentials', true)
return
}
// Once logged in, we can fetch the workspaces
$usersWorkspaceStore = await WorkspaceService.listUserWorkspaces()
// trigger a reload of the user
if ($workspaceStore) {
$userStore = await getUserExt($workspaceStore)
}
// Finally, we check whether the user is a superadmin
refreshSuperadmin()
redirectUser()
}
async function redirectUser() {
const firstTimeCookie =
@@ -140,14 +76,6 @@
}
}
async function loadLogins() {
const allLogins = await OauthService.listOauthLogins()
logins = allLogins.oauth
saml = allLogins.saml
showPassword = (logins.length == 0 && !saml) || (email != undefined && email.length > 0)
}
async function redirectIfNecessary() {
await UserService.getCurrentEmail()
redirectUser()
@@ -155,35 +83,10 @@
try {
setLicense()
loadLogins()
redirectIfNecessary()
} catch {
clearStores()
}
function handleKeyUp(event: KeyboardEvent) {
const key = event.key
if (key === 'Enter') {
event.preventDefault()
login()
}
}
function storeRedirect(provider: string) {
if (rd) {
try {
localStorage.setItem('rd', rd)
} catch (e) {
console.error('Could not persist redirection to local storage', e)
}
}
goto('/api/oauth/login/' + provider)
}
$: if (error) {
sendUserToast(error, true)
}
</script>
<div
@@ -210,134 +113,6 @@
<div class="flex justify-end">
<DarkModeToggle forcedDarkMode={false} />
</div>
<div class="bg-surface px-4 py-8 shadow md:border sm:rounded-lg sm:px-10">
<div class="grid {logins && logins.length > 2 ? 'grid-cols-2' : ''} gap-4">
{#if !logins}
{#each Array(4) as _}
<Skeleton layout={[0.5, [2.375]]} />
{/each}
{:else}
{#each providers as { type, icon, name }}
{#if logins?.includes(type)}
<Button
color="light"
variant="border"
startIcon={{ icon, classes: 'h-4' }}
on:click={() => storeRedirect(type)}
>
{name}
</Button>
{/if}
{/each}
{#each logins.filter((x) => !providersType?.includes(x)) as login}
<Button
color="dark"
variant="border"
btnClasses="mt-2 w-full !border-gray-300"
on:click={() => storeRedirect(login)}
>
{login}
</Button>
{/each}
{/if}
{#if saml}
<Button
color="dark"
variant="border"
btnClasses="mt-2 w-full !border-gray-300"
on:click={() => {
if (saml) {
window.location.href = saml
} else {
sendUserToast('No SAML login available', true)
}
}}
>
SSO
</Button>
{/if}
</div>
{#if saml || (logins && logins.length > 0)}
<div class={classNames('center-center', logins && logins.length > 0 ? 'mt-6' : '')}>
<Button
size="xs"
color="blue"
variant="border"
btnClasses="!border-none"
on:click={() => {
showPassword = !showPassword
}}
>
Log in without third-party
</Button>
</div>
{/if}
{#if showPassword}
<div>
<div class="space-y-6">
{#if isCloudHosted()}
<p class="text-xs text-tertiary italic pb-6">
To get credentials without the OAuth providers above, send an email at
contact@windmill.dev
</p>
{/if}
<div>
<label for="email" class="block text-sm font-medium leading-6 text-primary">
Email
</label>
<div>
<input
type="email"
bind:value={email}
id="email"
autocomplete="email"
class="block w-full rounded-md border-0 py-1.5 text-primary shadow-sm ring-1 ring-inset placeholder:text-secondary focus:ring-2 focus:ring-inset focus:ring-frost-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div>
<label for="password" class="block text-sm font-medium leading-6 text-primary">
Password
</label>
<div>
<input
on:keyup={handleKeyUp}
bind:value={password}
id="password"
type="password"
autocomplete="current-password"
class="block w-full rounded-md border-0 py-1.5 text-shadow shadow-sm ring-1 ring-inset placeholder:text-secondary focus:ring-2 focus:ring-inset focus:ring-frost-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div class="pt-2">
<button
on:click={login}
disabled={!email || !password}
class="flex w-full justify-center rounded-md bg-frost-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-frost-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-frost-600"
>
Sign in
</button>
</div>
</div>
{#if isCloudHosted()}
<p class="text-2xs text-tertiary italic mt-10 text-center">
By logging in, you agree to our
<a href="https://windmill.dev/terms_of_service" target="_blank" rel="noreferrer">
Terms of Service
</a>
and
<a href="https://windmill.dev/privacy_policy" target="_blank" rel="noreferrer">
Privacy Policy
</a>
</p>
{/if}
</div>
{/if}
</div>
<Login {rd} {error} {password} {email} />
</div>
</div>
@@ -4,6 +4,7 @@
import AppConnectInner from '$lib/components/AppConnectInner.svelte'
import DarkModeObserver from '$lib/components/DarkModeObserver.svelte'
import { Button } from '$lib/components/common'
import { workspaceStore } from '$lib/stores'
import { onMount } from 'svelte'
let resourceType = $page.url.searchParams.get('resource_type') ?? undefined
@@ -15,6 +16,11 @@
let appConnect: AppConnectInner | undefined = undefined
let darkMode: boolean = false
const workspace = $page.url.searchParams.get('workspace')
if (workspace) {
$workspaceStore = workspace
}
onMount(async () => {
if (resourceType) {
@@ -15,10 +15,14 @@
import { writable } from 'svelte/store'
import { setLicense } from '$lib/enterpriseUtils'
import { isCloudHosted } from '$lib/cloud'
import Login from '$lib/components/Login.svelte'
import { getUserExt } from '$lib/user'
import { User, UserRoundX } from 'lucide-svelte'
import ChartHighlightTheme from '$lib/components/ChartHighlightTheme.svelte'
let app: (AppWithLastVersion & { value: any }) | undefined = undefined
let notExists = false
let noPermission = false
setContext(IS_APP_PUBLIC_CONTEXT_KEY, true)
async function loadApp() {
@@ -28,22 +32,47 @@
path: $page.params.secret
})
} catch (e) {
notExists = true
if (e.status == 401) {
noPermission = true
} else {
notExists = true
}
}
}
if (BROWSER) {
setLicense()
loadApp()
loadUser()
}
async function loadUser() {
try {
userStore.set(await getUserExt($page.params.workspace))
} catch (e) {
console.warn('Anonymous user')
}
}
const breakpoint = writable<EditorBreakpoint>('lg')
const darkMode =
window.localStorage.getItem('dark-mode') ??
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
if (darkMode === 'dark') {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
</script>
<svelte:head>
{@html github}
</svelte:head>
<ChartHighlightTheme />
<div
class="z-50 text-xs fixed bottom-1 right-2 {$enterpriseLicense && !isCloudHosted()
? 'transition-opacity delay-1000 duration-1000 opacity-20 hover:delay-0 hover:opacity-100'
@@ -54,13 +83,26 @@
>
</div>
<div class="z-50 text-2xs text-tertiary absolute top-3 left-2"
>{#if $userStore}
<div class="flex gap-1 items-center"><User size={14} />{$userStore.username}</div>
{:else}<UserRoundX size={14} />{/if}
</div>
{#if notExists}
<div class="px-4 mt-20"
><Alert type="error" title="Not found"
>There was an error loading the app. Either it does not exist at this url or its visibility
has changed to not be public anymore. <a href="/">Go to app</a>
>There was an error loading the app, is the url correct? <a href="/">Go to Windmill</a>
</Alert></div
>
{:else if noPermission}
<div class="px-4 mt-20 w-full text-center font-bold text-xl"
>{#if $userStore}You are logged in but have no read access for this app{:else}You must be logged
in and have read access for this app{/if}</div
>
<div class="px-2 mx-auto mt-20 max-w-xl w-full">
<Login rd={$page.url.toString()} />
</div>
{:else if app}
{#key app}
<div