mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
feat: OAuth "Connect an App" (#155)
This commit is contained in:
@@ -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,5 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
|
||||
## [1.15.1](https://github.com/windmill-labs/windmill/compare/v1.15.0...v1.15.1) (2022-06-29)
|
||||
|
||||
|
||||
|
||||
@@ -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,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);
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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} /> 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} /> 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 = ''
|
||||
|
||||
@@ -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
|
||||
<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
|
||||
<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
|
||||
<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>
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user