From 60d48708f66bd1ffafe77ad7971ce71dece7e38f Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 2 Jul 2022 12:04:41 -0700 Subject: [PATCH] feat: OAuth "Connect an App" (#155) --- .env | 4 - CHANGELOG.md | 1 + README.md | 18 + backend/.gitignore | 1 + .../20220615151034_accounts.down.sql | 1 + .../migrations/20220615151034_accounts.up.sql | 13 + backend/oauth_connect.json | 10 + backend/oauth_login.json | 13 + backend/openapi.yaml | 211 +++++- backend/sqlx-data.json | 60 +- backend/src/jobs.rs | 1 + backend/src/lib.rs | 21 +- backend/src/oauth2.rs | 701 ++++++++++-------- backend/src/resources.rs | 2 + backend/src/users.rs | 11 +- backend/src/variables.rs | 3 +- frontend/src/lib/components/AppConnect.svelte | 274 +++++++ .../components}/CenteredModal.svelte | 0 .../src/lib/components/FlowBuilder.svelte | 14 +- .../lib/components/IconedResourceType.svelte | 6 +- frontend/src/lib/stores.ts | 10 +- frontend/src/routes/__layout-root.svelte | 14 +- frontend/src/routes/__layout@root.svelte | 35 +- frontend/src/routes/connection_added.svelte | 31 - frontend/src/routes/index.svelte | 9 +- .../oauth/callback/[client_name].svelte | 41 + .../src/routes/oauth/callback_slack.svelte | 37 + frontend/src/routes/resources.svelte | 38 +- .../src/routes/user/accept_invite@user.svelte | 4 +- .../routes/user/create_workspace@user.svelte | 2 +- frontend/src/routes/user/login@user.svelte | 46 +- .../login_callback/[client_name]@user.svelte | 46 ++ frontend/src/routes/user/settings@user.svelte | 3 +- .../user/superadmin_settings@user.svelte | 8 +- .../src/routes/user/workspaces@user.svelte | 5 +- frontend/src/routes/workspace_settings.svelte | 25 +- frontend/static/airtable_connect.png | Bin 0 -> 47978 bytes 37 files changed, 1244 insertions(+), 475 deletions(-) create mode 100644 backend/migrations/20220615151034_accounts.down.sql create mode 100644 backend/migrations/20220615151034_accounts.up.sql create mode 100644 backend/oauth_connect.json create mode 100644 backend/oauth_login.json create mode 100644 frontend/src/lib/components/AppConnect.svelte rename frontend/src/{routes/user => lib/components}/CenteredModal.svelte (100%) delete mode 100644 frontend/src/routes/connection_added.svelte create mode 100644 frontend/src/routes/oauth/callback/[client_name].svelte create mode 100644 frontend/src/routes/oauth/callback_slack.svelte create mode 100644 frontend/src/routes/user/login_callback/[client_name]@user.svelte create mode 100644 frontend/static/airtable_connect.png diff --git a/.env b/.env index 9bc036ec18..75cc45b1c1 100644 --- a/.env +++ b/.env @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d42cecaa4..5b7f0aab8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog + ## [1.15.1](https://github.com/windmill-labs/windmill/compare/v1.15.0...v1.15.1) (2022-06-29) diff --git a/README.md b/README.md index 2233f99081..6d42e6b18b 100644 --- a/README.md +++ b/README.md @@ -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 +{ + "": + "id": "", + "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 diff --git a/backend/.gitignore b/backend/.gitignore index 92035e72e6..290bfb5dcf 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,3 +1,4 @@ target/ .env v8.snap +oauth.json diff --git a/backend/migrations/20220615151034_accounts.down.sql b/backend/migrations/20220615151034_accounts.down.sql new file mode 100644 index 0000000000..d2f607c5b8 --- /dev/null +++ b/backend/migrations/20220615151034_accounts.down.sql @@ -0,0 +1 @@ +-- Add down migration script here diff --git a/backend/migrations/20220615151034_accounts.up.sql b/backend/migrations/20220615151034_accounts.up.sql new file mode 100644 index 0000000000..e7c0356554 --- /dev/null +++ b/backend/migrations/20220615151034_accounts.up.sql @@ -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); diff --git a/backend/oauth_connect.json b/backend/oauth_connect.json new file mode 100644 index 0000000000..5496551154 --- /dev/null +++ b/backend/oauth_connect.json @@ -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" + ] + } +} diff --git a/backend/oauth_login.json b/backend/oauth_login.json new file mode 100644 index 0000000000..cbf76b0a9e --- /dev/null +++ b/backend/oauth_login.json @@ -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" + ] + } +} diff --git a/backend/openapi.yaml b/backend/openapi.yaml index 0ef2b10381..94fc7c1485 100644 --- a/backend/openapi.yaml +++ b/backend/openapi.yaml @@ -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 diff --git a/backend/sqlx-data.json b/backend/sqlx-data.json index bf3affa0cd..712fa927e4 100644 --- a/backend/sqlx-data.json +++ b/backend/sqlx-data.json @@ -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 ] } } diff --git a/backend/src/jobs.rs b/backend/src/jobs.rs index f27f138872..2b7efc57ea 100644 --- a/backend/src/jobs.rs +++ b/backend/src/jobs.rs @@ -832,6 +832,7 @@ enum Job { #[serde(rename_all(serialize = "lowercase"))] pub enum JobKind { Script, + #[allow(non_camel_case_types)] Script_Hub, Preview, Dependencies, diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 5e5a7b31a9..6431f0757b 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -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 { Ok(db::connect(&database_url).await?) } -type BasicClientsMap = HashMap; - -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() diff --git a/backend/src/oauth2.rs b/backend/src/oauth2.rs index 1d3a6c3685..4308fc0e2f 100644 --- a/backend/src/oauth2.rs +++ b/backend/src/oauth2.rs @@ -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::()), ) + .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, } -pub fn build_connect_client(w_id: &str, client_name: &str, base_uri: &str) -> Result { - 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; + +#[derive(Debug, Serialize, Deserialize)] +pub struct OAuthConfig { + auth_url: String, + token_url: String, + scopes: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct OAuthClient { + id: String, + secret: String, +} +pub struct AllClients { + pub logins: BasicClientsMap, + pub connects: BasicClientsMap, + pub slack: Option, +} + +pub async fn build_oauth_clients(base_url: &str) -> anyhow::Result { + let connect_configs = serde_json::from_str::>(include_str!( + "../oauth_connect.json" + ))?; + let login_configs = + serde_json::from_str::>(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 = + match serde_json::from_str::>(&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 { - 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, +} +async fn connect( + Path(client_name): Path, + Query(ConnectScopes { scopes }): Query, + Extension(clients): Extension>, + cookies: Cookies, +) -> error::Result { + 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>, +) -> error::JsonResult> { + Ok(Json( + clients + .logins + .keys() + .map(|x| x.to_owned()) + .collect::>(), )) } -async fn connect( - Path((w_id, client_name)): Path<(String, String)>, - Extension(base_url): Extension, +async fn list_connects( + Extension(clients): Extension>, +) -> error::JsonResult>> { + Ok(Json( + (&clients.connects) + .into_iter() + .map(|(k, v)| (k.to_owned(), v.scopes.clone())) + .collect::>>(), + )) +} + +async fn connect_slack( + Extension(clients): Extension>, cookies: Cookies, ) -> error::Result { - 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, ) -> error::Result { 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, + Extension(user_db): Extension, +) -> error::Result { + 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>, + Extension(clients): Extension>, Path(client_name): Path, cookies: Cookies, ) -> error::Result { - 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, state: Option, - error: Option, +} + +#[derive(Serialize)] +pub struct ConnectResponse { + token: String, } async fn connect_callback( - authed: Authed, - Path((w_id, client_name)): Path<(String, String)>, - Query(query): Query, cookies: Cookies, - Extension(user_db): Extension, - Extension(base_url): Extension, -) -> error::Result { - 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, + Json(oauth): Json, + Extension(clients): Extension>, +) -> error::JsonResult { + 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, + Extension(clients): Extension>, +) -> error::JsonResult { + 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, + Json(token): Json, + Extension(user_db): Extension, + authed: Authed, +) -> Result { 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, - Query(query): Query, + Json(callback): Json, cookies: Cookies, - Extension(clients): Extension>, + Extension(clients): Extension>, Extension(db): Extension, -) -> error::Result { - 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 { + 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 { + 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::>() - .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::>( + 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::(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 { let email = match client_name { - "github" => http_client - .get("https://api.github.com/user") - .bearer_auth(token) - .send() - .await - .map_err(to_anyhow)? - .json::() - .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( + http_client: &Client, + url: &str, + token: &str, +) -> error::Result { + Ok(http_client + .get(url) + .bearer_auth(token) + .send() + .await + .map_err(to_anyhow)? + .json::() + .await + .map_err(to_anyhow)?) +} + +fn oauth_redirect( + clients: &HashMap, + client_name: String, + cookies: Cookies, + scopes: Option>, +) -> error::Result { + 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 +} diff --git a/backend/src/resources.rs b/backend/src/resources.rs index 41e3dc4dd0..7f30a3c0f9 100644 --- a/backend/src/resources.rs +++ b/backend/src/resources.rs @@ -67,6 +67,7 @@ pub struct Resource { pub description: Option, pub resource_type: String, pub extra_perms: serde_json::Value, + pub account: Option, } #[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)) diff --git a/backend/src/users.rs b/backend/src/users.rs index fdbf03c2f3..3c06e7584a 100644 --- a/backend/src/users.rs +++ b/backend/src/users.rs @@ -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()), } diff --git a/backend/src/variables.rs b/backend/src/variables.rs index 0d4dcd83ef..011d62c624 100644 --- a/backend/src/variables.rs +++ b/backend/src/variables.rs @@ -50,6 +50,7 @@ pub struct ListableVariable { pub is_secret: bool, pub description: String, pub extra_perms: serde_json::Value, + pub account: Option, } #[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) diff --git a/frontend/src/lib/components/AppConnect.svelte b/frontend/src/lib/components/AppConnect.svelte new file mode 100644 index 0000000000..e404abaf65 --- /dev/null +++ b/frontend/src/lib/components/AppConnect.svelte @@ -0,0 +1,274 @@ + + + + + { + dispatch('close') + }} +> +
Connect an app
+
+ {#if step == 1} + +
+ {#each Object.entries(connects) as [key, values]} + + {/each} +
+ + {#if !manual && resource_type != ''} + {#each scopes as v} +
+ + +
+ {/each} + {(resource_type ?? []).length} item(s) + {:else} +

Pick an oauth app and customize the scopes here

+ {/if} + +
+ {#each connectsManual as [key, instructions]} + + {/each} +
+ {:else if step == 2} + {#if manual} + +
+ {apiTokenApps[resource_type].instructions} +
+ {#if apiTokenApps[resource_type].img} +
+ connect +
+ {/if} +
+ +
+ {/if} + {:else} + +
    +
  • + 1. A secret variable containing the token {truncateRev(value, 5, '*****')} + will be stored at + {path}. You can refer to this variable anywhere this token + is required. +
  • +
  • + 2. A resource with a unique token field will be stored at {path} + and refer to the secret variable {path} as its token (using + variable templating + `$var:${path}`). You can refer to this resource anywhere + this token is required. A script can use the resource type + {resource_type} as a type parameter to restrict the kind of + tokens it accepts to this app. +
  • +
+ {/if} +
+
+ {#if step > 1 && !no_back} + + {/if} + +
+
+ + diff --git a/frontend/src/routes/user/CenteredModal.svelte b/frontend/src/lib/components/CenteredModal.svelte similarity index 100% rename from frontend/src/routes/user/CenteredModal.svelte rename to frontend/src/lib/components/CenteredModal.svelte diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 80a6fbfd3b..e3421e4acc 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -1,8 +1,8 @@ diff --git a/frontend/src/lib/components/IconedResourceType.svelte b/frontend/src/lib/components/IconedResourceType.svelte index 71b7bbc1db..c562f17890 100644 --- a/frontend/src/lib/components/IconedResourceType.svelte +++ b/frontend/src/lib/components/IconedResourceType.svelte @@ -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 @@ {:else if name === 'slack'} + {:else if name === 'github'} + {/if} {#if after} {name} diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts index 93f4f95396..64a596b065 100644 --- a/frontend/src/lib/stores.ts +++ b/frontend/src/lib/stores.ts @@ -14,6 +14,7 @@ export interface UserExt { let persistedWorkspace = browser && localStorage.getItem('workspace') +export const oauthStore = writable(undefined) export const userStore = writable(undefined) export const workspaceStore = writable( persistedWorkspace ? String(persistedWorkspace) : undefined @@ -22,10 +23,10 @@ export const usersWorkspaceStore = writable(undef export const superadmin = writable(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) diff --git a/frontend/src/routes/__layout-root.svelte b/frontend/src/routes/__layout-root.svelte index 2e23df2332..5c81899dbd 100644 --- a/frontend/src/routes/__layout-root.svelte +++ b/frontend/src/routes/__layout-root.svelte @@ -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) + } } } diff --git a/frontend/src/routes/__layout@root.svelte b/frontend/src/routes/__layout@root.svelte index 841f1bf5ac..3cc9fa44cc 100644 --- a/frontend/src/routes/__layout@root.svelte +++ b/frontend/src/routes/__layout@root.svelte @@ -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') + } }) @@ -190,7 +183,7 @@
- {$userStore?.username ?? $superadmin ?? '___'} + {$userStore?.username ?? ($superadmin ? $superadmin : '___')} {#if $userStore?.is_admin} {/if} diff --git a/frontend/src/routes/connection_added.svelte b/frontend/src/routes/connection_added.svelte deleted file mode 100644 index 2c208ea2d5..0000000000 --- a/frontend/src/routes/connection_added.svelte +++ /dev/null @@ -1,31 +0,0 @@ - - - -
-

Connection added for {client_name}

-

- Redirecting in 5s to your workspace settings.
- 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. -

-
-
diff --git a/frontend/src/routes/index.svelte b/frontend/src/routes/index.svelte index c160bc91f9..02a2f48436 100644 --- a/frontend/src/routes/index.svelte +++ b/frontend/src/routes/index.svelte @@ -1,8 +1,15 @@ diff --git a/frontend/src/routes/oauth/callback/[client_name].svelte b/frontend/src/routes/oauth/callback/[client_name].svelte new file mode 100644 index 0000000000..1e0ff39eed --- /dev/null +++ b/frontend/src/routes/oauth/callback/[client_name].svelte @@ -0,0 +1,41 @@ + + + + +
+ +
+
diff --git a/frontend/src/routes/oauth/callback_slack.svelte b/frontend/src/routes/oauth/callback_slack.svelte new file mode 100644 index 0000000000..082b2239c6 --- /dev/null +++ b/frontend/src/routes/oauth/callback_slack.svelte @@ -0,0 +1,37 @@ + + + + +
+ +
+
diff --git a/frontend/src/routes/resources.svelte b/frontend/src/routes/resources.svelte index db958ffb7c..f6a8d111b3 100644 --- a/frontend/src/routes/resources.svelte +++ b/frontend/src/routes/resources.svelte @@ -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 { 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) + } + }) @@ -111,12 +124,20 @@ - +
+ + +
@@ -218,7 +239,7 @@ {#if resourceTypes} {#each resourceTypes as { name, description, schema, canWrite }} - { @@ -253,6 +274,7 @@ + { diff --git a/frontend/src/routes/user/create_workspace@user.svelte b/frontend/src/routes/user/create_workspace@user.svelte index face8b8fbb..4c7105a796 100644 --- a/frontend/src/routes/user/create_workspace@user.svelte +++ b/frontend/src/routes/user/create_workspace@user.svelte @@ -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 = '' diff --git a/frontend/src/routes/user/login@user.svelte b/frontend/src/routes/user/login@user.svelte index 17f1fb4d8a..ec2afc6c64 100644 --- a/frontend/src/routes/user/login@user.svelte +++ b/frontend/src/routes/user/login@user.svelte @@ -1,15 +1,15 @@ + + +
+ +
+
diff --git a/frontend/src/routes/user/settings@user.svelte b/frontend/src/routes/user/settings@user.svelte index e4e7b06dde..c46d5eb065 100644 --- a/frontend/src/routes/user/settings@user.svelte +++ b/frontend/src/routes/user/settings@user.svelte @@ -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 diff --git a/frontend/src/routes/user/superadmin_settings@user.svelte b/frontend/src/routes/user/superadmin_settings@user.svelte index b32e222f38..3ee29f1ba3 100644 --- a/frontend/src/routes/user/superadmin_settings@user.svelte +++ b/frontend/src/routes/user/superadmin_settings@user.svelte @@ -1,14 +1,10 @@ + {#if $userStore?.is_admin} @@ -179,19 +188,15 @@

Status: {#if team_name}Connected to slack workspace {team_name}{:else}Not connected{/if}

-
Connect to slack {#if team_name}