[ee] feat: create a personal workspace on cloud signup instead of the demo invite

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9
This commit is contained in:
Guilhem Lemouel
2026-08-24 13:29:37 +02:00
co-authored by Claude Opus 5
parent 7751d3e43e
commit 9f12745b13
4 changed files with 166 additions and 110 deletions
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(\n SELECT 1 FROM workspace_invite WHERE email = $1\n UNION ALL\n SELECT 1 FROM usr WHERE email = $1\n )",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "9d5fb7a829a1631328cf9a34be41895619c0c69848fa8ea60f46e5edb676fec1"
}
+1 -1
View File
@@ -1 +1 @@
d6aef91c0f7ba556befbf4addeb7674d4a9dd819
395720dc81b2d5e3aff5be26a2600a85ca07f4a7
+118 -105
View File
@@ -5275,6 +5275,117 @@ async fn _check_nb_of_archived_workspaces(db: &DB) -> Result<()> {
return Ok(());
}
/// Insert a workspace and everything it needs to be usable: settings, encryption key,
/// the owner as its admin, and the `all` / `wm_deployers` groups. Returns the owner's
/// username in the new workspace.
///
/// The caller owns the transaction and the audit log: the signup path creates a
/// workspace before the user has an `ApiAuthed` to audit under.
pub async fn insert_workspace<'c>(
tx: &mut Transaction<'c, Postgres>,
id: &str,
name: &str,
owner_email: &str,
color: Option<&str>,
error_handler_fallback_to_instance_alerts: bool,
requested_username: Option<String>,
) -> Result<String> {
validate_workspace_name(name)?;
check_w_id_conflict(tx, id).await?;
sqlx::query!(
"INSERT INTO workspace
(id, name, owner)
VALUES ($1, $2, $3)",
id,
name,
owner_email,
)
.execute(&mut **tx)
.await?;
if error_handler_fallback_to_instance_alerts {
ensure_instance_alert_fallback_allowed(tx, id).await?;
}
sqlx::query!(
"INSERT INTO workspace_settings
(workspace_id, color, error_handler_fallback_to_instance_alerts)
VALUES ($1, $2, $3)",
id,
color,
error_handler_fallback_to_instance_alerts,
)
.execute(&mut **tx)
.await?;
let key = rd_string(64);
sqlx::query!(
"INSERT INTO workspace_key
(workspace_id, kind, key)
VALUES ($1, 'cloud', $2)",
id,
&key
)
.execute(&mut **tx)
.await?;
let automate_username_creation = sqlx::query_scalar!(
"SELECT value FROM global_settings WHERE name = $1",
AUTOMATE_USERNAME_CREATION_SETTING,
)
.fetch_optional(&mut **tx)
.await?
.map(|v| v.as_bool())
.flatten()
.unwrap_or(true);
let username = if automate_username_creation {
if requested_username.is_some_and(|u| u.len() > 0) {
return Err(Error::BadRequest(
"username is not allowed when username creation is automated".to_string(),
));
}
get_instance_username_or_create_pending(tx, owner_email).await?
} else {
requested_username.ok_or(Error::BadRequest("username is required".to_string()))?
};
sqlx::query!(
"INSERT INTO usr
(workspace_id, email, username, is_admin)
VALUES ($1, $2, $3, true)",
id,
owner_email,
username,
)
.execute(&mut **tx)
.await?;
sqlx::query!(
"INSERT INTO group_
VALUES ($1, 'all', 'The group that always contains all users of this workspace')",
id
)
.execute(&mut **tx)
.await?;
sqlx::query!(
"INSERT INTO group_
VALUES ($1, 'wm_deployers', 'Members can preserve the original author when deploying to this workspace')",
id
)
.execute(&mut **tx)
.await?;
sqlx::query!(
"INSERT INTO usr_to_group
VALUES ($1, 'all', $2)",
id,
username
)
.execute(&mut **tx)
.await?;
Ok(username)
}
async fn create_workspace(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -5302,115 +5413,17 @@ async fn create_workspace(
}
}
validate_workspace_name(&nw.name)?;
let mut tx: Transaction<'_, Postgres> = db.begin().await?;
check_w_id_conflict(&mut tx, &nw.id).await?;
sqlx::query!(
"INSERT INTO workspace
(id, name, owner)
VALUES ($1, $2, $3)",
nw.id,
nw.name,
authed.email,
)
.execute(&mut *tx)
.await?;
if nw.error_handler_fallback_to_instance_alerts {
ensure_instance_alert_fallback_allowed(&mut tx, &nw.id).await?;
}
sqlx::query!(
"INSERT INTO workspace_settings
(workspace_id, color, error_handler_fallback_to_instance_alerts)
VALUES ($1, $2, $3)",
nw.id,
nw.color,
insert_workspace(
&mut tx,
&nw.id,
&nw.name,
&authed.email,
nw.color.as_deref(),
nw.error_handler_fallback_to_instance_alerts,
nw.username,
)
.execute(&mut *tx)
.await?;
let key = rd_string(64);
sqlx::query!(
"INSERT INTO workspace_key
(workspace_id, kind, key)
VALUES ($1, 'cloud', $2)",
nw.id,
&key
)
.execute(&mut *tx)
.await?;
// let mc = magic_crypt::new_magic_crypt!(key, 256);
// sqlx::query!(
// "INSERT INTO variable
// (workspace_id, path, value, is_secret, description)
// VALUES ($1, 'g/all/pretty_secret', $2, true, 'This item is secret'),
// ($3, 'g/all/not_secret', $4, false, 'This item is not secret')",
// nw.id,
// crate::variables::encrypt(&mc, "pretty secret value"),
// nw.id,
// "finland does not actually exist",
// )
// .execute(&mut *tx)
// .await?;
let automate_username_creation = sqlx::query_scalar!(
"SELECT value FROM global_settings WHERE name = $1",
AUTOMATE_USERNAME_CREATION_SETTING,
)
.fetch_optional(&mut *tx)
.await?
.map(|v| v.as_bool())
.flatten()
.unwrap_or(true);
let username = if automate_username_creation {
if nw.username.is_some() && nw.username.unwrap().len() > 0 {
return Err(Error::BadRequest(
"username is not allowed when username creation is automated".to_string(),
));
}
get_instance_username_or_create_pending(&mut tx, &authed.email).await?
} else {
nw.username
.ok_or(Error::BadRequest("username is required".to_string()))?
};
sqlx::query!(
"INSERT INTO usr
(workspace_id, email, username, is_admin)
VALUES ($1, $2, $3, true)",
nw.id,
authed.email,
username,
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"INSERT INTO group_
VALUES ($1, 'all', 'The group that always contains all users of this workspace')",
nw.id
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"INSERT INTO group_
VALUES ($1, 'wm_deployers', 'Members can preserve the original author when deploying to this workspace')",
nw.id
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"INSERT INTO usr_to_group
VALUES ($1, 'all', $2)",
nw.id,
username
)
.execute(&mut *tx)
.await?;
audit_log(
@@ -1,7 +1,9 @@
<script lang="ts">
import { ArrowLeft } from 'lucide-svelte'
import { UserService } from '$lib/gen/services.gen'
import { UserService, WorkspaceService } from '$lib/gen/services.gen'
import { goto } from '$lib/navigation'
import { usersWorkspaceStore } from '$lib/stores'
import { switchWorkspace } from '$lib/storeUtils'
import CenteredModal from '$lib/components/CenteredModal.svelte'
import { Button } from '$lib/components/common'
import Popover from '$lib/components/meltComponents/Popover.svelte'
@@ -16,7 +18,7 @@
Building2,
Twitter,
Youtube,
Bot,
Bot,
MessageCircleCode
} from 'lucide-svelte'
import { sendUserToast } from '$lib/toast'
@@ -78,6 +80,25 @@
currentStep = STEP_SOURCE
}
// Cloud signup creates a workspace for the user, so the picker would show a single
// entry and one more click. Enter it directly; fall back to the picker for anyone
// with none or several.
async function leaveOnboarding() {
try {
const workspaces = await WorkspaceService.listUserWorkspaces()
usersWorkspaceStore.set(workspaces)
const owned = workspaces.workspaces.filter((w) => w.id !== 'admins')
if (owned.length === 1) {
switchWorkspace(owned[0].id)
await goto('/')
return
}
} catch (error) {
console.error('Could not list workspaces after onboarding:', error)
}
await goto('/user/workspaces')
}
async function continueToWorkspaces() {
if (!selectedSource || isSubmitting) return
@@ -96,7 +117,7 @@
sendUserToast('Failed to save information: ' + (error?.body || error?.message || error), true)
} finally {
// do not block users from accessing windmill even if there is an error
goto('/user/workspaces')
leaveOnboarding()
}
}
@@ -110,7 +131,7 @@
console.error('Error skipping onboarding:', error)
} finally {
// do not block users from accessing windmill even if there is an error
goto('/user/workspaces')
leaveOnboarding()
}
}
</script>