feat: dynamic load oauth login options if supported by gotrue

This commit is contained in:
Fu Zi Xiang 2023-11-22 22:45:15 +08:00
parent 47e49dc2dd
commit 39b5ca28ad
No known key found for this signature in database
14 changed files with 122 additions and 103 deletions

View File

@ -1,6 +1,7 @@
# Admin Frontend # Admin Frontend
## Partial Local Environment ## Partial Local Environment
- Go to source root folder of `AppFlowy-Cloud` - Go to source root folder of `AppFlowy-Cloud`
- Start running locally dependency servers: `docker compose --file docker-compose-dev.yml up -d` - Start running locally dependency servers: `docker compose --file docker-compose-dev.yml up -d`
- Start SQLX migrations `cargo sqlx database create && cargo sqlx migrate run && cargo sqlx prepare --workspace` - Start SQLX migrations `cargo sqlx database create && cargo sqlx migrate run && cargo sqlx prepare --workspace`
@ -9,6 +10,7 @@
- Run `cargo watch -x run -w .`, this watch for source changes, rebuild and rerun the app. - Run `cargo watch -x run -w .`, this watch for source changes, rebuild and rerun the app.
## Full Local Integration Environment ## Full Local Integration Environment
- Start the whole stack: `docker compose up -d` - Start the whole stack: `docker compose up -d`
- Go to [web server](localhost) - Go to [web server](localhost)
- After editing source files, do `docker compose up -d --no-deps --build admin_frontend` - After editing source files, do `docker compose up -d --no-deps --build admin_frontend`

View File

@ -1,2 +1,3 @@
# Discord # Discord
- Assets are derived from: https://discord.com/branding - Assets are derived from: https://discord.com/branding

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@ -1,2 +1,3 @@
# Github # Github
- Assets derived from: https://github.com/logos - Assets derived from: https://github.com/logos

View File

@ -1,2 +1,3 @@
# Google OAuth Sign Logo # Google OAuth Sign Logo
Assets in this directory are generated from: https://developers.google.com/identity/branding-guidelines Assets in this directory are generated from: https://developers.google.com/identity/branding-guidelines

View File

@ -7,7 +7,9 @@ pub struct ChangePassword;
#[derive(Template)] #[derive(Template)]
#[template(path = "pages/login.html")] #[template(path = "pages/login.html")]
pub struct Login; pub struct Login<'a> {
pub oauth_providers: Vec<&'a str>,
}
// #[derive(Template)] // #[derive(Template)]
// #[template(path = "login.html")] // #[template(path = "login.html")]

View File

