feat: add workspace-level service accounts (#8560)
* feat: add workspace-level service accounts (EE) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * sqlx * sqlx * chore: update ee-repo-ref Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT usr.*, password.super_admin, password.name FROM usr LEFT JOIN password ON usr.email = password.email Where usr.username = $1 AND workspace_id = $2\n ",
|
||||
"query": "SELECT usr.*, COALESCE(password.super_admin, false) as \"super_admin!\", password.name FROM usr LEFT JOIN password ON usr.email = password.email Where usr.username = $1 AND workspace_id = $2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -50,11 +50,16 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "super_admin",
|
||||
"name": "is_service_account",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "super_admin!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
@@ -76,8 +81,9 @@
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
null,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "6aabe704395c9be30c86d15a5d22f3509b4fcea56227b019588837132b64d58b"
|
||||
"hash": "1cf8597b9d37ec5a924aff8cbc0a05768ed9a679ba908ab16497a9bd55578ba1"
|
||||
}
|
||||
35
backend/.sqlx/query-544a02447bb2cbe8354a5c4ae93685848af38a3461257a9734c43cbd7bd905cb.json
generated
Normal file
35
backend/.sqlx/query-544a02447bb2cbe8354a5c4ae93685848af38a3461257a9734c43cbd7bd905cb.json
generated
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT email, is_service_account, disabled FROM usr WHERE username = $1 AND workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "is_service_account",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "disabled",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "544a02447bb2cbe8354a5c4ae93685848af38a3461257a9734c43cbd7bd905cb"
|
||||
}
|
||||
@@ -47,6 +47,11 @@
|
||||
"ordinal": 8,
|
||||
"name": "added_via",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "is_service_account",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -63,7 +68,8 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "5d6adbe21b9f8dd984d1bfc750fb81763d8650c1316bb0b20816f1a5d61a678c"
|
||||
|
||||
@@ -47,6 +47,11 @@
|
||||
"ordinal": 8,
|
||||
"name": "added_via",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "is_service_account",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -64,7 +69,8 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "60b3a59805d463a61eed68072d1ea032b00fc9bd7a6db22f530f67eb9730fa3b"
|
||||
|
||||
24
backend/.sqlx/query-68d1370fa02f4fe585684a91e898c4aed45e6b8f409bb33c2681f92265922040.json
generated
Normal file
24
backend/.sqlx/query-68d1370fa02f4fe585684a91e898c4aed45e6b8f409bb33c2681f92265922040.json
generated
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND (username = $2 OR email = $3))",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "68d1370fa02f4fe585684a91e898c4aed45e6b8f409bb33c2681f92265922040"
|
||||
}
|
||||
20
backend/.sqlx/query-a37c2c4d5656d4b44433de84c454046f7586e36b7bd6a4679d70c359d4aacfcf.json
generated
Normal file
20
backend/.sqlx/query-a37c2c4d5656d4b44433de84c454046f7586e36b7bd6a4679d70c359d4aacfcf.json
generated
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT COUNT(*) FROM usr WHERE is_service_account = true",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "a37c2c4d5656d4b44433de84c454046f7586e36b7bd6a4679d70c359d4aacfcf"
|
||||
}
|
||||
16
backend/.sqlx/query-add01e9e31d64e88b84c9505fe3de553031e581b1bb173413a9a3e3eb0817b43.json
generated
Normal file
16
backend/.sqlx/query-add01e9e31d64e88b84c9505fe3de553031e581b1bb173413a9a3e3eb0817b43.json
generated
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO usr_to_group (workspace_id, usr, group_) VALUES ($1, $2, $3)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "add01e9e31d64e88b84c9505fe3de553031e581b1bb173413a9a3e3eb0817b43"
|
||||
}
|
||||
@@ -47,6 +47,11 @@
|
||||
"ordinal": 8,
|
||||
"name": "added_via",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "is_service_account",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -63,7 +68,8 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e5fb3531f8bc7ef1f7484524f8c3bc9c48f71a44827ba0d01ac5588dc31082a2"
|
||||
|
||||
20
backend/.sqlx/query-f4ad2cf2438c2ae31e388517d09a2c1a2f63ab88cdbc79ffad96c6f9ffb5764b.json
generated
Normal file
20
backend/.sqlx/query-f4ad2cf2438c2ae31e388517d09a2c1a2f63ab88cdbc79ffad96c6f9ffb5764b.json
generated
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO token\n (token_hash, token_prefix, token, email, label, expiration, super_admin, owner)\n VALUES ($1, $2, $3, $4, $5, $6, false, $7)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Timestamptz",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f4ad2cf2438c2ae31e388517d09a2c1a2f63ab88cdbc79ffad96c6f9ffb5764b"
|
||||
}
|
||||
16
backend/.sqlx/query-f8654d5f50a80d862edbf57355502a9bd039d16f7dfb11e22d16ff9090456853.json
generated
Normal file
16
backend/.sqlx/query-f8654d5f50a80d862edbf57355502a9bd039d16f7dfb11e22d16ff9090456853.json
generated
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO usr\n (workspace_id, email, username, is_admin, operator, is_service_account)\n VALUES ($1, $2, $3, false, true, true)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f8654d5f50a80d862edbf57355502a9bd039d16f7dfb11e22d16ff9090456853"
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
5e8b1bcfc2c9ade9db39c839f2faed4f82da5efc
|
||||
208da6989ef606e4068663246903acbcaa90a9dc
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE usr DROP COLUMN is_service_account;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE usr ADD COLUMN IF NOT EXISTS is_service_account BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE magic_link ALTER COLUMN email TYPE VARCHAR(50);
|
||||
ALTER TABLE schedule ALTER COLUMN email TYPE VARCHAR(50);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE magic_link ALTER COLUMN email TYPE VARCHAR(255);
|
||||
ALTER TABLE schedule ALTER COLUMN email TYPE VARCHAR(255);
|
||||
@@ -225,7 +225,15 @@ impl AuthCache {
|
||||
t_hash,
|
||||
w_id.as_ref(),
|
||||
)
|
||||
.map(|x| (x.owner, x.email, x.super_admin, x.scopes, x.label))
|
||||
.map(|x| {
|
||||
(
|
||||
x.owner,
|
||||
x.email,
|
||||
x.super_admin,
|
||||
x.scopes,
|
||||
x.label,
|
||||
)
|
||||
})
|
||||
.fetch_optional(&self.db)
|
||||
.await
|
||||
.ok()
|
||||
@@ -234,7 +242,13 @@ impl AuthCache {
|
||||
if let Some(user) = user_o {
|
||||
let authed_o = {
|
||||
match user {
|
||||
(Some(owner), Some(email), super_admin, _, label) if w_id.is_some() => {
|
||||
(
|
||||
Some(owner),
|
||||
Some(email),
|
||||
super_admin,
|
||||
_,
|
||||
label,
|
||||
) if w_id.is_some() => {
|
||||
let username_override = username_override_from_label(label);
|
||||
if let Some((prefix, name)) = owner.split_once('/') {
|
||||
if prefix == "u" {
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
pub mod users;
|
||||
#[cfg(feature = "private")]
|
||||
pub mod users_ee;
|
||||
mod users_oss;
|
||||
|
||||
@@ -58,7 +58,7 @@ use windmill_common::{
|
||||
use windmill_common::{BASE_URL, HUB_BASE_URL};
|
||||
use windmill_git_sync::handle_deployment_metadata;
|
||||
|
||||
const COOKIE_PATH: &str = "/";
|
||||
pub const COOKIE_PATH: &str = "/";
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
@@ -75,6 +75,11 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/whoami", get(whoami))
|
||||
.route("/leave", post(leave_workspace))
|
||||
.route("/username_to_email/{username}", get(username_to_email))
|
||||
.route(
|
||||
"/impersonate_service_account",
|
||||
post(impersonate_service_account),
|
||||
)
|
||||
.route("/exit_impersonation", post(exit_impersonation))
|
||||
}
|
||||
|
||||
pub fn global_service() -> Router {
|
||||
@@ -135,6 +140,7 @@ pub struct User {
|
||||
pub role: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub added_via: Option<serde_json::Value>,
|
||||
pub is_service_account: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -176,6 +182,7 @@ pub struct UserInfo {
|
||||
pub folders: Vec<String>,
|
||||
pub folders_owners: Vec<String>,
|
||||
pub name: Option<String>,
|
||||
pub is_service_account: bool,
|
||||
}
|
||||
|
||||
#[derive(FromRow, Serialize)]
|
||||
@@ -620,8 +627,9 @@ async fn is_valid_logout_redirect(rd: &str) -> bool {
|
||||
async fn whoami(
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
ApiAuthed { username, email, is_admin, groups, folders, .. }: ApiAuthed,
|
||||
authed: ApiAuthed,
|
||||
) -> JsonResult<UserInfo> {
|
||||
let ApiAuthed { username, email, is_admin, groups, folders, .. } = authed;
|
||||
let user = get_user(&w_id, &username, &db).await?;
|
||||
if let Some(user) = user {
|
||||
Ok(Json(user))
|
||||
@@ -648,6 +656,7 @@ async fn whoami(
|
||||
.into_iter()
|
||||
.filter_map(|x| if x.2 { Some(x.0) } else { None })
|
||||
.collect(),
|
||||
is_service_account: false,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -663,11 +672,11 @@ async fn global_whoami(
|
||||
email = $1",
|
||||
email
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.fetch_optional(&db)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("fetching global identity: {e:#}")));
|
||||
.map_err(|e| Error::internal_err(format!("fetching global identity: {e:#}")))?;
|
||||
|
||||
if let Ok(user) = user {
|
||||
if let Some(user) = user {
|
||||
Ok(Json(user))
|
||||
} else if std::env::var("SUPERADMIN_SECRET").ok() == Some(token) {
|
||||
Ok(Json(GlobalUserInfo {
|
||||
@@ -685,7 +694,21 @@ async fn global_whoami(
|
||||
disabled: false,
|
||||
}))
|
||||
} else {
|
||||
Err(user.unwrap_err())
|
||||
// Service accounts don't have a password row
|
||||
Ok(Json(GlobalUserInfo {
|
||||
email: email.clone(),
|
||||
login_type: Some("service_account".to_string()),
|
||||
super_admin: false,
|
||||
devops: false,
|
||||
verified: true,
|
||||
name: None,
|
||||
company: None,
|
||||
username: None,
|
||||
operator_only: Some(true),
|
||||
first_time_user: false,
|
||||
role_source: "service_account".to_string(),
|
||||
disabled: false,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -736,12 +759,13 @@ pub struct User2 {
|
||||
pub name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub added_via: Option<serde_json::Value>,
|
||||
pub is_service_account: bool,
|
||||
}
|
||||
|
||||
async fn get_user(w_id: &str, username: &str, db: &DB) -> Result<Option<UserInfo>> {
|
||||
let user = sqlx::query_as!(
|
||||
User2,
|
||||
"SELECT usr.*, password.super_admin, password.name FROM usr LEFT JOIN password ON usr.email = password.email Where usr.username = $1 AND workspace_id = $2
|
||||
"SELECT usr.*, COALESCE(password.super_admin, false) as \"super_admin!\", password.name FROM usr LEFT JOIN password ON usr.email = password.email Where usr.username = $1 AND workspace_id = $2
|
||||
",
|
||||
username,
|
||||
w_id
|
||||
@@ -782,6 +806,7 @@ async fn get_user(w_id: &str, username: &str, db: &DB) -> Result<Option<UserInfo
|
||||
.into_iter()
|
||||
.filter_map(|x| if x.2 { Some(x.0) } else { None })
|
||||
.collect(),
|
||||
is_service_account: usr.is_service_account,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -2025,6 +2050,44 @@ async fn impersonate(
|
||||
Ok((StatusCode::CREATED, token))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ImpersonateServiceAccountRequest {
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
async fn impersonate_service_account(
|
||||
Extension(db): Extension<DB>,
|
||||
authed: ApiAuthed,
|
||||
cookies: Cookies,
|
||||
Tokened { token: current_token }: Tokened,
|
||||
Path(w_id): Path<String>,
|
||||
Json(req): Json<ImpersonateServiceAccountRequest>,
|
||||
) -> Result<(StatusCode, String)> {
|
||||
crate::users_oss::impersonate_service_account(db, authed, cookies, current_token, w_id, req)
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ExitImpersonationRequest {
|
||||
token: String,
|
||||
}
|
||||
|
||||
async fn exit_impersonation(
|
||||
cookies: Cookies,
|
||||
Json(req): Json<ExitImpersonationRequest>,
|
||||
) -> Result<String> {
|
||||
let mut cookie = tower_cookies::Cookie::new(COOKIE_NAME, req.token);
|
||||
cookie.set_secure(IS_SECURE.read().await.clone());
|
||||
cookie.set_same_site(Some(tower_cookies::cookie::SameSite::Lax));
|
||||
cookie.set_http_only(true);
|
||||
cookie.set_path(COOKIE_PATH);
|
||||
if COOKIE_DOMAIN.is_some() {
|
||||
cookie.set_domain(COOKIE_DOMAIN.clone().unwrap());
|
||||
}
|
||||
cookies.add(cookie);
|
||||
Ok("exited impersonation".to_string())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ListTokenQuery {
|
||||
exclude_ephemeral: Option<bool>,
|
||||
|
||||
28
backend/windmill-api-users/src/users_oss.rs
Normal file
28
backend/windmill-api-users/src/users_oss.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
#[cfg(feature = "private")]
|
||||
#[allow(unused)]
|
||||
pub use crate::users_ee::*;
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
use crate::users::ImpersonateServiceAccountRequest;
|
||||
#[cfg(not(feature = "private"))]
|
||||
use http::StatusCode;
|
||||
#[cfg(not(feature = "private"))]
|
||||
use tower_cookies::Cookies;
|
||||
#[cfg(not(feature = "private"))]
|
||||
use windmill_api_auth::ApiAuthed;
|
||||
#[cfg(not(feature = "private"))]
|
||||
use windmill_common::DB;
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub async fn impersonate_service_account(
|
||||
_db: DB,
|
||||
_authed: ApiAuthed,
|
||||
_cookies: Cookies,
|
||||
_current_token: String,
|
||||
_w_id: String,
|
||||
_req: ImpersonateServiceAccountRequest,
|
||||
) -> windmill_common::error::Result<(StatusCode, String)> {
|
||||
Err(windmill_common::error::Error::BadRequest(
|
||||
"Service accounts require Windmill Enterprise Edition".to_string(),
|
||||
))
|
||||
}
|
||||
@@ -75,6 +75,7 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/archive", post(archive_workspace))
|
||||
.route("/invite_user", post(invite_user))
|
||||
.route("/add_user", post(add_user))
|
||||
.route("/create_service_account", post(create_service_account))
|
||||
.route("/delete_invite", post(delete_invite))
|
||||
.route("/rebuild_dependency_map", post(rebuild_dependency_map))
|
||||
.route("/get_dependency_map", get(get_dependency_map))
|
||||
@@ -4232,6 +4233,20 @@ If you do not have an account on {}, login with SSO or ask an admin to create an
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct NewServiceAccount {
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
async fn create_service_account(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Json(nu): Json<NewServiceAccount>,
|
||||
) -> Result<(StatusCode, String)> {
|
||||
crate::workspaces_oss::create_service_account(authed, db, w_id, nu).await
|
||||
}
|
||||
|
||||
async fn delete_invite(
|
||||
ApiAuthed { username, is_admin, .. }: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
pub use crate::workspaces_ee::*;
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
use crate::workspaces::EditAutoInvite;
|
||||
use crate::workspaces::{EditAutoInvite, NewServiceAccount};
|
||||
#[cfg(not(feature = "private"))]
|
||||
use http::StatusCode;
|
||||
#[cfg(not(feature = "private"))]
|
||||
use windmill_api_auth::ApiAuthed;
|
||||
#[cfg(not(feature = "private"))]
|
||||
@@ -20,3 +22,15 @@ pub async fn edit_auto_invite(
|
||||
"Not implemented on OSS".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub async fn create_service_account(
|
||||
_authed: ApiAuthed,
|
||||
_db: DB,
|
||||
_w_id: String,
|
||||
_nu: NewServiceAccount,
|
||||
) -> windmill_common::error::Result<(StatusCode, String)> {
|
||||
Err(windmill_common::error::Error::BadRequest(
|
||||
"Service accounts require Windmill Enterprise Edition".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -2125,6 +2125,87 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/create_service_account:
|
||||
post:
|
||||
summary: create a service account
|
||||
operationId: createServiceAccount
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
required:
|
||||
- username
|
||||
responses:
|
||||
"201":
|
||||
description: service account created
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/users/impersonate_service_account:
|
||||
post:
|
||||
summary: impersonate a service account
|
||||
operationId: impersonateServiceAccount
|
||||
tags:
|
||||
- user
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
required:
|
||||
- username
|
||||
responses:
|
||||
"201":
|
||||
description: impersonation token
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/users/exit_impersonation:
|
||||
post:
|
||||
summary: exit service account impersonation
|
||||
operationId: exitImpersonation
|
||||
tags:
|
||||
- user
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
required:
|
||||
- token
|
||||
responses:
|
||||
"200":
|
||||
description: exited impersonation
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/delete_invite:
|
||||
post:
|
||||
summary: delete user invite
|
||||
@@ -20123,6 +20204,8 @@ components:
|
||||
nullable: true
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/UserSource"
|
||||
is_service_account:
|
||||
type: boolean
|
||||
required:
|
||||
- email
|
||||
- username
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { globalEmailInvite, superadmin, workspaceStore } from '$lib/stores'
|
||||
import { globalEmailInvite, superadmin, workspaceStore, enterpriseLicense } from '$lib/stores'
|
||||
import { SettingService, UserService, WorkspaceService } from '$lib/gen'
|
||||
import { Button } from './common'
|
||||
import Popover from './meltComponents/Popover.svelte'
|
||||
@@ -10,6 +10,7 @@
|
||||
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
|
||||
import { UserPlus } from 'lucide-svelte'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
@@ -32,45 +33,55 @@
|
||||
getAutomateUsernameCreationSetting()
|
||||
|
||||
async function addUser() {
|
||||
await WorkspaceService.addUser({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
email: email!,
|
||||
username: automateUsernameCreation ? undefined : username,
|
||||
is_admin: selected == 'admin',
|
||||
operator: selected == 'operator'
|
||||
}
|
||||
})
|
||||
sendUserToast(`Added ${email}`)
|
||||
if (!(await UserService.existsEmail({ email: email! }))) {
|
||||
let isSuperadmin = $superadmin
|
||||
if (!isCloudHosted()) {
|
||||
const emailCopy = email!
|
||||
sendUserToast(
|
||||
`User ${email} is not registered yet on the instance. ${
|
||||
!isSuperadmin
|
||||
? `If not using SSO, ask an administrator to add ${email} to the instance`
|
||||
: ''
|
||||
}`,
|
||||
true,
|
||||
isSuperadmin
|
||||
? [
|
||||
{
|
||||
label: 'Add user to the instance',
|
||||
callback: () => {
|
||||
$globalEmailInvite = emailCopy
|
||||
goto('#superadmin-settings')
|
||||
if (selected === 'service_account') {
|
||||
if (!username) return
|
||||
await WorkspaceService.createServiceAccount({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: { username: username! }
|
||||
})
|
||||
sendUserToast(`Service account '${username}' created`)
|
||||
} else {
|
||||
await WorkspaceService.addUser({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
email: email!,
|
||||
username: automateUsernameCreation ? undefined : username,
|
||||
is_admin: selected == 'admin',
|
||||
operator: selected == 'operator'
|
||||
}
|
||||
})
|
||||
sendUserToast(`Added ${email}`)
|
||||
if (!(await UserService.existsEmail({ email: email! }))) {
|
||||
let isSuperadmin = $superadmin
|
||||
if (!isCloudHosted()) {
|
||||
const emailCopy = email!
|
||||
sendUserToast(
|
||||
`User ${email} is not registered yet on the instance. ${
|
||||
!isSuperadmin
|
||||
? `If not using SSO, ask an administrator to add ${email} to the instance`
|
||||
: ''
|
||||
}`,
|
||||
true,
|
||||
isSuperadmin
|
||||
? [
|
||||
{
|
||||
label: 'Add user to the instance',
|
||||
callback: () => {
|
||||
$globalEmailInvite = emailCopy
|
||||
goto('#superadmin-settings')
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
: []
|
||||
)
|
||||
]
|
||||
: []
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
dispatch('new')
|
||||
}
|
||||
|
||||
let selected: 'operator' | 'developer' | 'admin' = $state('developer')
|
||||
let selected: 'operator' | 'developer' | 'admin' | 'service_account' = $state('developer')
|
||||
let isServiceAccount = $derived(selected === 'service_account')
|
||||
</script>
|
||||
|
||||
<Popover placement="bottom-end">
|
||||
@@ -80,15 +91,27 @@
|
||||
</Button>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<div class="flex flex-col w-72 p-4">
|
||||
<div class="flex flex-col w-[28rem] p-4">
|
||||
<span class="text-sm mb-2 leading-6 font-semibold">Add a new user</span>
|
||||
|
||||
<span class="text-xs mb-1 leading-6">Email</span>
|
||||
<input type="email mb-1" onkeyup={handleKeyUp} placeholder="email" bind:value={email} />
|
||||
{#if isServiceAccount}
|
||||
<span class="text-xs mb-1 leading-6">Username</span>
|
||||
<input
|
||||
type="text"
|
||||
onkeyup={handleKeyUp}
|
||||
placeholder="my_service_account"
|
||||
autocomplete="off"
|
||||
data-1p-ignore
|
||||
bind:value={username}
|
||||
/>
|
||||
{:else}
|
||||
<span class="text-xs mb-1 leading-6">Email</span>
|
||||
<input type="email mb-1" onkeyup={handleKeyUp} placeholder="email" bind:value={email} />
|
||||
|
||||
{#if !automateUsernameCreation}
|
||||
<span class="text-xs mb-1 pt-2 leading-6">Username</span>
|
||||
<input type="text" onkeyup={handleKeyUp} placeholder="username" bind:value={username} />
|
||||
{#if !automateUsernameCreation}
|
||||
<span class="text-xs mb-1 pt-2 leading-6">Username</span>
|
||||
<input type="text" onkeyup={handleKeyUp} placeholder="username" bind:value={username} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<span class="text-xs mb-1 pt-6 leading-6">Role</span>
|
||||
@@ -112,6 +135,13 @@
|
||||
tooltip="An admin has full control over a specific Windmill workspace, including the ability to manage users, edit entities, and control permissions within the workspace."
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
value="service_account"
|
||||
label={$enterpriseLicense ? 'Service Account' : 'Service Account (EE)'}
|
||||
tooltip="A service account is a workspace-scoped identity for automation. It cannot log in directly and can be impersonated by admins."
|
||||
disabled={!$enterpriseLicense}
|
||||
{item}
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
<Button
|
||||
@@ -125,7 +155,9 @@
|
||||
username = undefined
|
||||
})
|
||||
}}
|
||||
disabled={email === undefined || (!automateUsernameCreation && username === undefined)}
|
||||
disabled={isServiceAccount
|
||||
? username === undefined || username === ''
|
||||
: email === undefined || (!automateUsernameCreation && username === undefined)}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
|
||||
@@ -14,9 +14,15 @@
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import type { CancelablePromise, User, UserUsage } from '$lib/gen'
|
||||
import { UserService, WorkspaceService, GroupService, type WorkspaceInvite } from '$lib/gen'
|
||||
import { userStore, workspaceStore, superadmin, globalEmailInvite } from '$lib/stores'
|
||||
import {
|
||||
userStore,
|
||||
workspaceStore,
|
||||
superadmin,
|
||||
globalEmailInvite,
|
||||
enterpriseLicense
|
||||
} from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { Loader2, Mails, Search, Plus, UserMinus, X } from 'lucide-svelte'
|
||||
import { Loader2, Mails, Search, Plus, UserMinus, X, Bot, LogIn } from 'lucide-svelte'
|
||||
import Select from '$lib/components/select/Select.svelte'
|
||||
import SearchItems from '../SearchItems.svelte'
|
||||
import Cell from '../table/Cell.svelte'
|
||||
@@ -45,6 +51,8 @@
|
||||
let selectedNewInstanceGroup: string | undefined = $state(undefined)
|
||||
let selectedNewRole: string | undefined = $state('developer')
|
||||
|
||||
// Service account creation
|
||||
|
||||
// Available groups for dropdowns - filter out already configured groups
|
||||
let availableGroupItems = $derived(
|
||||
instanceGroups
|
||||
@@ -488,12 +496,14 @@
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton
|
||||
value="operator"
|
||||
small
|
||||
label="Operator"
|
||||
tooltip="An operator can only execute and view scripts/flows/apps from your workspace, and only those that he has visibility on."
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
value="developer"
|
||||
small
|
||||
label="Developer"
|
||||
tooltip="A Developer can execute and view scripts/flows/apps, but they can also create new ones and edit those they are allowed to by their path (either u/ or Writer or Admin of their folder found at /f)."
|
||||
{item}
|
||||
@@ -595,18 +605,21 @@
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton
|
||||
value="operator"
|
||||
small
|
||||
label="Operator"
|
||||
tooltip="An operator can only execute and view scripts/flows/apps from your workspace, and only those that he has visibility on."
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
value="developer"
|
||||
small
|
||||
label="Developer"
|
||||
tooltip="A Developer can execute and view scripts/flows/apps, but they can also create new ones and edit those they are allowed to by their path (either u/ or Writer or Admin of their folder found at /f)."
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
value="admin"
|
||||
small
|
||||
label="Admin"
|
||||
tooltip="An admin has full control over a specific Windmill workspace, including the ability to manage users, edit entities, and control permissions within the workspace."
|
||||
{item}
|
||||
@@ -663,18 +676,21 @@
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton
|
||||
value="operator"
|
||||
small
|
||||
label="Operator"
|
||||
tooltip="An operator can only execute and view scripts/flows/apps from your workspace, and only those that he has visibility on."
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
value="developer"
|
||||
small
|
||||
label="Developer"
|
||||
tooltip="A Developer can execute and view scripts/flows/apps, but they can also create new ones and edit those they are allowed to by their path (either u/ or Writer or Admin of their folder found at /f)."
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
value="admin"
|
||||
small
|
||||
label="Admin"
|
||||
tooltip="An admin has full control over a specific Windmill workspace, including the ability to manage users, edit entities, and control permissions within the workspace."
|
||||
{item}
|
||||
@@ -779,8 +795,21 @@
|
||||
</tr>
|
||||
{/if}
|
||||
<tr class={index % 2 === 0 ? 'bg-surface-tertiary' : 'bg-surface'}>
|
||||
<Cell first><a href="mailto:{email}">{truncate(email, 20)}</a></Cell>
|
||||
<Cell>{truncate(username, 30)}</Cell>
|
||||
<Cell first>
|
||||
{#if user.is_service_account}
|
||||
<span class="flex items-center gap-1.5 max-w-[150px]" title={email}>
|
||||
<Bot size={16} class="text-blue-500 shrink-0" />
|
||||
<span class="truncate">{email}</span>
|
||||
</span>
|
||||
{:else}
|
||||
<a href="mailto:{email}" class="block truncate max-w-[150px]" title={email}
|
||||
>{email}</a
|
||||
>
|
||||
{/if}
|
||||
</Cell>
|
||||
<Cell
|
||||
><span class="block truncate max-w-[120px]" title={username}>{username}</span></Cell
|
||||
>
|
||||
{#if hasNonManualUsers}
|
||||
<Cell>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -796,14 +825,21 @@
|
||||
</Cell>
|
||||
{/if}
|
||||
<Cell
|
||||
>{#if usage?.[email] != undefined}{usage?.[email]}{:else}<Loader2
|
||||
>{#if usage != undefined}{usage[email] ?? 0}{:else}<Loader2
|
||||
size={14}
|
||||
class="animate-spin"
|
||||
/>{/if}</Cell
|
||||
>
|
||||
<Cell>
|
||||
<div>
|
||||
{#if added_via?.source === 'instance_group'}
|
||||
{#if user.is_service_account}
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="rounded-md text-xs px-2 py-1 bg-surface shadow-md font-bold">
|
||||
Operator
|
||||
</span>
|
||||
<Tooltip>Service accounts are always operators.</Tooltip>
|
||||
</div>
|
||||
{:else if added_via?.source === 'instance_group'}
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="rounded-md text-xs px-2 py-1 bg-surface shadow-md font-bold">
|
||||
{is_admin ? 'Admin' : operator ? 'Operator' : 'Developer'}
|
||||
@@ -840,6 +876,7 @@
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton
|
||||
value="operator"
|
||||
small
|
||||
label="Operator"
|
||||
tooltip="An operator can only execute and view scripts/flows/apps from your workspace, and only those that he has visibility on."
|
||||
{item}
|
||||
@@ -847,6 +884,7 @@
|
||||
|
||||
<ToggleButton
|
||||
value="developer"
|
||||
small
|
||||
label="Developer"
|
||||
tooltip="A Developer can execute and view scripts/flows/apps, but they can also create new ones and edit those they are allowed to by their path (either u/ or Writer or Admin of their folder found at /f)."
|
||||
{item}
|
||||
@@ -854,6 +892,7 @@
|
||||
|
||||
<ToggleButton
|
||||
value="admin"
|
||||
small
|
||||
label="Admin"
|
||||
tooltip="An admin has full control over a specific Windmill workspace, including the ability to manage users, edit entities, and control permissions within the workspace."
|
||||
{item}
|
||||
@@ -887,6 +926,32 @@
|
||||
</Cell>
|
||||
<Cell>
|
||||
<div class="flex gap-1">
|
||||
{#if user.is_service_account && $userStore?.is_admin}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
startIcon={{ icon: LogIn }}
|
||||
disabled={!$enterpriseLicense}
|
||||
title={!$enterpriseLicense ? 'Requires Enterprise Edition' : undefined}
|
||||
onClick={async () => {
|
||||
try {
|
||||
// Backend sets the impersonation cookie and returns the old token
|
||||
const oldToken = await UserService.impersonateServiceAccount({
|
||||
workspace: $workspaceStore ?? '',
|
||||
requestBody: { username }
|
||||
})
|
||||
if (oldToken) {
|
||||
sessionStorage.setItem('pre_impersonation_token', oldToken)
|
||||
}
|
||||
window.location.href = '/'
|
||||
} catch (e) {
|
||||
sendUserToast('Failed to impersonate service account', true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Impersonate
|
||||
</Button>
|
||||
{/if}
|
||||
{#snippet removeUserButton(disabled: boolean)}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
@@ -1005,6 +1070,7 @@
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton
|
||||
value="operator"
|
||||
small
|
||||
label="Operator"
|
||||
tooltip="An operator can only execute and view scripts/flows/apps from your workspace, and only those that he has visibility on."
|
||||
{item}
|
||||
@@ -1012,6 +1078,7 @@
|
||||
|
||||
<ToggleButton
|
||||
value="developer"
|
||||
small
|
||||
label="Developer"
|
||||
tooltip="A Developer can execute and view scripts/flows/apps, but they can also create new ones and edit those they are allowed to by their path (either u/ or Writer or Admin of their folder found at /f)."
|
||||
{item}
|
||||
@@ -1019,6 +1086,7 @@
|
||||
|
||||
<ToggleButton
|
||||
value="admin"
|
||||
small
|
||||
label="Admin"
|
||||
tooltip="An admin has full control over a specific Windmill workspace, including the ability to manage users, edit entities, and control permissions within the workspace."
|
||||
{item}
|
||||
|
||||
@@ -18,10 +18,12 @@
|
||||
import { base } from '$lib/base'
|
||||
|
||||
import MultiplayerMenu from './MultiplayerMenu.svelte'
|
||||
import { Plus } from 'lucide-svelte'
|
||||
import {
|
||||
clearWorkspaceFromStorage,
|
||||
enterpriseLicense,
|
||||
superadmin,
|
||||
usedTriggerKinds,
|
||||
userWorkspaces,
|
||||
workspaceStore,
|
||||
tutorialsToDo,
|
||||
@@ -35,9 +37,10 @@
|
||||
import { Menu, Menubar, MenuItem } from '$lib/components/meltComponents'
|
||||
import MenuButton, { sidebarClasses } from './MenuButton.svelte'
|
||||
import MenuLink from './MenuLink.svelte'
|
||||
import ResizeTransitionWrapper from '../common/ResizeTransitionWrapper.svelte'
|
||||
import type { FavoriteKind } from './FavoriteMenu.svelte'
|
||||
let darkMode: boolean = $state(false)
|
||||
let showExtraTriggers = $state(false)
|
||||
let menubarEl: HTMLElement | undefined = $state()
|
||||
|
||||
interface Props {
|
||||
isCollapsed?: boolean
|
||||
@@ -114,55 +117,6 @@
|
||||
label: 'Workers',
|
||||
id: 'workers',
|
||||
href: `${base}/workers`
|
||||
}
|
||||
].filter(filterLink)
|
||||
)
|
||||
let secondMenuTriggerLinks = $derived(
|
||||
[
|
||||
{
|
||||
label: 'Custom HTTP routes',
|
||||
id: 'triggers',
|
||||
href: `${base}/routes`
|
||||
},
|
||||
{
|
||||
label: 'Websocket triggers',
|
||||
id: 'triggers',
|
||||
href: `${base}/websocket_triggers`
|
||||
},
|
||||
{
|
||||
label: 'Postgres triggers',
|
||||
id: 'triggers',
|
||||
href: `${base}/postgres_triggers`
|
||||
},
|
||||
{
|
||||
label: 'Kafka triggers',
|
||||
id: 'triggers',
|
||||
href: `${base}/kafka_triggers`
|
||||
},
|
||||
{
|
||||
label: 'NATS triggers',
|
||||
id: 'triggers',
|
||||
href: `${base}/nats_triggers`
|
||||
},
|
||||
{
|
||||
label: 'SQS triggers',
|
||||
id: 'triggers',
|
||||
href: `${base}/sqs_triggers`
|
||||
},
|
||||
{
|
||||
label: 'GCP Pub/Sub triggers',
|
||||
id: 'triggers',
|
||||
href: `${base}/gcp_triggers`
|
||||
},
|
||||
{
|
||||
label: 'MQTT triggers',
|
||||
id: 'triggers',
|
||||
href: `${base}/mqtt_triggers`
|
||||
},
|
||||
{
|
||||
label: 'Email triggers',
|
||||
id: 'triggers',
|
||||
href: `${base}/email_triggers`
|
||||
},
|
||||
{
|
||||
label: 'Audit logs',
|
||||
@@ -171,178 +125,250 @@
|
||||
}
|
||||
].filter(filterLink)
|
||||
)
|
||||
let showMore = $state(false)
|
||||
type TriggerMenuLink = SecondMenuLink & { kind: string }
|
||||
let allTriggerLinks: TriggerMenuLink[] = $derived(
|
||||
(
|
||||
[
|
||||
{ label: 'Custom HTTP routes', id: 'triggers', href: `${base}/routes`, kind: 'http' },
|
||||
{
|
||||
label: 'Websocket triggers',
|
||||
id: 'triggers',
|
||||
href: `${base}/websocket_triggers`,
|
||||
kind: 'ws'
|
||||
},
|
||||
{
|
||||
label: 'Postgres triggers',
|
||||
id: 'triggers',
|
||||
href: `${base}/postgres_triggers`,
|
||||
kind: 'postgres'
|
||||
},
|
||||
{ label: 'Kafka triggers', id: 'triggers', href: `${base}/kafka_triggers`, kind: 'kafka' },
|
||||
{ label: 'NATS triggers', id: 'triggers', href: `${base}/nats_triggers`, kind: 'nats' },
|
||||
{ label: 'SQS triggers', id: 'triggers', href: `${base}/sqs_triggers`, kind: 'sqs' },
|
||||
{
|
||||
label: 'GCP Pub/Sub triggers',
|
||||
id: 'triggers',
|
||||
href: `${base}/gcp_triggers`,
|
||||
kind: 'gcp'
|
||||
},
|
||||
{ label: 'MQTT triggers', id: 'triggers', href: `${base}/mqtt_triggers`, kind: 'mqtt' },
|
||||
{ label: 'Email triggers', id: 'triggers', href: `${base}/email_triggers`, kind: 'email' }
|
||||
] as TriggerMenuLink[]
|
||||
).filter(filterLink)
|
||||
)
|
||||
let secondMenuTriggerLinks = $derived(
|
||||
allTriggerLinks.filter((link) => $usedTriggerKinds.includes(link.kind))
|
||||
)
|
||||
let extraTriggerLinks = $derived(
|
||||
allTriggerLinks.filter((link) => !$usedTriggerKinds.includes(link.kind))
|
||||
)
|
||||
</script>
|
||||
|
||||
<Menubar>
|
||||
{#snippet children({ createMenu })}
|
||||
<Menu {createMenu} usePointerDownOutside on:close={() => (showMore = false)}>
|
||||
{#snippet triggr({ trigger })}
|
||||
<MenuButton
|
||||
class="!text-xs"
|
||||
icon={MenuIcon}
|
||||
{isCollapsed}
|
||||
lightMode
|
||||
label={undefined}
|
||||
{trigger}
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet children({ item })}
|
||||
<div class="w-full max-w-full">
|
||||
{#each favoriteLinks ?? [] as favorite (favorite.href)}
|
||||
<MenuItem
|
||||
href={favorite.href}
|
||||
{item}
|
||||
class={twMerge(
|
||||
'w-full inline-flex flex-row px-2 py-2 hover:bg-surface-hover',
|
||||
'data-[highlighted]:bg-surface-hover'
|
||||
)}
|
||||
>
|
||||
<span class="center-center">
|
||||
{#if favorite.kind == 'script'}
|
||||
<Code2 size={16} />
|
||||
{:else if favorite.kind == 'flow'}
|
||||
<BarsStaggered size={16} />
|
||||
{:else if favorite.kind == 'app' || favorite.kind == 'raw_app'}
|
||||
<LayoutDashboard size={16} />
|
||||
{:else if favorite.kind == 'asset'}
|
||||
<Table2 size={16} />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="text-primary ml-2 grow min-w-0 text-xs truncate">
|
||||
{favorite.label}
|
||||
</span>
|
||||
</MenuItem>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#each mainMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
|
||||
<MenuLink class="!text-xs" {...menuLink} {isCollapsed} {item} lightMode />
|
||||
{/each}
|
||||
|
||||
<div class="divide-y" role="none">
|
||||
<div role="none">
|
||||
<MenuItem
|
||||
href={USER_SETTINGS_HASH}
|
||||
class={twMerge(
|
||||
'flex flex-row gap-3.5 items-center px-2 py-2',
|
||||
sidebarClasses.text,
|
||||
sidebarClasses.hoverBg
|
||||
)}
|
||||
lightMode
|
||||
{item}
|
||||
>
|
||||
<Settings size={14} />
|
||||
Account settings
|
||||
</MenuItem>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={menubarEl}
|
||||
onmouseenter={() => {
|
||||
const btn = menubarEl?.querySelector('[data-melt-menubar-trigger]')
|
||||
if (btn instanceof HTMLElement) btn.click()
|
||||
}}
|
||||
>
|
||||
<Menubar>
|
||||
{#snippet children({ createMenu })}
|
||||
<Menu {createMenu} usePointerDownOutside on:close={() => (showExtraTriggers = false)}>
|
||||
{#snippet triggr({ trigger })}
|
||||
<MenuButton
|
||||
class="!text-xs bg-surface !pl-3.5 !pr-2 !w-auto"
|
||||
icon={MenuIcon}
|
||||
isCollapsed={false}
|
||||
lightMode
|
||||
label={undefined}
|
||||
{trigger}
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet children({ item })}
|
||||
<div class="w-full max-w-full">
|
||||
{#each favoriteLinks ?? [] as favorite (favorite.href)}
|
||||
<MenuItem
|
||||
href={favorite.href}
|
||||
{item}
|
||||
class={twMerge(
|
||||
'w-full inline-flex flex-row px-2 py-2 hover:bg-surface-hover',
|
||||
'data-[highlighted]:bg-surface-hover'
|
||||
)}
|
||||
>
|
||||
<span class="center-center">
|
||||
{#if favorite.kind == 'script'}
|
||||
<Code2 size={16} />
|
||||
{:else if favorite.kind == 'flow'}
|
||||
<BarsStaggered size={16} />
|
||||
{:else if favorite.kind == 'app' || favorite.kind == 'raw_app'}
|
||||
<LayoutDashboard size={16} />
|
||||
{:else if favorite.kind == 'asset'}
|
||||
<Table2 size={16} />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="text-primary ml-2 grow min-w-0 text-xs truncate">
|
||||
{favorite.label}
|
||||
</span>
|
||||
</MenuItem>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div role="none">
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
if (!document.documentElement.classList.contains('dark')) {
|
||||
document.documentElement.classList.add('dark')
|
||||
window.localStorage.setItem('dark-mode', 'dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
window.localStorage.setItem('dark-mode', 'light')
|
||||
}
|
||||
}}
|
||||
lightMode
|
||||
class={twMerge(
|
||||
'w-full flex gap-3.5 px-2 py-2',
|
||||
sidebarClasses.hoverBg,
|
||||
sidebarClasses.text
|
||||
)}
|
||||
{item}
|
||||
>
|
||||
{#if darkMode}
|
||||
<Sun size={14} />
|
||||
{:else}
|
||||
<Moon size={14} />
|
||||
{/if}
|
||||
Switch theme
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
href="{base}/user/workspaces"
|
||||
onClick={() => clearWorkspaceFromStorage()}
|
||||
lightMode
|
||||
class={twMerge('flex gap-3.5 px-2 py-2', sidebarClasses.hoverBg, sidebarClasses.text)}
|
||||
{item}
|
||||
>
|
||||
<Building size={14} />
|
||||
All workspaces
|
||||
</MenuItem>
|
||||
{#each mainMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
|
||||
<MenuLink class="!text-xs" {...menuLink} {isCollapsed} {item} lightMode />
|
||||
{/each}
|
||||
|
||||
{#if $superadmin}
|
||||
<div class="divide-y" role="none">
|
||||
<div role="none">
|
||||
<MenuItem
|
||||
href="#superadmin-settings"
|
||||
href={USER_SETTINGS_HASH}
|
||||
class={twMerge(
|
||||
'flex flex-row gap-3.5 items-center px-2 py-2 ',
|
||||
'text-secondary text-xs',
|
||||
'hover:bg-surface-hover hover:text-primary cursor-pointer',
|
||||
'flex flex-row gap-3.5 items-center px-2 py-2',
|
||||
sidebarClasses.text,
|
||||
sidebarClasses.hoverBg
|
||||
)}
|
||||
lightMode
|
||||
{item}
|
||||
>
|
||||
<Settings size={14} />
|
||||
Account settings
|
||||
</MenuItem>
|
||||
</div>
|
||||
|
||||
<div role="none">
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
if (!document.documentElement.classList.contains('dark')) {
|
||||
document.documentElement.classList.add('dark')
|
||||
window.localStorage.setItem('dark-mode', 'dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
window.localStorage.setItem('dark-mode', 'light')
|
||||
}
|
||||
}}
|
||||
lightMode
|
||||
class={twMerge(
|
||||
'w-full flex gap-3.5 px-2 py-2',
|
||||
sidebarClasses.hoverBg,
|
||||
sidebarClasses.text
|
||||
)}
|
||||
{item}
|
||||
>
|
||||
{#if darkMode}
|
||||
<Sun size={14} />
|
||||
{:else}
|
||||
<Moon size={14} />
|
||||
{/if}
|
||||
Switch theme
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
href="{base}/user/workspaces"
|
||||
onClick={() => clearWorkspaceFromStorage()}
|
||||
lightMode
|
||||
class={twMerge(
|
||||
'flex gap-3.5 px-2 py-2',
|
||||
sidebarClasses.hoverBg,
|
||||
sidebarClasses.text
|
||||
)}
|
||||
{item}
|
||||
>
|
||||
<Building size={14} />
|
||||
All workspaces
|
||||
</MenuItem>
|
||||
|
||||
{#if $superadmin}
|
||||
<MenuItem
|
||||
href="#superadmin-settings"
|
||||
class={twMerge(
|
||||
'flex flex-row gap-3.5 items-center px-2 py-2 ',
|
||||
'text-secondary text-xs',
|
||||
'hover:bg-surface-hover hover:text-primary cursor-pointer',
|
||||
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
|
||||
)}
|
||||
{item}
|
||||
>
|
||||
<ServerCog size={14} />
|
||||
Instance settings
|
||||
</MenuItem>
|
||||
{/if}
|
||||
|
||||
<MenuItem
|
||||
onClick={() => logout()}
|
||||
class={twMerge(
|
||||
'flex flex-row gap-3.5 items-center px-2 py-2 w-full',
|
||||
'text-primary text-xs',
|
||||
'hover:bg-surface-hover cursor-pointer',
|
||||
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
|
||||
)}
|
||||
{item}
|
||||
>
|
||||
<ServerCog size={14} />
|
||||
Instance settings
|
||||
<LogOut size={14} />
|
||||
Sign out
|
||||
</MenuItem>
|
||||
{/if}
|
||||
|
||||
<MenuItem
|
||||
onClick={() => logout()}
|
||||
class={twMerge(
|
||||
'flex flex-row gap-3.5 items-center px-2 py-2 w-full',
|
||||
'text-primary text-xs',
|
||||
'hover:bg-surface-hover cursor-pointer',
|
||||
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
|
||||
)}
|
||||
{item}
|
||||
>
|
||||
<LogOut size={14} />
|
||||
Sign out
|
||||
</MenuItem>
|
||||
</div>
|
||||
<div onmouseleave={() => (showMore = false)} role="none">
|
||||
{#if secondMenuLinks.length}
|
||||
<ResizeTransitionWrapper vertical innerClass="w-full">
|
||||
{#if !showMore}
|
||||
<div onmouseenter={() => (showMore = true)} role="none">
|
||||
<MenuItem {item}>
|
||||
<div class="px-2 py-2 text-primary text-2xs">More...</div>
|
||||
</MenuItem>
|
||||
</div>
|
||||
{:else}
|
||||
{#snippet renderSecondMenuLinks(menuLinks: SecondMenuLink[])}
|
||||
{#each menuLinks as menuLink (menuLink.href ?? menuLink.label)}
|
||||
<MenuItem
|
||||
href={menuLink.href}
|
||||
class={twMerge(
|
||||
'flex flex-row gap-3.5 items-center px-2 py-2 text-secondary text-2xs hover:bg-surface-hover hover:text-primary cursor-pointer',
|
||||
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
|
||||
)}
|
||||
{item}
|
||||
</div>
|
||||
<div role="none">
|
||||
{#snippet renderSecondMenuLinks(menuLinks: SecondMenuLink[])}
|
||||
{#each menuLinks as menuLink (menuLink.href ?? menuLink.label)}
|
||||
<MenuItem
|
||||
href={menuLink.href}
|
||||
class={twMerge(
|
||||
'flex flex-row gap-3.5 items-center px-2 py-2 text-secondary text-2xs hover:bg-surface-hover hover:text-primary cursor-pointer',
|
||||
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
|
||||
)}
|
||||
{item}
|
||||
>
|
||||
{menuLink.label}
|
||||
</MenuItem>
|
||||
{/each}
|
||||
{/snippet}
|
||||
{#if secondMenuLinks.length || secondMenuTriggerLinks.length || extraTriggerLinks.length}
|
||||
<div class="divide-y">
|
||||
{#if secondMenuLinks.length}<div
|
||||
>{@render renderSecondMenuLinks(secondMenuLinks)}</div
|
||||
>{/if}
|
||||
{#if secondMenuTriggerLinks.length}<div
|
||||
>{@render renderSecondMenuLinks(secondMenuTriggerLinks)}</div
|
||||
>{/if}
|
||||
{#if extraTriggerLinks.length}<div>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="flex flex-row gap-3.5 items-center px-2 py-2 w-full text-secondary text-2xs hover:bg-surface-hover hover:text-primary cursor-pointer"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
showExtraTriggers = !showExtraTriggers
|
||||
}}
|
||||
>
|
||||
{menuLink.label}
|
||||
</MenuItem>
|
||||
{/each}
|
||||
{/snippet}
|
||||
<div class="divide-y">
|
||||
<div>{@render renderSecondMenuLinks(secondMenuLinks)}</div>
|
||||
<div>{@render renderSecondMenuLinks(secondMenuTriggerLinks)}</div>
|
||||
</div>
|
||||
{/if}
|
||||
</ResizeTransitionWrapper>
|
||||
{/if}
|
||||
{#if $enterpriseLicense}
|
||||
<MultiplayerMenu />
|
||||
{/if}
|
||||
<Plus size={12} />
|
||||
<span class="text-2xs">More triggers</span>
|
||||
</div>
|
||||
{#if showExtraTriggers}
|
||||
{#each extraTriggerLinks as menuLink (menuLink.href)}
|
||||
<MenuItem
|
||||
href={menuLink.href}
|
||||
class={twMerge(
|
||||
'flex flex-row gap-3.5 items-center px-2 py-2 pl-6 text-tertiary text-2xs hover:bg-surface-hover hover:text-primary cursor-pointer',
|
||||
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
|
||||
)}
|
||||
{item}
|
||||
>
|
||||
{menuLink.label}
|
||||
</MenuItem>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if $enterpriseLicense}
|
||||
<MultiplayerMenu />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Menu>
|
||||
{/snippet}
|
||||
</Menubar>
|
||||
{/snippet}
|
||||
</Menu>
|
||||
{/snippet}
|
||||
</Menubar>
|
||||
</div>
|
||||
|
||||
<DarkModeObserver bind:darkMode />
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface UserExt {
|
||||
pgroups: string[]
|
||||
folders: string[]
|
||||
folders_owners: string[]
|
||||
is_service_account?: boolean
|
||||
}
|
||||
|
||||
export interface UserWorkspace {
|
||||
|
||||
@@ -676,7 +676,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="absolute top-2 left-2 z5000">
|
||||
<div class="absolute top-1 left-1 z5000">
|
||||
<OperatorMenu favoriteLinks={favoriteManager.current} />
|
||||
</div>
|
||||
{/if}
|
||||
@@ -775,6 +775,38 @@
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col h-full w-full">
|
||||
{#if $userStore?.is_service_account}
|
||||
<div
|
||||
class="bg-yellow-100 dark:bg-yellow-900/50 border-b border-yellow-300 dark:border-yellow-700 px-4 py-2 text-sm text-yellow-800 dark:text-yellow-200 flex items-center justify-center gap-4 shrink-0"
|
||||
>
|
||||
<span>
|
||||
Viewing workspace on behalf of <strong>{$userStore.username}</strong>
|
||||
<span class="text-yellow-600 dark:text-yellow-400"
|
||||
>(impersonated by {$userStore.impersonating_email})</span
|
||||
>
|
||||
</span>
|
||||
<button
|
||||
class="px-3 py-1 text-xs font-medium bg-yellow-200 dark:bg-yellow-800 hover:bg-yellow-300 dark:hover:bg-yellow-700 rounded transition-colors"
|
||||
onclick={async () => {
|
||||
const savedToken = sessionStorage.getItem('pre_impersonation_token')
|
||||
if (savedToken && $workspaceStore) {
|
||||
try {
|
||||
await UserService.exitImpersonation({
|
||||
workspace: $workspaceStore,
|
||||
requestBody: { token: savedToken }
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Failed to exit impersonation', e)
|
||||
}
|
||||
sessionStorage.removeItem('pre_impersonation_token')
|
||||
}
|
||||
window.location.href = '/workspace_settings?tab=users'
|
||||
}}
|
||||
>
|
||||
Exit impersonation
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<AiChatLayout
|
||||
{children}
|
||||
noPadding={devOnly}
|
||||
|
||||
Reference in New Issue
Block a user