feat: app custom paths (#4828)

* feat: app custom paths

* nit

* make ee only + fix sqlx

* fix: custom http routes auth

* nits

* fix auth + nits

* apps_ee

* move custom path to ee

* fix app jwt

* update ee ref
This commit is contained in:
HugoCasa
2024-12-04 16:50:17 +01:00
committed by GitHub
parent 4efa9c2b0a
commit 1ec6c6f765
16 changed files with 514 additions and 48 deletions
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM app WHERE custom_path = $1 AND ($2::TEXT IS NULL OR workspace_id = $2))",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "1ec97e1bf7c6edfa82b7e64585171ca897dcfa9e82618ce8f11afb08a39e3b20"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO app\n (workspace_id, path, summary, policy, versions, draft_only)\n VALUES ($1, $2, $3, $4, '{}', $5) RETURNING id",
"query": "INSERT INTO app\n (workspace_id, path, summary, policy, versions, draft_only, custom_path)\n VALUES ($1, $2, $3, $4, '{}', $5, $6) RETURNING id",
"describe": {
"columns": [
{
@@ -15,12 +15,13 @@
"Varchar",
"Varchar",
"Jsonb",
"Bool"
"Bool",
"Text"
]
},
"nullable": [
false
]
},
"hash": "75e880f9d9fbda36c2314706923cef36e4667d930fb8ee1876dd9ce1c92396b2"
"hash": "6b53f7c4bb73177316d6134698f3979f51b53dcd4d8ec50d312c9e7fe31ad5f5"
}
@@ -0,0 +1,25 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM app WHERE custom_path = $1 AND ($2::TEXT IS NULL OR workspace_id = $2) AND NOT (path = $3 AND workspace_id = $4))",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "fb1399c1dc171ec6bb24fee3477a1d606d9725e20e9c2d76a0887fadfd87f8df"
}
+1 -1
View File
@@ -1 +1 @@
aefbc1e2188fea312996fcfc30a29d8fb5315316
8606d98a692d11b09a387c5efbd6b4335c533fd3
@@ -0,0 +1,2 @@
-- Add down migration script here
ALTER TABLE app DROP COLUMN custom_path;
@@ -0,0 +1,2 @@
-- Add up migration script here
ALTER TABLE app ADD COLUMN custom_path TEXT CHECK (custom_path ~ '^[\w-]+(\/[\w-]+)*$');
+50
View File
@@ -3733,6 +3733,27 @@ paths:
required:
- app
/apps_u/public_app_by_custom_path/{custom_path}:
get:
summary: get public app by custom path
operationId: getPublicAppByCustomPath
tags:
- app
parameters:
- $ref: "#/components/parameters/CustomPath"
responses:
"200":
description: app details
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/AppWithLastVersion"
- type: object
properties:
workspace_id:
type: string
/scripts/hub/get/{path}:
get:
summary: get hub script content by path
@@ -5371,6 +5392,8 @@ paths:
type: boolean
deployment_message:
type: string
custom_path:
type: string
required:
- path
- value
@@ -5696,6 +5719,8 @@ paths:
$ref: "#/components/schemas/Policy"
deployment_message:
type: string
custom_path:
type: string
responses:
"200":
description: app updated
@@ -5704,6 +5729,23 @@ paths:
schema:
type: string
/w/{workspace}/apps/custom_path_exists/{custom_path}:
get:
summary: check if custom path exists
operationId: customPathExists
tags:
- app
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/CustomPath"
responses:
"200":
description: custom path exists
content:
application/json:
schema:
type: boolean
/w/{workspace}/apps_u/execute_component/{path}:
post:
summary: executeComponent
@@ -10222,6 +10264,12 @@ components:
required: true
schema:
type: string
CustomPath:
name: custom_path
in: path
required: true
schema:
type: string
PathId:
name: id
in: path
@@ -12860,6 +12908,8 @@ components:
draft_only:
type: boolean
draft: {}
custom_path:
type: string
AppHistory:
type: object
+93 -16
View File
@@ -56,10 +56,10 @@ use windmill_common::{
jobs::{get_payload_tag_from_prefixed_path, JobPayload, RawCode},
users::username_to_permissioned_as,
utils::{
http_get_from_hub, not_found_if_none, paginate, query_elems_from_hub, Pagination, StripPath,
http_get_from_hub, not_found_if_none, paginate, query_elems_from_hub, require_admin, Pagination, StripPath
},
variables::{build_crypt, build_crypt_with_key_suffix},
worker::to_raw_value,
worker::{to_raw_value, CLOUD_HOSTED},
HUB_BASE_URL,
};
@@ -81,6 +81,7 @@ pub fn workspaced_service() -> Router {
.route("/history/p/*path", get(get_app_history))
.route("/get_latest_version/*path", get(get_latest_version))
.route("/history_update/a/:id/v/:version", post(update_app_history))
.route("/custom_path_exists/*custom_path", get(custom_path_exists))
}
pub fn unauthed_service() -> Router {
@@ -90,13 +91,17 @@ pub fn unauthed_service() -> Router {
.route("/public_app/:secret", get(get_public_app_by_secret))
.route("/public_resource/*path", get(get_public_resource))
}
pub fn global_service() -> Router {
Router::new()
.route("/hub/list", get(list_hub_apps))
.route("/hub/get/:id", get(get_hub_app_by_id))
}
#[cfg(not(feature = "enterprise"))]
pub fn global_unauthed_service() -> Router {
Router::new()
}
#[derive(FromRow, Deserialize, Serialize)]
pub struct ListableApp {
pub id: i64,
@@ -147,21 +152,26 @@ pub struct AppWithLastVersionAndStarred {
pub starred: Option<bool>,
}
#[cfg(feature = "enterprise")]
#[derive(Serialize, FromRow)]
pub struct AppWithLastVersionAndWorkspace {
#[sqlx(flatten)]
#[serde(flatten)]
pub app: AppWithLastVersion,
pub workspace_id: String,
}
#[derive(Serialize, Deserialize, FromRow)]
pub struct AppWithLastVersionAndDraft {
pub id: i64,
pub path: String,
pub summary: String,
pub policy: sqlx::types::Json<Box<RawValue>>,
pub versions: Vec<i64>,
pub value: sqlx::types::Json<Box<RawValue>>,
pub created_by: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub extra_perms: serde_json::Value,
#[sqlx(flatten)]
#[serde(flatten)]
pub app: AppWithLastVersion,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft: Option<sqlx::types::Json<Box<RawValue>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_only: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub custom_path: Option<String>,
}
#[derive(Serialize)]
@@ -229,6 +239,7 @@ pub struct CreateApp {
pub policy: Policy,
pub draft_only: Option<bool>,
pub deployment_message: Option<String>,
pub custom_path: Option<String>,
}
#[derive(Deserialize)]
@@ -238,6 +249,7 @@ pub struct EditApp {
pub value: Option<sqlx::types::Json<Box<RawValue>>>,
pub policy: Option<Policy>,
pub deployment_message: Option<String>,
pub custom_path: Option<String>,
}
#[derive(Serialize, FromRow)]
@@ -408,7 +420,7 @@ async fn get_app_w_draft(
let app_o = sqlx::query_as::<_, AppWithLastVersionAndDraft>(
r#"SELECT app.id, app.path, app.summary, app.versions, app.policy,
app.extra_perms, app_version.value,
app.extra_perms, app_version.value, app.custom_path,
app_version.created_at, app_version.created_by,
app.draft_only, draft.value as "draft"
from app
@@ -515,6 +527,22 @@ async fn update_app_history(
return Ok(());
}
async fn custom_path_exists(
Extension(db): Extension<DB>,
Path((w_id, custom_path)): Path<(String, String)>,
) -> JsonResult<bool> {
let exists =
sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM app WHERE custom_path = $1 AND ($2::TEXT IS NULL OR workspace_id = $2))",
custom_path,
if *CLOUD_HOSTED { Some(&w_id) } else { None }
)
.fetch_one(&db)
.await?.unwrap_or(false);
Ok(Json(exists))
}
async fn get_app_by_id(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
@@ -598,6 +626,7 @@ async fn get_public_app_by_secret(
Ok(Json(app))
}
async fn get_public_resource(
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
@@ -680,6 +709,26 @@ async fn create_app(
)));
}
if let Some(custom_path) = &app.custom_path {
require_admin(authed.is_admin, &authed.username)?;
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM app WHERE custom_path = $1 AND ($2::TEXT IS NULL OR workspace_id = $2))",
custom_path,
if *CLOUD_HOSTED { Some(&w_id) } else { None }
)
.fetch_one(&mut *tx)
.await?.unwrap_or(false);
if exists {
return Err(Error::BadRequest(format!(
"App with custom path {} already exists",
custom_path
)));
}
}
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'",
&app.path,
@@ -690,13 +739,14 @@ async fn create_app(
let id = sqlx::query_scalar!(
"INSERT INTO app
(workspace_id, path, summary, policy, versions, draft_only)
VALUES ($1, $2, $3, $4, '{}', $5) RETURNING id",
(workspace_id, path, summary, policy, versions, draft_only, custom_path)
VALUES ($1, $2, $3, $4, '{}', $5, $6) RETURNING id",
w_id,
app.path,
app.summary,
json!(app.policy),
app.draft_only,
app.custom_path,
)
.fetch_one(&mut *tx)
.await?;
@@ -899,7 +949,11 @@ async fn update_app(
let mut tx = user_db.clone().begin(&authed).await?;
let npath = if ns.policy.is_some() || ns.path.is_some() || ns.summary.is_some() {
let npath = if ns.policy.is_some()
|| ns.path.is_some()
|| ns.summary.is_some()
|| ns.custom_path.is_some()
{
let mut sqlb = SqlBuilder::update_table("app");
sqlb.and_where_eq("path", "?".bind(&path));
sqlb.and_where_eq("workspace_id", "?".bind(&w_id));
@@ -932,6 +986,29 @@ async fn update_app(
sqlb.set_str("summary", nsummary);
}
if let Some(ncustom_path) = &ns.custom_path {
require_admin(authed.is_admin, &authed.username)?;
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM app WHERE custom_path = $1 AND ($2::TEXT IS NULL OR workspace_id = $2) AND NOT (path = $3 AND workspace_id = $4))",
ncustom_path,
if *CLOUD_HOSTED { Some(&w_id) } else { None },
path,
w_id
)
.fetch_one(&mut *tx)
.await?.unwrap_or(false);
if exists {
return Err(Error::BadRequest(format!(
"App with custom path {} already exists",
ncustom_path
)));
}
sqlb.set_str("custom_path", ncustom_path);
}
if let Some(mut npolicy) = ns.policy {
npolicy.on_behalf_of = Some(username_to_permissioned_as(&authed.username));
npolicy.on_behalf_of_email = Some(authed.email.clone());
+5
View File
@@ -0,0 +1,5 @@
use axum::Router;
pub fn global_unauthed_service() -> Router {
Router::new()
}
+13
View File
@@ -64,6 +64,8 @@ mod indexer_ee;
mod inputs;
mod integration;
#[cfg(feature = "enterprise")]
mod apps_ee;
#[cfg(feature = "parquet")]
mod job_helpers_ee;
pub mod job_metrics;
@@ -343,6 +345,17 @@ pub async fn run_server(
)
.nest("/concurrency_groups", concurrency_groups::global_service())
.nest("/scripts_u", scripts::global_unauthed_service())
.nest("/apps_u", {
#[cfg(feature = "enterprise")]
{
apps_ee::global_unauthed_service()
}
#[cfg(not(feature = "enterprise"))]
{
Router::new()
}
})
.nest(
"/w/:workspace_id/apps_u",
apps::unauthed_service()
@@ -68,6 +68,7 @@
summary: string
policy: any
draft_only?: boolean
custom_path?: string
}
| undefined = undefined
export let version: number | undefined = undefined
@@ -11,12 +11,11 @@
import Toggle from '$lib/components/Toggle.svelte'
import { AppService, DraftService, type Job, type Policy } from '$lib/gen'
import { redo, undo } from '$lib/history'
import { enterpriseLicense, workspaceStore } from '$lib/stores'
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import {
AlignHorizontalSpaceAround,
BellOff,
Bug,
Clipboard,
DiffIcon,
Expand,
FileJson,
@@ -39,7 +38,6 @@
import {
classNames,
cleanValueProperties,
copyToClipboard,
truncateRev,
orderedJsonStringify,
type Value,
@@ -90,6 +88,9 @@
import HideButton from './settingsPanel/HideButton.svelte'
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
import { computeS3FileInputPolicy, computeWorkspaceS3FileInputPolicy } from './appUtilsS3'
import { isCloudHosted } from '$lib/cloud'
import { base } from '$lib/base'
import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte'
async function hash(message) {
try {
@@ -119,6 +120,7 @@
summary: string
policy: any
draft_only?: boolean
custom_path?: string
}
| undefined = undefined
export let version: number | undefined = undefined
@@ -479,14 +481,16 @@
summary: $summary,
policy,
path: npath,
deployment_message: deploymentMsg
deployment_message: deploymentMsg,
custom_path: $userStore?.is_admin || $userStore?.is_super_admin ? customPath : undefined
}
})
savedApp = {
summary: $summary,
value: structuredClone($app),
path: npath,
policy
policy,
custom_path: customPath
}
const appHistory = await AppService.getAppHistoryByPath({
workspace: $workspaceStore!,
@@ -885,6 +889,37 @@
let priorDarkMode = document.documentElement.classList.contains('dark')
setTheme($app?.darkMode)
let customPath = savedApp?.custom_path
let dirtyCustomPath = false
let customPathError = ''
$: fullCustomUrl = `${window.location.origin}${base}/a/${
isCloudHosted() ? $workspaceStore + '/' : ''
}${customPath}`
async function appExists(customPath: string) {
return await AppService.customPathExists({
workspace: $workspaceStore!,
customPath
})
}
let validateTimeout: NodeJS.Timeout | undefined = undefined
async function validateCustomPath(customPath: string): Promise<void> {
customPathError = ''
if (validateTimeout) {
clearTimeout(validateTimeout)
}
validateTimeout = setTimeout(async () => {
if (!/^[\w-]+(\/[\w-]+)*$/.test(customPath)) {
customPathError = 'Invalid path'
} else if (customPath !== savedApp?.custom_path && (await appExists(customPath))) {
customPathError = 'Path already taken'
} else {
customPathError = ''
}
validateTimeout = undefined
}, 500)
}
$: customPath !== undefined && validateCustomPath(customPath)
</script>
<svelte:window on:keydown={onKeyDown} />
@@ -1071,7 +1106,7 @@
</Button>
<Button
startIcon={{ icon: Save }}
disabled={pathError != ''}
disabled={pathError != '' || customPathError != ''}
on:click={() => {
if ($appPath == '') {
createApp(newEditedPath)
@@ -1121,29 +1156,68 @@
</div>
<div class="my-6 box">
Public url:
<div class="text-secondary">
<div>Public URL</div>
</div>
{#if secretUrl}
{@const url = `${window.location.hostname}/public/${$workspaceStore}/${secretUrl}`}
{@const href = window.location.protocol + '//' + url}
<a
on:click={(e) => {
e.preventDefault()
copyToClipboard(href)
}}
{href}
class="whitespace-nowrap text-ellipsis overflow-hidden mr-1 inline-flex gap-2"
>
{url}
<span class="text-gray-700 ml-2">
<Clipboard />
</span>
</a>
{@const href = `${window.location.origin}${base}/public/${$workspaceStore}/${secretUrl}`}
<ClipboardPanel content={href} size="md" />
{:else}<Loader2 class="animate-spin" />
{/if}
<div class="text-xs text-secondary"
>Share this url directly or embed it using an iframe (if requiring login, top-level domain
of embedding app must be the same as the one of Windmill)</div
>
<div class="text-xs text-secondary mt-1">
Share this url directly or embed it using an iframe (if requiring login, top-level domain
of embedding app must be the same as the one of Windmill)
</div>
<div class="mt-4">
{#if !$enterpriseLicense}
<Alert title="EE Only" type="warning" size="xs">
Custom path is an enterprise only feature.
</Alert>
<div class="mb-2" />
{:else if !($userStore?.is_admin || $userStore?.is_super_admin)}
<Alert type="warning" title="Admin only" size="xs">
Custom path can only be set by workspace admins
</Alert>
<div class="mb-2" />
{/if}
<Toggle
on:change={({ detail }) => {
customPath = detail ? '' : undefined
}}
checked={customPath !== undefined}
options={{
right: 'Use a custom URL'
}}
disabled={!$enterpriseLicense || !($userStore?.is_admin || $userStore?.is_super_admin)}
/>
{#if customPath !== undefined}
<div class="text-secondary text-sm flex items-center gap-1 w-full justify-between">
<div>Custom path</div>
</div>
<input
disabled={!($userStore?.is_admin || $userStore?.is_super_admin)}
type="text"
autocomplete="off"
bind:value={customPath}
class={customPathError === ''
? ''
: 'border border-red-700 bg-red-100 border-opacity-30 focus:border-red-700 focus:border-opacity-30 focus-visible:ring-red-700 focus-visible:ring-opacity-25 focus-visible:border-red-700'}
on:input={() => {
dirtyCustomPath = true
}}
/>
<div class="text-secondary text-sm flex items-center gap-1 mt-2 w-full justify-between">
<div>Custom public URL</div>
</div>
<ClipboardPanel content={fullCustomUrl} size="md" />
<div class="text-red-600 dark:text-red-400 text-2xs mt-1.5"
>{dirtyCustomPath ? customPathError : ''}
</div>
{/if}
</div>
</div>
<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
@@ -1,9 +1,11 @@
<script lang="ts">
import { copyToClipboard } from '$lib/utils'
import { Clipboard } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
export let content: string
export let title: string | undefined = undefined
export let size: 'sm' | 'md' = 'sm'
</script>
{#if title !== undefined}
@@ -19,6 +21,8 @@
copyToClipboard(content)
}}
>
<div class="text-xs truncate whitespace-no-wrap grow">{content}</div>
<div class={twMerge('truncate whitespace-no-wrap grow', size === 'sm' ? 'text-xs' : 'text-sm')}
>{content}</div
>
<Clipboard size={12} class="flex-shrink-0" />
</div>
@@ -19,6 +19,7 @@
summary: string
policy: any
draft_only?: boolean
custom_path?: string
}
| undefined = undefined
let redraw = 0
@@ -58,7 +59,8 @@
path: app_w_draft_.path,
policy: app_w_draft_.policy
}
: undefined
: undefined,
custom_path: app_w_draft_.custom_path
}
if (stateLoadedFromUrl) {
+5
View File
@@ -0,0 +1,5 @@
export function load({ params }) {
return {
stuff: { title: `Public App` }
}
}
@@ -0,0 +1,182 @@
<script lang="ts">
import { BROWSER } from 'esm-env'
import { page } from '$app/stores'
import { base } from '$lib/base'
import AppPreview from '$lib/components/apps/editor/AppPreview.svelte'
import { IS_APP_PUBLIC_CONTEXT_KEY, type EditorBreakpoint } from '$lib/components/apps/types'
import { Alert, Skeleton } from '$lib/components/common'
import { WindmillIcon } from '$lib/components/icons'
import { AppService, OpenAPI, type AppWithLastVersion } from '$lib/gen'
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import { twMerge } from 'tailwind-merge'
import { setContext } from 'svelte'
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 { goto } from '$app/navigation'
import { sendUserToast } from '$lib/toast'
let app: (AppWithLastVersion & { value: any }) | undefined = undefined
let notExists = false
let noPermission = false
let jwtError = false
setContext(IS_APP_PUBLIC_CONTEXT_KEY, true)
function isJwt(t: string) {
// simply check that the first part is a valid base64 encoded json
try {
const parts = t.split('.')
const header = atob(parts[0])
JSON.parse(header)
return true
} catch (e) {
return false
}
}
function parseCustomPath(customPath: string): { path: string; jwt: string | undefined } {
const parts = customPath.split('/')
if (parts.length > 1 && isJwt(parts[parts.length - 1])) {
return {
path: parts.slice(0, -1).join('/'),
jwt: parts[parts.length - 1]
}
} else {
return {
path: customPath,
jwt: undefined
}
}
}
const parsedCustomPath = parseCustomPath($page.params.path)
async function loadApp() {
if (parsedCustomPath.jwt) {
OpenAPI.TOKEN = 'jwt_ext_' + parsedCustomPath.jwt
jwtError = false
}
try {
app = await AppService.getPublicAppByCustomPath({
customPath: parsedCustomPath.path
})
workspaceStore.set(app.workspace_id)
noPermission = false
notExists = false
try {
userStore.set(await getUserExt(app.workspace_id))
if (!$userStore && parsedCustomPath.jwt) {
jwtError = true
sendUserToast('Could not authentify user with jwt token', true)
}
} catch (e) {
console.warn('Anonymous user')
}
} catch (e) {
if (e.status == 401) {
noPermission = true
} else {
notExists = true
}
}
}
if (BROWSER) {
setLicense()
loadApp()
}
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>
<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'
: ''}"
>
<a href="https://windmill.dev" class="whitespace-nowrap text-tertiary inline-flex items-center"
>Powered by &nbsp;<WindmillIcon />&nbsp;Windmill</a
>
</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, is the url correct? <a href={base}>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 to this app{:else}You must be logged
in and have read access to this app{/if}</div
>
<div class="px-2 mx-auto mt-20 max-w-xl w-full">
{#if !jwtError}
<Login
on:login={() => {
// window.location.reload()
loadApp()
app = app
}}
popup
rd={$page.url.toString()}
/>
{/if}
</div>
{:else if app}
{#key app}
<div
class={twMerge(
'min-h-screen h-full w-full flex',
app?.value?.['css']?.['app']?.['viewer']?.class,
'wm-app-viewer'
)}
style={app?.value?.['css']?.['app']?.['viewer']?.style}
>
<AppPreview
noBackend={false}
context={{
email: $userStore?.email,
groups: $userStore?.groups,
username: $userStore?.username,
query: Object.fromEntries($page.url.searchParams.entries()),
hash: $page.url.hash.substring(1)
}}
workspace={$page.params.workspace}
summary={app.summary}
app={app.value}
appPath={app.path}
{breakpoint}
policy={app.policy}
isEditor={false}
replaceStateFn={(path) => goto(path)}
gotoFn={(path, opt) => goto(path, opt)}
/>
</div>
{/key}
{:else}
<Skeleton layout={[[4], 0.5, [50]]} />
{/if}