@ -64,8 +64,10 @@ pub async fn user_user_handler(
render_template(templates::UserDetails { user: &user }) render_template(templates::UserDetails { user: &user })
} }
pub async fn login_handler() -> Result<Html<String>, WebAppError> { pub async fn login_handler(State(state): State<AppState>) -> Result<Html<String>, WebAppError> {
render_template(templates::Login {}) let external = state.gotrue_client.settings().await?.external;
let oauth_providers = external.oauth_providers();
render_template(templates::Login { oauth_providers })
} }
pub async fn user_change_password_handler() -> Result<Html<String>, WebAppError> { pub async fn user_change_password_handler() -> Result<Html<String>, WebAppError> {

View File

@ -16,9 +16,7 @@
<tr> <tr>
<td></td> <td></td>
<td style="text-align: right"> <td style="text-align: right">
<button class="button cyan" type="submit"> <button class="button cyan" type="submit">Invite</button>
Invite
</button>
</td> </td>
</tr> </tr>
</table> </table>

View File

@ -24,15 +24,15 @@
</html> </html>
<script> <script>
document.body.addEventListener('htmx:beforeRequest', function(event) { document.body.addEventListener("htmx:beforeRequest", function (event) {
const closeButton = event.target.querySelector('.button'); const closeButton = event.target.querySelector(".button");
closeButton.classList.add('loading-button'); closeButton.classList.add("loading-button");
closeButton.disabled = true; closeButton.disabled = true;
}); });
document.body.addEventListener("htmx:afterRequest", function (evt) { document.body.addEventListener("htmx:afterRequest", function (evt) {
const closeButton = event.target.querySelector('.button'); const closeButton = event.target.querySelector(".button");
closeButton.classList.remove('loading-button'); closeButton.classList.remove("loading-button");
closeButton.disabled = false; closeButton.disabled = false;
const detail = evt.detail; const detail = evt.detail;

View File

@ -58,8 +58,10 @@
Sign In / Sign Up Sign In / Sign Up
</button> </button>
</form> </form>
<br />
<!-- Load OAuth Providers if configured -->
{% if oauth_providers.len() > 0 %}
<br />
<table style="width: 100%"> <table style="width: 100%">
<tr style="display: flex"> <tr style="display: flex">
<td style="width: 100%; margin: auto"><hr /></td> <td style="width: 100%; margin: auto"><hr /></td>
@ -70,42 +72,40 @@
<h3>OAuth Login</h3> <h3>OAuth Login</h3>
<div id="oauth-container"> <div id="oauth-container">
{% for provider in oauth_providers %}
<div class="oauth-icon"> <div class="oauth-icon">
<a href="/gotrue/authorize?provider=google&redirect_to=/web/login"> <a
{% include "../assets/google/logo.html" %} href="/gotrue/authorize?provider={{ provider }}&redirect_to=/web/login"
</a> >
</div> <div
<div class="oauth-icon"> hx-get="../assets/{{ provider }}/logo.html"
<a href="/gotrue/authorize?provider=discord&redirect_to=/web/login"> hx-trigger="load"
{% include "../assets/discord/logo.html" %} hx-swap="outerHTML"
</a> ></div>
</div>
<div class="oauth-icon">
<a href="/gotrue/authorize?provider=github&redirect_to=/web/login">
{% include "../assets/github/logo.html" %}
</a> </a>
</div> </div>
{% endfor %} {% endif %}
</div> </div>
</div> </div>
</div>
<script> <script>
// OAuthLogin // OAuthLogin
if (window.location.hash) { if (window.location.hash) {
// Extract data from the URL fragment // Extract data from the URL fragment
const fragmentData = window.location.hash.substring(1); // Remove the leading # const fragmentData = window.location.hash.substring(1); // Remove the leading #
const fragmentParams = new URLSearchParams(fragmentData); // Parse the fragment data as a URLSearchParams object const fragmentParams = new URLSearchParams(fragmentData); // Parse the fragment data as a URLSearchParams object
const refreshToken = fragmentParams.get("refresh_token"); // Extract the refresh_token const refreshToken = fragmentParams.get("refresh_token"); // Extract the refresh_token
fetch(`/web-api/login_refresh/${refreshToken}`, { fetch(`/web-api/login_refresh/${refreshToken}`, {
// Login in via refresh_token // Login in via refresh_token
method: "POST", method: "POST",
}).then((response) => { }).then((response) => {
if (!response.ok) { if (!response.ok) {
displayHttpStatusAndPayload(response); displayHttpStatusAndPayload(response);
} else { } else {
window.location.href = "/web/home"; window.location.href = "/web/home";
} }
}); });
} }
</script> </script>
{% endblock %} {% endblock %}
</div>

View File

@ -44,7 +44,7 @@ use url::Url;
use crate::retry::{RefreshTokenAction, RefreshTokenRetryCondition}; use crate::retry::{RefreshTokenAction, RefreshTokenRetryCondition};
use crate::ws::{WSClientHttpSender, WSError}; use crate::ws::{WSClientHttpSender, WSError};
use gotrue_entity::dto::SignUpResponse::{Authenticated, NotAuthenticated}; use gotrue_entity::dto::SignUpResponse::{Authenticated, NotAuthenticated};
use gotrue_entity::dto::{GotrueTokenResponse, OAuthProvider, UpdateGotrueUserParams, User}; use gotrue_entity::dto::{GotrueTokenResponse, AuthProvider, UpdateGotrueUserParams, User};
use realtime_entity::realtime_proto::HttpRealtimeMessage; use realtime_entity::realtime_proto::HttpRealtimeMessage;
/// `Client` is responsible for managing communication with the GoTrue API and cloud storage. /// `Client` is responsible for managing communication with the GoTrue API and cloud storage.
@ -210,7 +210,7 @@ impl Client {
#[instrument(level = "debug", skip_all, err)] #[instrument(level = "debug", skip_all, err)]
pub async fn generate_oauth_url_with_provider( pub async fn generate_oauth_url_with_provider(
&self, &self,
provider: &OAuthProvider, provider: &AuthProvider,
) -> Result<String, AppResponseError> { ) -> Result<String, AppResponseError> {
let settings = self.gotrue_client.settings().await?; let settings = self.gotrue_client.settings().await?;
if !settings.external.has_provider(provider) { if !settings.external.has_provider(provider) {
@ -225,7 +225,7 @@ impl Client {
.append_pair("provider", provider.as_str()) .append_pair("provider", provider.as_str())
.append_pair("redirect_to", DESKTOP_CALLBACK_URL); .append_pair("redirect_to", DESKTOP_CALLBACK_URL);
if let OAuthProvider::Google = provider { if let AuthProvider::Google = provider {
url url
.query_pairs_mut() .query_pairs_mut()
// In many cases, especially for server-side applications or mobile apps that might need to // In many cases, especially for server-side applications or mobile apps that might need to

View File

@ -103,16 +103,30 @@ pub struct GoTrueSettings {
pub struct GoTrueOAuthProviderSettings(BTreeMap<String, bool>); pub struct GoTrueOAuthProviderSettings(BTreeMap<String, bool>);
impl GoTrueOAuthProviderSettings { impl GoTrueOAuthProviderSettings {
pub fn has_provider(&self, p: &OAuthProvider) -> bool { pub fn has_provider(&self, p: &AuthProvider) -> bool {
let a = self.0.get(p.as_str()); let a = self.0.get(p.as_str());
match a { match a {
Some(v) => *v, Some(v) => *v,
None => false, None => false,
} }
} }
pub fn oauth_providers(&self) -> Vec<&str> {
self
.0
.iter()
.filter(|&(key, &value)| value && key != "email" && key != "phone")
.map(|(key, _value)| key.as_str())
.collect()
}
} }
pub enum OAuthProvider { pub enum AuthProvider {
// Non-OAuth providers
Email,
Phone,
// OAuth providers
Apple, Apple,
Azure, Azure,
Bitbucket, Bitbucket,
@ -131,63 +145,61 @@ pub enum OAuthProvider {
Workos, Workos,
Twitch, Twitch,
Twitter, Twitter,
Email,
Phone,
Zoom, Zoom,
} }
impl OAuthProvider { impl AuthProvider {
pub fn as_str(&self) -> &str { pub fn as_str(&self) -> &str {
match self { match self {
OAuthProvider::Apple => "apple", AuthProvider::Apple => "apple",
OAuthProvider::Azure => "azure", AuthProvider::Azure => "azure",
OAuthProvider::Bitbucket => "bitbucket", AuthProvider::Bitbucket => "bitbucket",
OAuthProvider::Discord => "discord", AuthProvider::Discord => "discord",
OAuthProvider::Facebook => "facebook", AuthProvider::Facebook => "facebook",
OAuthProvider::Figma => "figma", AuthProvider::Figma => "figma",
OAuthProvider::Github => "github", AuthProvider::Github => "github",
OAuthProvider::Gitlab => "gitlab", AuthProvider::Gitlab => "gitlab",
OAuthProvider::Google => "google", AuthProvider::Google => "google",
OAuthProvider::Keycloak => "keycloak", AuthProvider::Keycloak => "keycloak",
OAuthProvider::Kakao => "kakao", AuthProvider::Kakao => "kakao",
OAuthProvider::Linkedin => "linkedin", AuthProvider::Linkedin => "linkedin",
OAuthProvider::Notion => "notion", AuthProvider::Notion => "notion",
OAuthProvider::Spotify => "spotify", AuthProvider::Spotify => "spotify",
OAuthProvider::Slack => "slack", AuthProvider::Slack => "slack",
OAuthProvider::Workos => "workos", AuthProvider::Workos => "workos",
OAuthProvider::Twitch => "twitch", AuthProvider::Twitch => "twitch",
OAuthProvider::Twitter => "twitter", AuthProvider::Twitter => "twitter",
OAuthProvider::Email => "email", AuthProvider::Email => "email",
OAuthProvider::Phone => "phone", AuthProvider::Phone => "phone",
OAuthProvider::Zoom => "zoom", AuthProvider::Zoom => "zoom",
} }
} }
} }
impl OAuthProvider { impl AuthProvider {
pub fn from<A: AsRef<str>>(value: A) -> Option<OAuthProvider> { pub fn from<A: AsRef<str>>(value: A) -> Option<AuthProvider> {
match value.as_ref() { match value.as_ref() {
"apple" => Some(OAuthProvider::Apple), "apple" => Some(AuthProvider::Apple),
"azure" => Some(OAuthProvider::Azure), "azure" => Some(AuthProvider::Azure),
"bitbucket" => Some(OAuthProvider::Bitbucket), "bitbucket" => Some(AuthProvider::Bitbucket),
"discord" => Some(OAuthProvider::Discord), "discord" => Some(AuthProvider::Discord),
"facebook" => Some(OAuthProvider::Facebook), "facebook" => Some(AuthProvider::Facebook),
"figma" => Some(OAuthProvider::Figma), "figma" => Some(AuthProvider::Figma),
"github" => Some(OAuthProvider::Github), "github" => Some(AuthProvider::Github),
"gitlab" => Some(OAuthProvider::Gitlab), "gitlab" => Some(AuthProvider::Gitlab),
"google" => Some(OAuthProvider::Google), "google" => Some(AuthProvider::Google),
"keycloak" => Some(OAuthProvider::Keycloak), "keycloak" => Some(AuthProvider::Keycloak),
"kakao" => Some(OAuthProvider::Kakao), "kakao" => Some(AuthProvider::Kakao),
"linkedin" => Some(OAuthProvider::Linkedin), "linkedin" => Some(AuthProvider::Linkedin),
"notion" => Some(OAuthProvider::Notion), "notion" => Some(AuthProvider::Notion),
"spotify" => Some(OAuthProvider::Spotify), "spotify" => Some(AuthProvider::Spotify),
"slack" => Some(OAuthProvider::Slack), "slack" => Some(AuthProvider::Slack),
"workos" => Some(OAuthProvider::Workos), "workos" => Some(AuthProvider::Workos),
"twitch" => Some(OAuthProvider::Twitch), "twitch" => Some(AuthProvider::Twitch),
"twitter" => Some(OAuthProvider::Twitter), "twitter" => Some(AuthProvider::Twitter),
"email" => Some(OAuthProvider::Email), "email" => Some(AuthProvider::Email),
"phone" => Some(OAuthProvider::Phone), "phone" => Some(AuthProvider::Phone),
"zoom" => Some(OAuthProvider::Zoom), "zoom" => Some(AuthProvider::Zoom),
_ => None, _ => None,
} }
} }

View File

@ -4,7 +4,7 @@ use crate::params::{
}; };
use anyhow::Context; use anyhow::Context;
use gotrue_entity::dto::{ use gotrue_entity::dto::{
AdminListUsersResponse, GoTrueSettings, GotrueTokenResponse, OAuthProvider, SignUpResponse, AdminListUsersResponse, AuthProvider, GoTrueSettings, GotrueTokenResponse, SignUpResponse,
UpdateGotrueUserParams, User, UpdateGotrueUserParams, User,
}; };
use gotrue_entity::error::{GoTrueError, GoTrueErrorSerde, GotrueClientError}; use gotrue_entity::error::{GoTrueError, GoTrueErrorSerde, GotrueClientError};
@ -24,7 +24,7 @@ impl Client {
} }
} }
pub fn oauth_url(&self, provider: &OAuthProvider) -> String { pub fn oauth_url(&self, provider: &AuthProvider) -> String {
format!("{}/authorize?provider={}", self.base_url, provider.as_str()) format!("{}/authorize?provider={}", self.base_url, provider.as_str())
} }

View File

@ -1,5 +1,5 @@
use app_error::ErrorCode; use app_error::ErrorCode;
use gotrue_entity::dto::OAuthProvider; use gotrue_entity::dto::AuthProvider;
use crate::{ use crate::{
localhost_client, test_appflowy_cloud_client, localhost_client, test_appflowy_cloud_client,
@ -52,7 +52,7 @@ async fn sign_up_but_existing_user() {
async fn sign_up_oauth_not_available() { async fn sign_up_oauth_not_available() {
let c = localhost_client(); let c = localhost_client();
let err = c let err = c
.generate_oauth_url_with_provider(&OAuthProvider::Zoom) .generate_oauth_url_with_provider(&AuthProvider::Zoom)
.await .await
.err() .err()
.unwrap(); .unwrap();
@ -68,14 +68,14 @@ async fn sign_up_oauth_not_available() {
async fn sign_up_with_google_oauth() { async fn sign_up_with_google_oauth() {
let c = localhost_client(); let c = localhost_client();
let url = c let url = c
.generate_oauth_url_with_provider(&OAuthProvider::Google) .generate_oauth_url_with_provider(&AuthProvider::Google)
.await .await
.unwrap(); .unwrap();
assert!(!url.is_empty()); assert!(!url.is_empty());
let c = test_appflowy_cloud_client(); let c = test_appflowy_cloud_client();
let url = c let url = c
.generate_oauth_url_with_provider(&OAuthProvider::Google) .generate_oauth_url_with_provider(&AuthProvider::Google)
.await .await
.unwrap(); .unwrap();
assert!(!url.is_empty()); assert!(!url.is_empty());