feat: OAuth "Connect an App" (#155)

This commit is contained in:
Ruben Fiszel
2022-07-02 12:04:41 -07:00
committed by GitHub
parent e42dda7873
commit 60d48708f6
37 changed files with 1244 additions and 475 deletions
-4
View File
@@ -1,5 +1 @@
DB_PASSWORD=changeme
# GitHub OAuth- https://docs.github.com/en/developers/apps/building-oauth-apps/creating-an-oauth-app
GITHUB_OAUTH_CLIENT_ID=yours_client_id
GITHUB_OAUTH_CLIENT_SECRET=yours_client_sected
+1
View File
@@ -1,5 +1,6 @@
# Changelog
## [1.15.1](https://github.com/windmill-labs/windmill/compare/v1.15.0...v1.15.1) (2022-06-29)
+18
View File
@@ -118,6 +118,24 @@ Detailed instructions for more complex deployments will come soon. For simpler
docker based ones, the docker-compose.yml file contains all the necessary
informations.
### OAuth for self-hosting
To get the same oauth integrations as Windmill Cloud, mount `oauth.json` with
the following format:
```json
{
"<client>":
"id": "<CLIENT_ID>",
"secret": "<CLIENT_SECRET>"
}
```
and mount it at `/src/usr/app/oauth.json`.
You will also want to import all the approved resource types from
[WindmillHub](https://hub.windmill.dev).
## Contributors
<a href="https://github.com/windmill-labs/windmill/graphs/contributors">
+1
View File
@@ -1,3 +1,4 @@
target/
.env
v8.snap
oauth.json
@@ -0,0 +1 @@
-- Add down migration script here
@@ -0,0 +1,13 @@
-- Add up migration script here
CREATE TABLE account (
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id),
id SERIAL NOT NULL,
expires_at TIMESTAMP,
refresh_token VARCHAR(255),
PRIMARY KEY (workspace_id, id)
);
ALTER TABLE resource ADD COLUMN account INTEGER;
ALTER TABLE variable ADD COLUMN account INTEGER;
ALTER TABLE password ALTER COLUMN login_type TYPE VARCHAR(50);
+10
View File
@@ -0,0 +1,10 @@
{
"github": {
"auth_url": "https://github.com/login/oauth/authorize",
"token_url": "https://github.com/login/oauth/access_token",
"scopes": [
"workflow",
"repo"
]
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"github": {
"auth_url": "https://github.com/login/oauth/authorize",
"token_url": "https://github.com/login/oauth/access_token"
},
"gitlab": {
"auth_url": "https://gitlab.com/oauth/authorize",
"token_url": "https://gitlab.com/oauth/token",
"scopes": [
"read_user"
]
}
}
+205 -6
View File
@@ -104,7 +104,7 @@ paths:
tags:
- user
requestBody:
description: Partially filled script
description: credentials
required: true
content:
application/json:
@@ -970,15 +970,117 @@ paths:
items:
$ref: "#/components/schemas/ContextualVariable"
/w/{workspace}/oauth/disconnect/{client_name}:
/oauth/login_callback/{client_name}:
post:
summary: disconnect client
operationId: disconnectClient
security: []
summary: login with oauth authorization flow
operationId: loginWithOauth
tags:
- workspace
- user
parameters:
- $ref: "#/components/parameters/ClientName"
requestBody:
description: Partially filled script
required: true
content:
application/json:
schema:
type: object
properties:
code:
type: string
state:
type: string
responses:
"200":
description: >
Successfully authenticated.
The session ID is returned in a cookie named `token` and as plaintext response.
Preferred method of authorization is through the bearer token. The cookie is only for browser convenience.
headers:
Set-Cookie:
schema:
type: string
example: token=abcde12345; Path=/; HttpOnly
content:
text/plain:
schema:
type: string
/oauth/connect_slack_callback:
post:
summary: connect slack callback
operationId: connectSlackCallback
tags:
- oauth
requestBody:
description: code endpoint
required: true
content:
application/json:
schema:
type: object
properties:
code:
type: string
state:
type: string
required:
- code
- state
responses:
"200":
description: slack token
content:
application/json:
schema:
$ref: "#/components/schemas/SlackToken"
/oauth/connect_callback/{client_name}:
post:
summary: connect callback
operationId: connectCallback
tags:
- oauth
parameters:
- $ref: "#/components/parameters/ClientName"
requestBody:
description: code endpoint
required: true
content:
application/json:
schema:
type: object
properties:
code:
type: string
state:
type: string
required:
- code
- state
responses:
"200":
description: oauth token
content:
application/json:
schema:
type: object
properties:
token:
type: string
/w/{workspace}/oauth/disconnect/{account_id}:
post:
summary: disconnect account
operationId: disconnectAccount
tags:
- oauth
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/ClientName"
- $ref: "#/components/parameters/AccountId"
responses:
"200":
description: disconnected client
@@ -987,6 +1089,77 @@ paths:
schema:
type: string
/w/{workspace}/oauth/disconnect_slack:
post:
summary: disconnect slack
operationId: disconnectSlack
tags:
- oauth
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: disconnected slack
content:
text/plain:
schema:
type: string
/w/{workspace}/oauth/set_workspace_slack:
post:
summary: set workspace's slack
operationId: setWorkspaceSlack
tags:
- oauth
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/SlackToken"
responses:
"200":
description: workspace slack is set
content:
text/plain:
schema:
type: string
/oauth/list_logins:
get:
summary: list oauth logins
operationId: listOAuthLogins
tags:
- oauth
responses:
"200":
description: list of oauth login clients
content:
application/json:
schema:
type: array
items:
type: string
/oauth/list_connects:
get:
summary: list oauth connects
operationId: listOAuthConnects
tags:
- oauth
responses:
"200":
description: list of oauth connects clients
content:
application/json:
schema:
additionalProperties:
type: array
items:
type: string
/w/{workspace}/resources/create:
post:
summary: create resource
@@ -2516,6 +2689,12 @@ components:
required: true
schema:
type: string
AccountId:
name: account
in: path
required: true
schema:
type: string
ClientName:
name: client_name
in: path
@@ -3581,3 +3760,23 @@ components:
type: string
required: [type]
SlackToken:
type: object
properties:
access_token:
type: string
team_id:
type: string
team_name:
type: string
bot:
type: object
properties:
bot_access_token:
type: string
required:
- access_token
- team_id
- team_name
- bot
+27 -33
View File
@@ -146,6 +146,19 @@
"nullable": []
}
},
"0dd3fe3ddf9cb72760687d2ee0950afdcce2d54721bfe8dba008b15e4b581956": {
"query": "DELETE FROM account WHERE id = $1 AND workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4",
"Text"
]
},
"nullable": []
}
},
"103e321fbaa847831682b5cba2fd94f12c508ddf372f9facd18e30d00afd1ea3": {
"query": "SELECT label, concat(substring(token for 10)) as token_prefix, expiration, created_at, last_used_at FROM token WHERE email = $1",
"describe": {
@@ -1061,21 +1074,6 @@
"nullable": []
}
},
"5da22b7f44b631740697e49d5766c31668233fe2453d51e8d9d4c45974492616": {
"query": "INSERT INTO variable\n (workspace_id, path, value, is_secret, description)\n VALUES ($1, $2, $3, true, $4) ON CONFLICT (workspace_id, path) DO UPDATE SET value = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Varchar"
]
},
"nullable": []
}
},
"6199e8be5cb13db71108e555ea20f0b76dc38476670f9fc0667b057d2766d42e": {
"query": "SELECT set_config('session.groups', $1, true)",
"describe": {
@@ -1936,6 +1934,11 @@
"ordinal": 5,
"name": "extra_perms",
"type_info": "Jsonb"
},
{
"ordinal": 6,
"name": "account",
"type_info": "Int4"
}
],
"parameters": {
@@ -1950,7 +1953,8 @@
true,
true,
false,
false
false,
true
]
}
},
@@ -2910,22 +2914,6 @@
"nullable": []
}
},
"ea8ebb8d972fe99c960b5a69f794ee2b57bfb1914bf370c5b10313e45fa9b65f": {
"query": "INSERT INTO resource\n (workspace_id, path, value, description, resource_type)\n VALUES ($1, $2, $3, $4, $5) ON CONFLICT (workspace_id, path) DO UPDATE SET value = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Jsonb",
"Text",
"Varchar"
]
},
"nullable": []
}
},
"f056b5f3e66a764748925f1bfd3180923fde8c7fdf69088d0e4a5555cc049545": {
"query": "SELECT result FROM completed_job WHERE id = $1 AND workspace_id = $2",
"describe": {
@@ -3085,6 +3073,11 @@
"ordinal": 5,
"name": "extra_perms",
"type_info": "Jsonb"
},
{
"ordinal": 6,
"name": "account",
"type_info": "Int4"
}
],
"parameters": {
@@ -3098,7 +3091,8 @@
true,
true,
false,
false
false,
true
]
}
}
+1
View File
@@ -832,6 +832,7 @@ enum Job {
#[serde(rename_all(serialize = "lowercase"))]
pub enum JobKind {
Script,
#[allow(non_camel_case_types)]
Script_Hub,
Preview,
Dependencies,
+3 -18
View File
@@ -5,14 +5,13 @@
* LICENSE-AGPL for a copy of the license.
*/
use ::oauth2::basic::BasicClient;
use argon2::Argon2;
use axum::{handler::Handler, middleware::from_extractor, routing::get, Extension, Router};
use db::DB;
use git_version::git_version;
use hyper::Response;
use slack_http_verifier::SlackVerifier;
use std::{collections::HashMap, net::SocketAddr, sync::Arc};
use std::{net::SocketAddr, sync::Arc};
use tokio::sync::Mutex;
use tower::ServiceBuilder;
use tower_cookies::CookieManagerLayer;
@@ -49,7 +48,7 @@ mod workspaces;
use error::Error;
pub use crate::email::EmailSender;
use crate::{db::UserDB, utils::rd_string};
use crate::{db::UserDB, oauth2::build_oauth_clients, utils::rd_string};
const GIT_VERSION: &str = git_version!(args = ["--tag", "--always"], fallback = "unknown-version");
pub const DEFAULT_NUM_WORKERS: usize = 3;
@@ -132,20 +131,6 @@ pub async fn connect_db() -> anyhow::Result<DB> {
Ok(db::connect(&database_url).await?)
}
type BasicClientsMap = HashMap<String, BasicClient>;
pub fn build_oauth_clients(base_url: &str) -> BasicClientsMap {
[(
"github".to_string(),
oauth2::build_gh_client(
&std::env::var("GITHUB_OAUTH_CLIENT_ID").unwrap_or_else(|_| "".to_string()),
&std::env::var("GITHUB_OAUTH_CLIENT_SECRET").unwrap_or_else(|_| "".to_string()),
base_url,
),
)]
.into()
}
#[derive(Clone)]
struct BaseUrl(String);
@@ -161,7 +146,7 @@ pub async fn run_server(
let auth_cache = Arc::new(users::AuthCache::new(db.clone()));
let argon2 = Arc::new(Argon2::default());
let email_sender = Arc::new(es);
let basic_clients = Arc::new(build_oauth_clients(base_url));
let basic_clients = Arc::new(build_oauth_clients(base_url).await?);
let slack_verifier = Arc::new(
std::env::var("SLACK_SIGNING_SECRET")
.ok()
+406 -295
View File
@@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::fmt::Debug;
use std::sync::Arc;
@@ -7,9 +8,9 @@ use axum::body::Bytes;
use axum::extract::{Extension, FromRequest, Path, Query, RequestParts};
use axum::response::Redirect;
use axum::routing::{get, post};
use axum::{async_trait, Router};
use futures::TryFutureExt;
use axum::{async_trait, Json, Router};
use hyper::StatusCode;
use itertools::Itertools;
use oauth2::basic::{
BasicClient, BasicErrorResponse, BasicRevocationErrorResponse, BasicTokenIntrospectionResponse,
BasicTokenType,
@@ -23,98 +24,189 @@ use oauth2::{
TokenResponse, TokenUrl,
};
use reqwest::Client;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use slack_http_verifier::SlackVerifier;
use tokio::fs::File;
use tokio::io::AsyncReadExt;
use tower_cookies::{Cookie, Cookies};
use crate::audit::{audit_log, ActionKind};
use crate::db::{UserDB, DB};
use crate::error::{self, to_anyhow, Error, Result};
use crate::error::{self, to_anyhow, Result};
use crate::jobs;
use crate::jobs::{get_latest_hash_for_path, JobPayload};
use crate::users::{Authed, LoginType};
use crate::variables::build_crypt;
use crate::users::Authed;
use crate::workspaces::WorkspaceSettings;
use crate::{jobs, BasicClientsMap};
use crate::{variables, BaseUrl};
use crate::BaseUrl;
pub fn global_service() -> Router {
Router::new()
.route("/login/:client", get(login))
.route("/login_callback/:client", get(login_callback))
.route("/login_callback/:client", post(login_callback))
.route("/connect/:client", get(connect))
.route("/connect_callback/:client", post(connect_callback))
.route("/connect_slack", get(connect_slack))
.route("/connect_slack_callback", post(connect_slack_callback))
.route(
"/slack_command",
post(slack_command).route_layer(axum::middleware::from_extractor::<SlackSig>()),
)
.route("/list_logins", get(list_logins))
.route("/list_connects", get(list_connects))
}
pub fn workspaced_service() -> Router {
Router::new()
.route("/connect/:client", get(connect))
.route("/disconnect/:client", post(disconnect))
.route("/connect_callback/:client", get(connect_callback))
.route("/disconnect/:account_id", post(disconnect))
.route("/disconnect_slack", post(disconnect_slack))
.route("/set_workspace_slack", post(set_workspace_slack))
}
pub fn build_gh_client(client_id: &str, client_secret: &str, base_uri: &str) -> BasicClient {
let auth_url = AuthUrl::new("https://github.com/login/oauth/authorize".to_string())
.expect("Invalid authorization endpoint URL");
let token_url = TokenUrl::new("https://github.com/login/oauth/access_token".to_string())
.expect("Invalid token endpoint URL");
// Set up the config for the Github OAuth2 process.
BasicClient::new(
ClientId::new(client_id.to_string()),
Some(ClientSecret::new(client_secret.to_string())),
auth_url,
Some(token_url),
)
.set_redirect_uri(
RedirectUrl::new(format!("{base_uri}/api/oauth/login_callback/github")).unwrap(),
)
pub struct ClientWithScopes {
client: BasicClient,
scopes: Vec<String>,
}
pub fn build_connect_client(w_id: &str, client_name: &str, base_uri: &str) -> Result<BasicClient> {
let (auth_str, token_str) = match client_name {
"gmail" => ("", ""),
"slack" => (
"https://slack.com/oauth/authorize",
"https://slack.com/api/oauth.access",
),
_ => Err(Error::BadRequest(format!("unrecognized client!")))?,
pub type BasicClientsMap = HashMap<String, ClientWithScopes>;
#[derive(Debug, Serialize, Deserialize)]
pub struct OAuthConfig {
auth_url: String,
token_url: String,
scopes: Option<Vec<String>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct OAuthClient {
id: String,
secret: String,
}
pub struct AllClients {
pub logins: BasicClientsMap,
pub connects: BasicClientsMap,
pub slack: Option<SlackClient>,
}
pub async fn build_oauth_clients(base_url: &str) -> anyhow::Result<AllClients> {
let connect_configs = serde_json::from_str::<HashMap<String, OAuthConfig>>(include_str!(
"../oauth_connect.json"
))?;
let login_configs =
serde_json::from_str::<HashMap<String, OAuthConfig>>(include_str!("../oauth_login.json"))?;
let mut content = String::new();
let path = "./oauth.json";
if std::path::Path::new(path).exists() {
let mut file = File::open(path).await?;
file.read_to_string(&mut content).await?;
} else {
content.push_str("{}");
}
let oauths: HashMap<String, OAuthClient> =
match serde_json::from_str::<HashMap<String, OAuthClient>>(&content) {
Ok(clients) => clients,
Err(e) => {
tracing::error!("Error while deserializing oauth.json: {e}");
HashMap::new()
}
}
.into_iter()
.collect();
tracing::info!("OAuth loaded clients: {}", oauths.keys().join(", "));
let logins = login_configs
.into_iter()
.filter(|x| oauths.contains_key(&x.0))
.map(|(k, v)| {
let scopes = v.scopes.clone();
let named_client =
build_basic_client(k.clone(), v, oauths.get(&k).unwrap(), true, base_url);
(
named_client.0,
ClientWithScopes {
client: named_client.1,
scopes: scopes.unwrap_or(vec![]),
},
)
})
.collect();
let connects = connect_configs
.into_iter()
.filter(|x| oauths.contains_key(&x.0))
.map(|(k, v)| {
let scopes = v.scopes.clone();
let named_client =
build_basic_client(k.clone(), v, oauths.get(&k).unwrap(), false, base_url);
(
named_client.0,
ClientWithScopes {
client: named_client.1,
scopes: scopes.unwrap_or(vec![]),
},
)
})
.collect();
let slack = oauths.get("slack").map(|v| build_slack_client(v, base_url));
Ok(AllClients {
logins,
connects,
slack,
})
}
pub fn build_basic_client(
name: String,
config: OAuthConfig,
client: &OAuthClient,
login: bool,
base_url: &str,
) -> (String, BasicClient) {
let auth_url =
AuthUrl::new(config.auth_url.to_string()).expect("Invalid authorization endpoint URL");
let token_url =
TokenUrl::new(config.token_url.to_string()).expect("Invalid token endpoint URL");
let redirect_url = if login {
format!("{base_url}/user/login_callback/{name}")
} else {
format!("{base_url}/oauth/callback/{name}")
};
let auth_url = AuthUrl::new(auth_str.to_string()).expect("Invalid authorization endpoint URL");
let token_url = TokenUrl::new(token_str.to_string()).expect("Invalid token endpoint URL");
// Set up the config for the Github OAuth2 process.
Ok(BasicClient::new(
ClientId::new(
std::env::var(&format!("{}_OAUTH_CLIENT_ID", client_name.to_uppercase()))
.ok()
.ok_or(Error::BadRequest(format!(
"client id for {} not in env",
client_name
)))?,
),
Some(ClientSecret::new(
std::env::var(&format!(
"{}_OAUTH_CLIENT_SECRET",
client_name.to_uppercase()
))
.ok()
.ok_or(Error::BadRequest(format!(
"client secret for {} not in env",
client_name
)))?,
)),
(
name.to_string(),
BasicClient::new(
ClientId::new(client.id.to_string()),
Some(ClientSecret::new(client.secret.to_string())),
auth_url,
Some(token_url),
)
.set_redirect_uri(RedirectUrl::new(redirect_url).unwrap()),
)
}
pub fn build_slack_client(client: &OAuthClient, base_url: &str) -> SlackClient {
let auth_url = AuthUrl::new("https://slack.com/oauth/authorize".to_string())
.expect("Invalid authorization endpoint URL");
let token_url = TokenUrl::new("https://slack.com/api/oauth.access".to_string())
.expect("Invalid token endpoint URL");
let redirect_url = format!("{base_url}/oauth/callback_slack");
SlackClient::new(
ClientId::new(client.id.to_string()),
Some(ClientSecret::new(client.secret.to_string())),
auth_url,
Some(token_url),
)
.set_redirect_uri(
RedirectUrl::new(format!(
"{base_uri}/api/w/{w_id}/oauth/connect_callback/{client_name}"
))
.unwrap(),
))
.set_redirect_uri(RedirectUrl::new(redirect_url).unwrap())
}
type SlackClient = OClient<
@@ -129,11 +221,8 @@ type SlackClient = OClient<
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct SlackTokenResponse {
access_token: AccessToken,
team_id: String,
team_name: String,
#[serde(rename = "scope")]
#[serde(deserialize_with = "helpers::deserialize_space_delimited_vec")]
#[serde(serialize_with = "helpers::serialize_space_delimited_vec")]
@@ -196,137 +285,129 @@ where
}
}
pub fn build_slack_client(w_id: &str, client_name: &str, base_uri: &str) -> Result<SlackClient> {
let (auth_str, token_str) = (
"https://slack.com/oauth/authorize",
"https://slack.com/api/oauth.access",
);
let auth_url = AuthUrl::new(auth_str.to_string()).expect("Invalid authorization endpoint URL");
let token_url = TokenUrl::new(token_str.to_string()).expect("Invalid token endpoint URL");
// Set up the config for the Github OAuth2 process.
Ok(SlackClient::new(
ClientId::new(
std::env::var(&format!("{}_OAUTH_CLIENT_ID", client_name.to_uppercase()))
.ok()
.ok_or(Error::BadRequest(format!(
"client id for {} not in env",
client_name
)))?,
),
Some(ClientSecret::new(
std::env::var(&format!(
"{}_OAUTH_CLIENT_SECRET",
client_name.to_uppercase()
))
.ok()
.ok_or(Error::BadRequest(format!(
"client secret for {} not in env",
client_name
)))?,
)),
auth_url,
Some(token_url),
#[derive(Deserialize)]
struct ConnectScopes {
scopes: Option<String>,
}
async fn connect(
Path(client_name): Path<String>,
Query(ConnectScopes { scopes }): Query<ConnectScopes>,
Extension(clients): Extension<Arc<AllClients>>,
cookies: Cookies,
) -> error::Result<Redirect> {
let connects = &clients.connects;
oauth_redirect(
connects,
client_name,
cookies,
scopes.map(|x| x.split('+').map(|x| x.to_owned()).collect()),
)
.set_redirect_uri(
RedirectUrl::new(format!(
"{base_uri}/api/w/{w_id}/oauth/connect_callback/{client_name}"
))
.unwrap(),
}
async fn list_logins(
Extension(clients): Extension<Arc<AllClients>>,
) -> error::JsonResult<Vec<String>> {
Ok(Json(
clients
.logins
.keys()
.map(|x| x.to_owned())
.collect::<Vec<String>>(),
))
}
async fn connect(
Path((w_id, client_name)): Path<(String, String)>,
Extension(base_url): Extension<BaseUrl>,
async fn list_connects(
Extension(clients): Extension<Arc<AllClients>>,
) -> error::JsonResult<HashMap<String, Vec<String>>> {
Ok(Json(
(&clients.connects)
.into_iter()
.map(|(k, v)| (k.to_owned(), v.scopes.clone()))
.collect::<HashMap<String, Vec<String>>>(),
))
}
async fn connect_slack(
Extension(clients): Extension<Arc<AllClients>>,
cookies: Cookies,
) -> error::Result<Redirect> {
let client = build_connect_client(&w_id, &client_name, &base_url.0)?;
let (authorize_url, csrf_state) = client
let client = clients
.slack
.as_ref()
.ok_or_else(|| error::Error::BadRequest("slack client not setup".to_string()))?
.authorize_url(CsrfToken::new_random)
.add_scope(Scope::new("bot".to_string()))
.add_scope(Scope::new("commands".to_string()))
.url();
let csrf = csrf_state.secret().to_string();
let mut cookie = Cookie::new("csrf", csrf);
cookie.set_path("/");
cookies.add(cookie);
.add_scope(Scope::new("commands".to_string()));
let authorize_url = set_csrf_and_retrieve_auth_url(client, cookies);
Ok(Redirect::to(authorize_url.as_str()))
}
async fn disconnect(
authed: Authed,
Path((w_id, client_name)): Path<(String, String)>,
Path((w_id, id)): Path<(String, i32)>,
Extension(user_db): Extension<UserDB>,
) -> error::Result<String> {
let mut tx = user_db.begin(&authed).await?;
match client_name.as_str() {
"slack" => {
sqlx::query!(
"UPDATE workspace_settings
SET slack_team_id = null, slack_name = null WHERE workspace_id = $1",
&w_id
)
.execute(&mut tx)
.await?;
}
_ => Err(error::Error::BadRequest(format!(
"Not recognized client name {client_name}"
)))?,
}
sqlx::query!(
"DELETE FROM account WHERE id = $1 AND workspace_id = $2",
id,
w_id
)
.execute(&mut tx)
.await?;
tx.commit().await?;
Ok(format!("{client_name} disconnected"))
Ok(format!("account {id} disconnected"))
}
async fn disconnect_slack(
authed: Authed,
Path(w_id): Path<String>,
Extension(user_db): Extension<UserDB>,
) -> error::Result<String> {
let mut tx = user_db.begin(&authed).await?;
sqlx::query!(
"UPDATE workspace_settings
SET slack_team_id = null, slack_name = null WHERE workspace_id = $1",
&w_id
)
.execute(&mut tx)
.await?;
tx.commit().await?;
Ok(format!("slack disconnected"))
}
async fn login(
Extension(clients): Extension<Arc<BasicClientsMap>>,
Extension(clients): Extension<Arc<AllClients>>,
Path(client_name): Path<String>,
cookies: Cookies,
) -> error::Result<Redirect> {
let client = clients
.get(&client_name)
.ok_or(Error::BadRequest(format!("client {} invalid", client_name)))?;
let (authorize_url, csrf_state) = client
.authorize_url(CsrfToken::new_random)
.add_scope(Scope::new("user:email".to_string()))
// .add_scope(Scope::new("read:user".to_string()))
.url();
let csrf = csrf_state.secret().to_string();
let mut cookie = Cookie::new("csrf", csrf);
cookie.set_path("/");
cookies.add(cookie);
Ok(Redirect::to(authorize_url.as_str()))
let clients = &clients.logins;
oauth_redirect(clients, client_name, cookies, None)
}
#[derive(Deserialize)]
pub struct CallbackQuery {
pub struct OAuthCallback {
code: Option<String>,
state: Option<String>,
error: Option<String>,
}
#[derive(Serialize)]
pub struct ConnectResponse {
token: String,
}
async fn connect_callback(
authed: Authed,
Path((w_id, client_name)): Path<(String, String)>,
Query(query): Query<CallbackQuery>,
cookies: Cookies,
Extension(user_db): Extension<UserDB>,
Extension(base_url): Extension<BaseUrl>,
) -> error::Result<Redirect> {
if let Some(error) = query.error {
return Ok(Redirect::to(&format!(
"/connection_added?error={}",
urlencoding::encode(&error).into_owned()
)));
}
let code = AuthorizationCode::new(query.code.unwrap());
let state = CsrfToken::new(query.state.unwrap());
Path(client_name): Path<String>,
Json(oauth): Json<OAuthCallback>,
Extension(clients): Extension<Arc<AllClients>>,
) -> error::JsonResult<ConnectResponse> {
let code = AuthorizationCode::new(oauth.code.unwrap());
let state = CsrfToken::new(oauth.state.unwrap());
let csrf_state = cookies
.get("csrf")
@@ -337,100 +418,84 @@ async fn connect_callback(
return Err(error::Error::BadRequest("csrf did not match".to_string()));
}
let token = (&clients
.connects
.get(&client_name)
.ok_or_else(|| error::Error::BadRequest("invalid client".to_string()))?
.client
.exchange_code(code)
.request_async(async_http_client)
.await
.map_err(|e| error::Error::InternalErr(format!("invalid code: {e:?}")))?
.access_token()
.secret())
.to_string();
Ok(Json(ConnectResponse { token }))
}
async fn connect_slack_callback(
cookies: Cookies,
Json(oauth): Json<OAuthCallback>,
Extension(clients): Extension<Arc<AllClients>>,
) -> error::JsonResult<SlackTokenResponse> {
let code = AuthorizationCode::new(oauth.code.unwrap());
let state = CsrfToken::new(oauth.state.unwrap());
let csrf_state = cookies
.get("csrf")
.map(|x| x.value().to_string())
.unwrap_or("".to_string());
if state.secret().to_string() != csrf_state {
return Err(error::Error::BadRequest("csrf did not match".to_string()));
}
let slack_token = (&clients
.slack
.as_ref()
.ok_or_else(|| error::Error::BadRequest("slack client not setup".to_string()))?
.exchange_code(code)
.request_async(async_http_client)
.await
.map_err(|e| error::Error::InternalErr(format!("invalid code: {e:?}")))?)
.to_owned();
Ok(Json(slack_token))
}
async fn set_workspace_slack(
Path(w_id): Path<String>,
Json(token): Json<SlackTokenResponse>,
Extension(user_db): Extension<UserDB>,
authed: Authed,
) -> Result<String> {
let mut tx = user_db.begin(&authed).await?;
let mc = build_crypt(&mut tx, &w_id).await?;
let token_res = match client_name.as_str() {
"slack" => {
let t = build_slack_client(&w_id, &client_name, &base_url.0)?
.exchange_code(code)
.request_async(async_http_client)
.await;
if let Ok(token) = t {
sqlx::query!(
"INSERT INTO workspace_settings
sqlx::query!(
"INSERT INTO workspace_settings
(workspace_id, slack_team_id, slack_name)
VALUES ($1, $2, $3) ON CONFLICT (workspace_id) DO UPDATE SET slack_team_id = $2, slack_name = $3",
&w_id,
token.team_id,
token.team_name
)
.execute(&mut tx)
.await?;
sqlx::query!(
"INSERT INTO group_
&w_id,
token.team_id,
token.team_name
)
.execute(&mut tx)
.await?;
sqlx::query!(
"INSERT INTO group_
(workspace_id, name, summary)
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
&w_id,
"slack",
"The group that runs the script triggered by the slack /windmill command.
&w_id,
"slack",
"The group that runs the script triggered by the slack /windmill command.
Share scripts to this group to make them executable from slack and add
members to this group to let them manage the slack related owner space."
)
.execute(&mut tx)
.await?;
Ok(token.bot.bot_access_token.to_owned())
} else {
Err(t.unwrap_err())
}
}
_ => {
build_connect_client(&w_id, &client_name, &base_url.0)?
.exchange_code(code)
.request_async(async_http_client)
.map_ok(|t| t.access_token().secret().to_owned())
.await
}
};
if let Ok(token) = token_res {
tracing::info!("{token}");
let variable_path = &format!("g/all/{}_token", &client_name);
sqlx::query!(
"INSERT INTO variable
(workspace_id, path, value, is_secret, description)
VALUES ($1, $2, $3, true, $4) ON CONFLICT (workspace_id, path) DO UPDATE SET value = $3",
&w_id,
variable_path,
variables::encrypt(&mc, token.to_string()),
format!("OAuth2 token for {client_name}"),
)
.execute(&mut tx)
.await?;
sqlx::query!(
"INSERT INTO resource
(workspace_id, path, value, description, resource_type)
VALUES ($1, $2, $3, $4, $5) ON CONFLICT (workspace_id, path) DO UPDATE SET value = $3",
&w_id,
variable_path,
serde_json::json!({ "token": format!("$var:{variable_path}") }),
format!("OAuth2 token for {client_name}"),
&client_name
)
.execute(&mut tx)
.await?;
audit_log(
&mut tx,
&authed.username,
"oauth2.connect",
ActionKind::Create,
&w_id,
Some(&client_name),
None,
)
.await?;
tx.commit().await?;
Ok(Redirect::to(
format!("/connection_added?client_name={}", &client_name).as_str(),
))
} else {
let error = token_res.unwrap_err().to_string();
Ok(Redirect::to(&format!(
"/connection_added?error={}",
urlencoding::encode(&format!("error fetching token: {error}")).into_owned()
)))
}
)
.execute(&mut tx)
.await?;
tx.commit().await?;
Ok("slack workspace connected".to_string())
}
#[derive(Deserialize, Debug)]
@@ -547,20 +612,13 @@ pub struct UserInfo {
async fn login_callback(
Path(client_name): Path<String>,
Query(query): Query<CallbackQuery>,
Json(callback): Json<OAuthCallback>,
cookies: Cookies,
Extension(clients): Extension<Arc<BasicClientsMap>>,
Extension(clients): Extension<Arc<AllClients>>,
Extension(db): Extension<DB>,
) -> error::Result<Redirect> {
if let Some(error) = query.error {
return Ok(Redirect::to(&format!(
"/user/login?error={}",
urlencoding::encode(&error).into_owned()
)));
}
let code = AuthorizationCode::new(query.code.unwrap());
let state = CsrfToken::new(query.state.unwrap());
) -> error::Result<String> {
let code = AuthorizationCode::new(callback.code.unwrap());
let state = CsrfToken::new(callback.state.unwrap());
let csrf_state = cookies
.get("csrf")
@@ -571,7 +629,7 @@ async fn login_callback(
return Err(error::Error::BadRequest("csrf did not match".to_string()));
}
let client = clients.get(&client_name).unwrap();
let client = &clients.logins.get(&client_name).unwrap().client;
// Exchange the code with a token.
let token_res = client
@@ -590,7 +648,7 @@ async fn login_callback(
let mut tx = db.begin().await?;
let login: Option<(String, LoginType, bool)> =
let login: Option<(String, String, bool)> =
sqlx::query_as("SELECT email, login_type, super_admin FROM password WHERE email = $1")
.bind(&email)
.fetch_optional(&mut tx)
@@ -644,12 +702,11 @@ async fn login_callback(
}
}
tx.commit().await?;
Ok(Redirect::to("/user/workspaces"))
Ok("Successfully logged in".to_string())
} else {
Ok(Redirect::to(&format!(
"/user/login?error={}",
urlencoding::encode("invalid token").into_owned()
)))
Err(error::Error::BadRequest(
"failed to exchange code".to_string(),
))
}
}
@@ -660,24 +717,33 @@ pub struct GHEmailInfo {
primary: bool,
}
#[derive(Deserialize)]
pub struct EmailInfo {
email: String,
}
async fn get_email(http_client: &Client, client_name: &str, token: &str) -> error::Result<String> {
tracing::info!("{token}");
let email = match client_name {
"github" => http_client
.get("https://api.github.com/user/emails")
.bearer_auth(token)
.send()
.await
.map_err(to_anyhow)?
.json::<Vec<GHEmailInfo>>()
.await
.map_err(to_anyhow)?
.iter()
.find(|x| x.primary && x.verified)
.ok_or(error::Error::BadRequest(format!(
"user does not have any primary and verified address"
)))?
.email
.to_string(),
"github" => http_get_user_info::<Vec<GHEmailInfo>>(
http_client,
"https://api.github.com/user/emails",
token,
)
.await?
.iter()
.find(|x| x.primary && x.verified)
.ok_or(error::Error::BadRequest(format!(
"user does not have any primary and verified address"
)))?
.email
.to_string(),
"gitlab" => {
http_get_user_info::<EmailInfo>(http_client, "https://gitlab.com/api/v4/user", token)
.await?
.email
.to_string()
}
_ => {
return Err(error::Error::BadRequest(
"client name not recognized".to_string(),
@@ -693,15 +759,10 @@ async fn get_user_info(
token: &str,
) -> error::Result<UserInfo> {
let email = match client_name {
"github" => http_client
.get("https://api.github.com/user")
.bearer_auth(token)
.send()
.await
.map_err(to_anyhow)?
.json::<UserInfo>()
.await
.map_err(to_anyhow)?,
"github" => http_get_user_info(http_client, "https://api.github.com/user", token).await?,
"gitlab" => {
http_get_user_info(http_client, "https://gitlab.com/api/v4/user", token).await?
}
_ => {
return Err(error::Error::BadRequest(
"client name not recognized".to_string(),
@@ -710,3 +771,53 @@ async fn get_user_info(
};
Ok(email)
}
async fn http_get_user_info<T: DeserializeOwned>(
http_client: &Client,
url: &str,
token: &str,
) -> error::Result<T> {
Ok(http_client
.get(url)
.bearer_auth(token)
.send()
.await
.map_err(to_anyhow)?
.json::<T>()
.await
.map_err(to_anyhow)?)
}
fn oauth_redirect(
clients: &HashMap<String, ClientWithScopes>,
client_name: String,
cookies: Cookies,
scopes: Option<Vec<String>>,
) -> error::Result<Redirect> {
let client_w_scopes = clients
.get(&client_name)
.ok_or_else(|| error::Error::BadRequest("client not found".to_string()))?;
let mut client = client_w_scopes.client.authorize_url(CsrfToken::new_random);
let scopes_iter = if let Some(scopes) = scopes {
scopes
} else {
client_w_scopes.scopes.clone()
};
for scope in scopes_iter.iter() {
client = client.add_scope(oauth2::Scope::new(scope.to_string()));
}
let authorize_url = set_csrf_and_retrieve_auth_url(client, cookies);
Ok(Redirect::to(authorize_url.as_str()))
}
fn set_csrf_and_retrieve_auth_url(
client: oauth2::AuthorizationRequest,
cookies: Cookies,
) -> url::Url {
let (authorize_url, csrf_state) = client.url();
let csrf = csrf_state.secret().to_string();
let mut cookie = Cookie::new("csrf", csrf);
cookie.set_path("/");
cookies.add(cookie);
authorize_url
}
+2
View File
@@ -67,6 +67,7 @@ pub struct Resource {
pub description: Option<String>,
pub resource_type: String,
pub extra_perms: serde_json::Value,
pub account: Option<i32>,
}
#[derive(Deserialize)]
@@ -104,6 +105,7 @@ async fn list_resources(
"description",
"resource_type",
"extra_perms",
"account",
])
.order_by("path", true)
.and_where("workspace_id = ? OR workspace_id = 'starter'".bind(&w_id))
+2 -9
View File
@@ -452,13 +452,6 @@ struct WorkspaceUsername {
pub username: String,
}
#[derive(sqlx::Type, Serialize, Deserialize)]
#[sqlx(type_name = "LOGIN_TYPE", rename_all = "lowercase")]
#[serde(rename_all(serialize = "lowercase"))]
pub enum LoginType {
Password,
Github,
}
async fn exists_username(
authed: Authed,
@@ -1418,7 +1411,7 @@ pub async fn delete_expired_items_perdiodically(
match tokens_deleted_r {
Ok(tokens) => tracing::info!("deleted {} tokens: {:?}", tokens.len(), tokens),
Ok(tokens) => tracing::debug!("deleted {} tokens: {:?}", tokens.len(), tokens),
Err(e) => tracing::error!("Error deleting token: {}", e.to_string()),
}
@@ -1431,7 +1424,7 @@ pub async fn delete_expired_items_perdiodically(
.await;
match magic_links_deleted_r {
Ok(tokens) => tracing::info!("deleted {} tokens: {:?}", tokens.len(), tokens),
Ok(tokens) => tracing::debug!("deleted {} tokens: {:?}", tokens.len(), tokens),
Err(e) => tracing::error!("Error deleting token: {}", e.to_string()),
}
+2 -1
View File
@@ -50,6 +50,7 @@ pub struct ListableVariable {
pub is_secret: bool,
pub description: String,
pub extra_perms: serde_json::Value,
pub account: Option<i32>,
}
#[derive(Deserialize)]
@@ -151,7 +152,7 @@ async fn list_variables(
let mut tx = user_db.begin(&authed).await?;
let rows = sqlx::query_as::<_, ListableVariable>(
"SELECT workspace_id, path, CASE WHEN is_secret IS TRUE THEN null ELSE value::text END as value, is_secret, description, extra_perms from variable
"SELECT workspace_id, path, CASE WHEN is_secret IS TRUE THEN null ELSE value::text END as value, is_secret, description, extra_perms, account from variable
WHERE (workspace_id = $1 OR (is_secret IS NOT TRUE AND workspace_id = 'starter')) ORDER BY path",
)
.bind(&w_id)
@@ -0,0 +1,274 @@
<script lang="ts" context="module">
const apiTokenApps: Record<string, { img?: string; instructions: string }> = {
airtable: {
img: 'airtable_connect.png',
instructions: 'Click on the top-right avatar -> Account -> Api'
}
}
</script>
<script lang="ts">
import IconedResourceType from './IconedResourceType.svelte'
import PageHeader from './PageHeader.svelte'
import { workspaceStore, userStore, oauthStore } from '$lib/stores'
import { faMinus, faPlus } from '@fortawesome/free-solid-svg-icons'
import { OauthService, ResourceService, VariableService } from '$lib/gen'
import { createEventDispatcher, onMount } from 'svelte'
import Modal from './Modal.svelte'
import Icon from 'svelte-awesome'
import Path from './Path.svelte'
import Password from './Password.svelte'
import { sendUserToast, truncate, truncateRev } from '$lib/utils'
import { goto } from '$app/navigation'
let manual = false
let value = ''
let connects: Record<string, string[]> = {}
let connectsManual: [string, { img?: string; instructions: string }][] = []
let scopes: string[] = []
let path: string
let modal: Modal
let resource_type = ''
let step = 1
let no_back = false
export function open() {
step = 1
value = ''
resource_type = ''
no_back = false
modal.openModal()
}
export function openFromOauth(rt: string) {
resource_type = rt
value = $oauthStore!
$oauthStore = undefined
manual = false
step = 3
no_back = true
modal.openModal()
}
async function loadConnects() {
connects = await OauthService.listOAuthConnects()
}
async function loadResources() {
const availableRts = await ResourceService.listResourceTypeNames({
workspace: $workspaceStore!
})
connectsManual = Object.entries(apiTokenApps).filter(([key, _]) => availableRts.includes(key))
}
async function next() {
if (step < 3 && manual) {
step += 1
} else if (step == 1 && !manual) {
window.location.href = `/api/oauth/connect/${resource_type}?scopes=${scopes.join('+')}`
} else {
let exists = true
try {
await VariableService.getVariable({
workspace: $workspaceStore!,
path
})
} catch (e) {
exists = false
}
if (exists) {
throw Error(`Variable at path ${path} already exists. Delete it or pick another path`)
}
exists = true
try {
await ResourceService.getResource({
workspace: $workspaceStore!,
path
})
} catch (e) {
exists = false
}
if (exists) {
throw Error(`Resource at path ${path} already exists. Delete it or pick another path`)
}
await VariableService.createVariable({
workspace: $workspaceStore!,
requestBody: {
path,
value,
is_secret: true,
description: `OAuth token for ${resource_type}`
}
})
await ResourceService.createResource({
workspace: $workspaceStore!,
requestBody: {
resource_type,
path,
value: `{ "token": "$var:${path}"}`,
description: `OAuth token for ${resource_type}`
}
})
dispatch('refresh')
sendUserToast(`App token set at resource and variable path: ${path}`)
modal.closeModal()
}
}
async function back() {
if (step > 1) {
step -= 1
}
}
const dispatch = createEventDispatcher()
$: {
if ($workspaceStore) {
loadResources()
}
}
onMount(() => {
loadConnects()
})
</script>
<Modal
bind:this={modal}
on:close={() => {
dispatch('close')
}}
>
<div slot="title">Connect an app</div>
<div slot="content">
{#if step == 1}
<PageHeader title="Oauth apps" />
<div class="grid sm:grid-cols-2 md:grid-cols-3 gap-x-2 gap-y-1 items-center mb-2">
{#each Object.entries(connects) as [key, values]}
<button
class="px-4 h-8 {key == resource_type ? 'item-button-selected' : 'item-button'}"
on:click={() => {
manual = false
resource_type = key
scopes = values
dispatch('click')
}}
>
<IconedResourceType name={key} after={true} />
</button>
{/each}
</div>
<PageHeader title="scopes" primary={false} />
{#if !manual && resource_type != ''}
{#each scopes as v}
<div class="flex flex-row max-w-md">
<input type="text" bind:value={v} />
<button
class="default-button-secondary mx-6"
on:click={() => {
scopes = scopes.filter((el) => el != v)
}}><Icon data={faMinus} class="mb-1" /></button
>
</div>
{/each}
<button
class="default-button-secondary mt-1"
on:click={() => {
resource_type = resource_type.concat('')
}}>Add item &nbsp;<Icon data={faPlus} class="mb-1" /></button
><span class="ml-2">{(resource_type ?? []).length} item(s)</span>
{:else}
<p class="italic text-sm">Pick an oauth app and customize the scopes here</p>
{/if}
<PageHeader title="API token apps" />
<div class="grid sm:grid-cols-2 md:grid-cols-3 gap-x-2 gap-y-1 items-center mb-2">
{#each connectsManual as [key, instructions]}
<button
class="px-4 h-8 {key == resource_type ? 'item-button-selected' : 'item-button'}"
on:click={() => {
manual = true
resource_type = key
dispatch('click')
}}
>
<IconedResourceType name={key} after={true} />
</button>
{/each}
</div>
{:else if step == 2}
{#if manual}
<PageHeader title="Instructions" />
<div>
{apiTokenApps[resource_type].instructions}
</div>
{#if apiTokenApps[resource_type].img}
<div class="mt-4">
<img alt="connect" src={apiTokenApps[resource_type].img} />
</div>
{/if}
<div class="mt-4">
<Password bind:password={value} label="Paste token here" />
</div>
{/if}
{:else}
<Path bind:path initialPath={`u/${$userStore?.username ?? ''}/my_${resource_type}`} />
<ul class="mt-10 bg-white">
<li>
1. A secret variable containing the token <span class="font-bold"
>{truncateRev(value, 5, '*****')}</span
>
will be stored at
<span class="font-mono">{path}</span>. You can refer to this variable anywhere this token
is required.
</li>
<li class="mt-4">
2. A resource with a unique token field will be stored at <span class="font-mono"
>{path}</span
>
and refer to the secret variable <span class="font-mono">{path}</span> as its token (using
variable templating
<span class="font-mono">`$var:${path}`</span>). You can refer to this resource anywhere
this token is required. A script can use the resource type
<span class="font-mono">{resource_type}</span> as a type parameter to restrict the kind of
tokens it accepts to this app.
</li>
</ul>
{/if}
</div>
<div slot="submission">
{#if step > 1 && !no_back}
<button class="default-button px-4 py-2 font-semibold" on:click={back}>Back</button>
{/if}
<button
class="default-button px-4 py-2 font-semibold"
class:default-button-disabled={(step == 1 && resource_type == '') ||
(step == 2 && value == '')}
on:click={next}
>
{step == 3 ? 'Connect' : 'Next'}
</button>
</div>
</Modal>
<style>
.item-button {
@apply py-1;
@apply border;
@apply rounded-sm;
}
.item-button-selected {
@apply py-1;
@apply border border-blue-500;
@apply bg-blue-50;
@apply rounded-sm;
}
.selected:hover {
@apply border border-gray-400 rounded-md border-opacity-50;
}
</style>
+12 -2
View File
@@ -1,8 +1,8 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { page } from '$app/stores'
import { FlowService, type Flow } from '$lib/gen'
import { clearPreviewResults, workspaceStore } from '$lib/stores'
import { FlowService, ScriptService, type Flow } from '$lib/gen'
import { clearPreviewResults, workspaceStore, hubScripts } from '$lib/stores'
import { sendUserToast } from '$lib/utils'
import { onMount } from 'svelte'
import SvelteMarkdown from 'svelte-markdown'
@@ -16,6 +16,15 @@
$: step = Number($page.url.searchParams.get('step')) || 1
async function loadSearchData() {
const scripts = await ScriptService.listHubScripts()
$hubScripts = scripts.map((x) => ({
path: `hub/${x.id}/${x.summary.toLowerCase().replaceAll(/\s+/g, '_')}`,
summary: `${x.summary} (${x.app})`,
approved: x.approved
}))
}
async function saveFlow(): Promise<void> {
if (initialPath == '') {
await FlowService.createFlow({
@@ -55,6 +64,7 @@
}
onMount(() => {
loadSearchData()
clearPreviewResults()
})
</script>
@@ -3,9 +3,9 @@
import Mail from './icons/Mail.svelte'
import DbIcon from './icons/DbIcon.svelte'
import PostgresIcon from './icons/PostgresIcon.svelte'
import Icon from 'svelte-awesome'
import { faSlack } from '@fortawesome/free-brands-svg-icons'
import { faGithub } from '@fortawesome/free-brands-svg-icons'
import Slack from './icons/Slack.svelte'
import Icon from 'svelte-awesome'
export let name: string
export let after: boolean = false
@@ -27,6 +27,8 @@
<DbIcon {height} {width} />
{:else if name === 'slack'}
<Slack {height} {width} />
{:else if name === 'github'}
<Icon data={faGithub} scale={1.4} />
{/if}
{#if after}
{name}
+6 -4
View File
@@ -14,6 +14,7 @@ export interface UserExt {
let persistedWorkspace = browser && localStorage.getItem('workspace')
export const oauthStore = writable<string | undefined>(undefined)
export const userStore = writable<UserExt | undefined>(undefined)
export const workspaceStore = writable<string | undefined>(
persistedWorkspace ? String(persistedWorkspace) : undefined
@@ -22,10 +23,10 @@ export const usersWorkspaceStore = writable<UserWorkspaceList | undefined>(undef
export const superadmin = writable<String | false | undefined>(undefined)
export const hubScripts = writable<
| Array<{
path: string
summary: string
approved: boolean
}>
path: string
summary: string
approved: boolean
}>
| undefined
>(undefined)
@@ -41,6 +42,7 @@ if (browser) {
}
export function clearStores(): void {
localStorage.removeItem('workspace')
userStore.set(undefined)
workspaceStore.set(undefined)
usersWorkspaceStore.set(undefined)
+11 -3
View File
@@ -39,13 +39,21 @@
console.log('You are a superadmin, you can go wherever you please')
} else {
$userStore = await getUserExt($workspaceStore)
throw Error('Not logged in')
if (!userStore) {
throw Error('Not logged in')
}
}
} else {
goto('/user/workspaces')
}
} catch {
logoutWithRedirect($page.url.pathname)
} catch (e) {
if (
$page.url.pathname != '/user/login' &&
!$page.url.pathname.startsWith('/user/login_callback')
) {
console.error(e)
logoutWithRedirect($page.url.pathname)
}
}
}
+14 -21
View File
@@ -21,16 +21,11 @@
import { onMount } from 'svelte'
import Icon from 'svelte-awesome'
import '../app.css'
import { OpenAPI, ScriptService } from '$lib/gen'
import {
hubScripts,
superadmin,
userStore,
usersWorkspaceStore,
workspaceStore
} from '$lib/stores'
import { clickOutside } from '$lib/utils'
import { OpenAPI } from '$lib/gen'
import { superadmin, userStore, usersWorkspaceStore, workspaceStore } from '$lib/stores'
import { clickOutside, sendUserToast, sleep } from '$lib/utils'
import { logout } from '$lib/logout'
import { goto } from '$app/navigation'
OpenAPI.WITH_CREDENTIALS = true
@@ -57,20 +52,18 @@
workspacePickerOpen = false
}
async function loadSearchData() {
const scripts = await ScriptService.listHubScripts()
$hubScripts = scripts.map((x) => ({
path: `hub/${x.id}/${x.summary.toLowerCase().replaceAll(/\s+/g, '_')}`,
summary: `${x.summary} (${x.app})`,
approved: x.approved
}))
}
onMount(() => {
onMount(async () => {
isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent)
//Mobile
isCollapsed = isMobile
loadSearchData()
await sleep(2000)
if (!$workspaceStore || !$userStore) {
sendUserToast(
'Workspace not set or corresponding to another user. Redirecting to list of workspaces.',
true
)
goto('/user/workspaces')
}
})
</script>
@@ -190,7 +183,7 @@
<div class="mx-auto">
<span class:hidden={isCollapsed} class="px-2 font-mono text-xs whitespace-nowrap">
<Icon class="text-white" data={faUser} scale={0.6} />
{$userStore?.username ?? $superadmin ?? '___'}
{$userStore?.username ?? ($superadmin ? $superadmin : '___')}
{#if $userStore?.is_admin}
<Icon class="text-white" data={faCrown} scale={0.6} />
{/if}
@@ -1,31 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { page } from '$app/stores'
import { sendUserToast } from '$lib/utils'
import CenteredPage from '$lib/components/CenteredPage.svelte'
let error = $page.url.searchParams.get('error')
let client_name = $page.url.searchParams.get('client_name')
if (client_name) {
sendUserToast(
`Connection added for ${client_name}. The oauth token has been stored in the variables`
)
} else if (error) {
sendUserToast(`Error adding connection ${error}`, true)
} else {
goto('/variables')
}
setTimeout(() => goto('/workspace_settings'), 5000)
</script>
<CenteredPage>
<div class="py-6">
<h1>Connection added for {client_name}</h1>
<p>
Redirecting in 5s to your workspace settings. <br />
The oauth token has been stored in the variables at 'g/all/{client_name}' and also a resource
of type {client_name} at same path 'g/all/{client_name}' refering to that token.
</p>
</div>
</CenteredPage>
+8 -1
View File
@@ -1,8 +1,15 @@
<script lang="ts">
import { goto } from '$app/navigation'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { workspaceStore } from '$lib/stores'
goto('/scripts')
$workspaceStore = localStorage.getItem('workspace') ?? undefined
if (!$workspaceStore) {
goto('/user/workspaces')
} else {
goto('/scripts')
}
</script>
<CenteredPage>
@@ -0,0 +1,41 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { page } from '$app/stores'
import { sendUserToast } from '$lib/utils'
import { onMount } from 'svelte'
import { OauthService } from '$lib/gen'
import { oauthStore } from '$lib/stores'
import Icon from 'svelte-awesome'
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
let client_name = $page.params.client_name
let error = $page.url.searchParams.get('error')
let code = $page.url.searchParams.get('code') ?? undefined
let state = $page.url.searchParams.get('state') ?? undefined
onMount(async () => {
if (error) {
sendUserToast(`Error trying to add ${client_name} connection: ${error}`, true)
goto('/resources')
} else if (code && state) {
const res = await OauthService.connectCallback({
clientName: client_name,
requestBody: { code, state }
})
$oauthStore = res.token
goto(`/resources?resource_type=${client_name}`)
} else {
sendUserToast('Missing code or state as query params', true)
goto('/resources')
}
})
</script>
<CenteredPage>
<PageHeader title="Connection to {client_name} in progress" />
<div class="mx-auto w-0">
<Icon class="animate-spin" data={faSpinner} scale={2.0} />
</div>
</CenteredPage>
@@ -0,0 +1,37 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { page } from '$app/stores'
import { sendUserToast } from '$lib/utils'
import { onMount } from 'svelte'
import { OauthService } from '$lib/gen'
import { workspaceStore, oauthStore } from '$lib/stores'
import Icon from 'svelte-awesome'
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
let error = $page.url.searchParams.get('error')
let code = $page.url.searchParams.get('code') ?? undefined
let state = $page.url.searchParams.get('state') ?? undefined
onMount(async () => {
if (error) {
sendUserToast(`Error trying to add slack connection: ${error}`, true)
} else if (code && state) {
const res = await OauthService.connectSlackCallback({ requestBody: { code, state } })
$oauthStore = res.bot.bot_access_token
await OauthService.setWorkspaceSlack({ workspace: $workspaceStore!, requestBody: res })
sendUserToast('Slack workspace connected to your Windmill workspace')
} else {
sendUserToast('Missing code or state as query params', true)
}
goto('/workspace_settings')
})
</script>
<CenteredPage>
<PageHeader title="Connection to slack in progress" />
<div class="mx-auto w-0">
<Icon class="animate-spin" data={faSpinner} scale={2.0} />
</div>
</CenteredPage>
+30 -8
View File
@@ -14,7 +14,7 @@
import ShareModal from '$lib/components/ShareModal.svelte'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import SvelteMarkdown from 'svelte-markdown'
import { userStore, workspaceStore, type UserExt } from '$lib/stores'
import { userStore, workspaceStore, oauthStore } from '$lib/stores'
import SchemaEditor from '$lib/components/SchemaEditor.svelte'
import type { Schema } from '$lib/common'
import SchemaViewer from '$lib/components/SchemaViewer.svelte'
@@ -23,6 +23,10 @@
import CenteredPage from '$lib/components/CenteredPage.svelte'
import Icon from 'svelte-awesome'
import Required from '$lib/components/Required.svelte'
import AppConnect from '$lib/components/AppConnect.svelte'
import { page } from '$app/stores'
import { onMount } from 'svelte'
type ResourceW = Resource & { canWrite: boolean }
type ResourceTypeW = ResourceType & { canWrite: boolean }
@@ -34,6 +38,7 @@
let resourceViewerSchema: Schema = emptySchema()
let typeModalMode: 'view' | 'view-type' | 'create' = 'view'
let newResourceTypeName: string
let newResourceTypeSchema: Schema
let newResourceTypeDescription: string
@@ -41,6 +46,7 @@
let resourceEditor: ResourceEditor | undefined
let shareModal: ShareModal
let appConnect: AppConnect
async function loadResources(): Promise<void> {
resources = (await ResourceService.listResource({ workspace: $workspaceStore! })).map((x) => {
@@ -103,6 +109,13 @@
loadResourceTypes()
}
}
onMount(() => {
let resource_type = $page.url.searchParams.get('resource_type')
if ($oauthStore && resource_type) {
appConnect.openFromOauth(resource_type)
}
})
</script>
<svelte:head>
@@ -111,12 +124,20 @@
<CenteredPage>
<PageHeader title="Resources">
<button
class="default-button"
on:click={() => {
resourceEditor?.initNew()
}}><Icon class="text-white mb-1" data={faPlus} scale={0.9} /> &nbsp; Add a resource</button
>
<div class="flex flex-row space-x-4">
<button
class="default-button"
on:click={() => {
appConnect.open()
}}>Connect an App</button
>
<button
class="default-button"
on:click={() => {
resourceEditor?.initNew()
}}><Icon class="text-white mb-1" data={faPlus} scale={0.9} /> &nbsp; Add a resource</button
>
</div>
</PageHeader>
<div class="relative">
@@ -218,7 +239,7 @@
{#if resourceTypes}
{#each resourceTypes as { name, description, schema, canWrite }}
<tr>
<td
<td class="pr-4"
><a
href="#{name}"
on:click={() => {
@@ -253,6 +274,7 @@
</TableCustom>
</CenteredPage>
<AppConnect bind:this={appConnect} on:refresh={loadResources} />
<ResourceEditor bind:this={resourceEditor} on:refresh={loadResources} />
<ShareModal
@@ -5,7 +5,7 @@
import { sendUserToast } from '$lib/utils'
import { page } from '$app/stores'
import { usersWorkspaceStore, workspaceStore } from '$lib/stores'
import CenteredModal from './CenteredModal.svelte'
import CenteredModal from '$lib/components/CenteredModal.svelte'
let workspace_id = $page.url.searchParams.get('workspace') ?? ''
let username = ''
@@ -23,7 +23,7 @@
sendUserToast(`Invitation to ${workspace_id} accepted as ${username}`)
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
workspaceStore.set(workspace_id)
goto('/')
goto('/scripts')
}
async function validateName(username: string): Promise<void> {
@@ -7,7 +7,7 @@
import { page } from '$app/stores'
import { usersWorkspaceStore, workspaceStore } from '$lib/stores'
import CenteredModal from './CenteredModal.svelte'
import CenteredModal from '$lib/components/CenteredModal.svelte'
let id = ''
let name = ''
+33 -13
View File
@@ -1,15 +1,15 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { page } from '$app/stores'
import { faGithub } from '@fortawesome/free-brands-svg-icons'
import { faGithub, faGitlab } from '@fortawesome/free-brands-svg-icons'
import { onMount } from 'svelte'
import Icon from 'svelte-awesome'
import { slide } from 'svelte/transition'
import { UserService, WorkspaceService } from '$lib/gen'
import { clearStores, usersWorkspaceStore, workspaceStore } from '$lib/stores'
import { OauthService, UserService, WorkspaceService } from '$lib/gen'
import { clearStores, usersWorkspaceStore, workspaceStore, userStore } from '$lib/stores'
import { sendUserToast } from '$lib/utils'
import CenteredModal from './CenteredModal.svelte'
import { refreshSuperadmin } from '$lib/user'
import CenteredModal from '$lib/components/CenteredModal.svelte'
import { getUserExt, refreshSuperadmin } from '$lib/user'
let email = $page.url.searchParams.get('email') ?? ''
let password = $page.url.searchParams.get('password') ?? ''
@@ -17,6 +17,7 @@
const rd = $page.url.searchParams.get('rd')
let showPassword = false
let logins: string[] = []
async function login(): Promise<void> {
const requestBody = {
@@ -29,7 +30,10 @@
// Once logged in, we can fetch the workspaces
$usersWorkspaceStore = await WorkspaceService.listUserWorkspaces()
// trigger a reload of the user
$workspaceStore = $workspaceStore
if ($workspaceStore) {
$userStore = await getUserExt($workspaceStore)
}
// Finally, we check whether the user is a superadmin
refreshSuperadmin()
redirectUser()
@@ -47,8 +51,14 @@
}
}
async function loadLogins() {
logins = await OauthService.listOAuthLogins()
showPassword = logins.length == 0
}
onMount(async () => {
try {
loadLogins()
await UserService.getCurrentEmail()
redirectUser()
} catch {
@@ -73,13 +83,23 @@
<!-- Enable submit form on enter -->
<CenteredModal>
<div class="justify-center text-center flex flex-col">
<span class="text-xs text-gray-600">Currently only signup through Github is supported</span>
<a rel="external" href="/api/oauth/login/github"
><button class="m-auto default-button bg-black mt-2 py-2 w-full text-gray-200"
>Signup or login with Github &nbsp;
<Icon class="text-white pb-1" data={faGithub} scale={1.4} />
</button></a
>
{#if logins.includes('github')}
<a rel="external" href="/api/oauth/login/github"
><button class="m-auto default-button bg-black mt-2 py-2 w-full text-gray-200"
>Github &nbsp;
<Icon class="text-white pb-1" data={faGithub} scale={1.4} />
</button></a
>
{/if}
{#if logins.includes('gitlab')}
<a rel="external" href="/api/oauth/login/gitlab"
><button
class="m-auto default-button bg-orange-400 mt-2 py-2 w-full text-black hover:bg-orange-600"
>Gitlab &nbsp;
<Icon class="pb-1" data={faGitlab} scale={1.4} />
</button></a
>
{/if}
</div>
<div class="flex flex-row-reverse w-full">
<button
@@ -0,0 +1,46 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { page } from '$app/stores'
import { sendUserToast, sleep } from '$lib/utils'
import { onMount } from 'svelte'
import { UserService } from '$lib/gen'
import CenteredModal from '$lib/components/CenteredModal.svelte'
import Icon from 'svelte-awesome'
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
import { userStore, workspaceStore } from '$lib/stores'
import { getUserExt } from '$lib/user'
let error = $page.url.searchParams.get('error')
let clientName = $page.params.client_name
let code = $page.url.searchParams.get('code') ?? undefined
let state = $page.url.searchParams.get('state') ?? undefined
onMount(async () => {
if (error) {
sendUserToast(`Error trying to login with ${clientName} ${error}`, true)
goto('/user/login')
} else if (code && state && clientName) {
try {
await UserService.loginWithOauth({ requestBody: { code, state }, clientName })
} catch (e) {
goto('/user/login')
throw e
}
if ($workspaceStore) {
$userStore = await getUserExt($workspaceStore)
goto('/')
} else {
goto('/user/workspaces')
}
} else {
sendUserToast('Missing code or state as query params', true)
goto('/user/login')
}
})
</script>
<CenteredModal title="Login from {clientName} in progress">
<div class="mx-auto w-0">
<Icon class="animate-spin" data={faSpinner} scale={2.0} />
</div>
</CenteredModal>
@@ -4,11 +4,10 @@
import type { TruncatedToken, NewToken } from '$lib/gen'
import { UserService, SettingsService } from '$lib/gen'
import { displayDate, sendUserToast, getToday } from '$lib/utils'
import PageHeader from '$lib/components/PageHeader.svelte'
import Icon from 'svelte-awesome'
import { faPlus } from '@fortawesome/free-solid-svg-icons'
import TableCustom from '$lib/components/TableCustom.svelte'
import CenteredModal from './CenteredModal.svelte'
import CenteredModal from '$lib/components/CenteredModal.svelte'
let newPassword: string | undefined
let passwordError: string | undefined
@@ -1,14 +1,10 @@
<script lang="ts">
import Fuse from 'fuse.js'
import { superadmin, usersWorkspaceStore } from '$lib/stores'
import { UserService, SettingsService, GlobalUserInfo } from '$lib/gen'
import { displayDate, sendUserToast, getToday } from '$lib/utils'
import Icon from 'svelte-awesome'
import { faPlus } from '@fortawesome/free-solid-svg-icons'
import TableCustom from '$lib/components/TableCustom.svelte'
import CenteredModal from './CenteredModal.svelte'
import CenteredModal from '$lib/components/CenteredModal.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import InviteGlobalUser from '$lib/components/InviteGlobalUser.svelte'
@@ -6,7 +6,7 @@
import { UserService, type WorkspaceInvite, WorkspaceService } from '$lib/gen'
import { superadmin, usersWorkspaceStore, workspaceStore } from '$lib/stores'
import CenteredModal from './CenteredModal.svelte'
import CenteredModal from '../../lib/components/CenteredModal.svelte'
import Switch from '$lib/components/Switch.svelte'
import { faCrown, faUserCog } from '@fortawesome/free-solid-svg-icons'
import Icon from 'svelte-awesome'
@@ -88,8 +88,7 @@
"
on:click={() => {
workspaceStore.set(workspace.id)
goto('/')
goto('/scripts')
}}
><span class="font-mono">{workspace.id}</span> - {workspace.name} as
<span class="font-mono">{workspace.username}</span>
+15 -10
View File
@@ -1,10 +1,10 @@
<script lang="ts">
import Fuse from 'fuse.js'
import { UserService, type WorkspaceInvite, WorkspaceService } from '$lib/gen'
import { UserService, type WorkspaceInvite, WorkspaceService, OauthService } from '$lib/gen'
import type { User } from '$lib/gen'
import { sendUserToast } from '$lib/utils'
import PageHeader from '$lib/components/PageHeader.svelte'
import { userStore, usersWorkspaceStore, workspaceStore } from '$lib/stores'
import { userStore, usersWorkspaceStore, workspaceStore, oauthStore } from '$lib/stores'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import Icon from 'svelte-awesome'
import { faSlack } from '@fortawesome/free-brands-svg-icons'
@@ -12,6 +12,8 @@
import { goto } from '$app/navigation'
import InviteUser from '$lib/components/InviteUser.svelte'
import ScriptPicker from '$lib/components/ScriptPicker.svelte'
import AppConnect from '$lib/components/AppConnect.svelte'
import { onMount } from 'svelte'
let users: User[] = []
let invites: WorkspaceInvite[] = []
@@ -19,8 +21,8 @@
let userFilter = ''
let scriptPath: string
let team_name: string | undefined
let slackLoaded = false
let appConnect: AppConnect
const fuseOptions = {
includeScore: false,
keys: ['username', 'email']
@@ -84,8 +86,15 @@
loadSlack()
}
}
onMount(() => {
if ($oauthStore) {
appConnect.openFromOauth('slack')
}
})
</script>
<AppConnect bind:this={appConnect} />
<CenteredPage>
{#if $userStore?.is_admin}
<PageHeader title="Workspace Settings of {$workspaceStore}" />
@@ -179,19 +188,15 @@
<p class="text-xs text-gray-700 my-1">
Status: {#if team_name}Connected to slack workspace {team_name}{:else}Not connected{/if}
</p>
<a
class="default-button mt-2"
rel="external"
href="/api/w/{$workspaceStore}/oauth/connect/slack"
<a class="default-button mt-2" rel="external" href="/api/oauth/connect_slack"
>Connect to slack <Icon class="text-white mb-1" data={faSlack} scale={0.9} />
</a>
{#if team_name}
<button
class="default-button mt-2"
on:click={async () => {
await WorkspaceService.disconnectClient({
workspace: $workspaceStore ?? '',
clientName: 'slack'
await OauthService.disconnectSlack({
workspace: $workspaceStore ?? ''
})
loadSlack()
sendUserToast('Disconnected slack')
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB