mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
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:
+23
@@ -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"
|
||||
}
|
||||
+4
-3
@@ -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"
|
||||
}
|
||||
+25
@@ -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 @@
|
||||
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-]+)*$');
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
use axum::Router;
|
||||
|
||||
pub fn global_unauthed_service() -> Router {
|
||||
Router::new()
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 <WindmillIcon /> 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}
|
||||
Reference in New Issue
Block a user