feat: issue#1173 allow admin frontend to be served on a different path

This commit is contained in:
khorshuheng 2025-01-23 14:48:52 +08:00
parent b7e08687b4
commit a352b36eb9
37 changed files with 312 additions and 167 deletions

View file

@ -8,6 +8,7 @@ pub struct Config {
pub gotrue_url: String,
pub appflowy_cloud_url: String,
pub oauth: OAuthConfig,
pub path_prefix: String,
}
#[derive(Debug, Clone)]
@ -41,6 +42,7 @@ impl Config {
.map(|s| s.to_string())
.collect(),
},
path_prefix: get_or_default("ADMIN_FRONTEND_PATH_PREFIX", ""),
};
Ok(cfg)
}

View file

@ -46,7 +46,7 @@ impl From<redis::RedisError> for WebApiError<'_> {
pub enum WebAppError {
Askama(askama::Error),
GoTrue(gotrue_entity::error::GoTrueError),
LoginRedirectRequired(String),
ExtApi(ext::error::Error),
Redis(redis::RedisError),
BadRequest(String),
@ -59,9 +59,8 @@ impl IntoResponse for WebAppError {
tracing::error!("askama error: {:?}", e);
status::StatusCode::INTERNAL_SERVER_ERROR.into_response()
},
WebAppError::GoTrue(e) => {
tracing::error!("gotrue error: {:?}", e);
Redirect::to("/login").into_response()
WebAppError::LoginRedirectRequired(base_path) => {
Redirect::to(&format!("{}/login", base_path)).into_response()
},
WebAppError::ExtApi(e) => e.into_response(),
WebAppError::Redis(e) => {
@ -88,12 +87,6 @@ impl From<askama::Error> for WebAppError {
}
}
impl From<gotrue_entity::error::GoTrueError> for WebAppError {
fn from(v: gotrue_entity::error::GoTrueError) -> Self {
WebAppError::GoTrue(v)
}
}
impl From<ext::error::Error> for WebAppError {
fn from(v: ext::error::Error) -> Self {
WebAppError::ExtApi(v)

View file

@ -49,6 +49,7 @@ async fn main() {
let session_store = session::SessionStorage::new(redis_client);
let address = format!("{}:{}", config.host, config.port);
let path_prefix = config.path_prefix.clone();
let state = AppState {
appflowy_cloud_url: config.appflowy_cloud_url.clone(),
gotrue_client,
@ -57,17 +58,33 @@ async fn main() {
};
let web_app_router = web_app::router(state.clone()).with_state(state.clone());
let web_api_router = web_api::router().with_state(state);
let web_api_router = web_api::router().with_state(state.clone());
let app = Router::new()
let favicon_redirect_url = state.prepend_with_path_prefix("/assets/favicon.ico");
let base_path_redirect_url = state.prepend_with_path_prefix("/web");
let base_app = Router::new()
.route(
"/favicon.ico",
get(|| async { Redirect::permanent("/assets/favicon.ico") }),
get(|| async {
let favicon_redirect_url = favicon_redirect_url;
Redirect::permanent(&favicon_redirect_url)
}),
)
.route(
"/",
get(|| async {
let base_path_redirect_url = base_path_redirect_url;
Redirect::permanent(&base_path_redirect_url)
}),
)
.route("/", get(|| async { Redirect::permanent("/web") }))
.nest_service("/web", web_app_router)
.nest_service("/web-api", web_api_router)
.nest_service("/assets", ServeDir::new("assets"));
let app = if path_prefix.is_empty() {
base_app
} else {
Router::new().nest(&path_prefix, base_app)
};
let listener = TcpListener::bind(address)
.await

View file

@ -10,6 +10,12 @@ pub struct AppState {
pub config: Config,
}
impl AppState {
pub fn prepend_with_path_prefix(&self, path: &str) -> String {
format!("{}{}", self.config.path_prefix, path)
}
}
#[derive(Serialize, Deserialize)]
pub struct WebApiLoginRequest {
pub email: String,

View file

@ -140,7 +140,8 @@ impl FromRequestParts<AppState> for UserSession {
Ok(jar) => jar,
Err(err) => {
tracing::error!("failed to get cookie jar, error: {}", err);
return Err(Redirect::to("/web/login").into_response());
let redirect_url = state.prepend_with_path_prefix("/web/login");
return Err(Redirect::to(&redirect_url).into_response());
},
};
@ -156,8 +157,15 @@ impl FromRequestParts<AppState> for UserSession {
.map(|uri| urlencoding::encode(&uri.to_string()).to_string());
match original_url {
Some(url) => Err(Redirect::to(&format!("/web/login-v2?redirect_to={}", url)).into_response()),
None => Err(Redirect::to("/web/login").into_response()),
Some(url) => {
let redirect_url =
state.prepend_with_path_prefix(&format!("/web/login-v2?redirect_to={}", url));
Err(Redirect::to(&redirect_url).into_response())
},
None => {
let redirect_url = state.prepend_with_path_prefix("/web/login");
Err(Redirect::to(&redirect_url).into_response())
},
}
}
}

View file

@ -60,9 +60,10 @@ pub struct ChangePassword;
#[derive(Template)]
#[template(path = "pages/login.html")]
pub struct Login<'a> {
pub path_prefix: &'a str,
pub oauth_providers: &'a [&'a str],
pub redirect_to: Option<&'a str>,
pub oauth_redirect_to: Option<&'a str>,
pub oauth_redirect_to: &'a str,
}
#[derive(Template)]
@ -70,7 +71,8 @@ pub struct Login<'a> {
pub struct LoginV2<'a> {
pub oauth_providers: &'a [&'a str],
pub redirect_to: Option<&'a str>,
pub oauth_redirect_to: Option<&'a str>,
pub oauth_redirect_to: &'a str,
pub path_prefix: &'a str,
}
#[derive(Template)]
@ -78,6 +80,7 @@ pub struct LoginV2<'a> {
pub struct Home<'a> {
pub user: &'a User,
pub is_admin: bool,
pub path_prefix: &'a str,
}
#[derive(Template)]
@ -109,6 +112,7 @@ pub struct Navigate;
#[derive(Template)]
#[template(path = "pages/admin_home.html")]
pub struct AdminHome<'a> {
pub path_prefix: &'a str,
pub user: &'a User,
}

View file

@ -133,7 +133,8 @@ async fn delete_account_handler(
session: UserSession,
) -> Result<axum::response::Response, WebApiError<'static>> {
delete_current_user(&session.token.access_token, &state.appflowy_cloud_url).await?;
Ok(Redirect::to("/web/login").into_response())
let redirect_url = state.prepend_with_path_prefix("/web/login");
Ok(Redirect::to(&redirect_url).into_response())
}
// Invite another user, this will trigger email sending
@ -142,6 +143,11 @@ async fn invite_handler(
State(state): State<AppState>,
Form(param): Form<WebApiInviteUserRequest>,
) -> Result<WebApiResponse<()>, WebApiError<'static>> {
let magic_link_redirect = if state.config.path_prefix.is_empty() {
"/".to_owned()
} else {
state.config.path_prefix.clone()
};
state
.gotrue_client
.magic_link(
@ -149,7 +155,7 @@ async fn invite_handler(
email: param.email,
..Default::default()
},
Some("/".to_owned()),
Some(magic_link_redirect),
)
.await?;
Ok(WebApiResponse::<()>::from_str("Invitation sent".into()))
@ -558,10 +564,11 @@ async fn logout_handler(
.value();
state.session_store.del_user_session(session_id).await?;
let htmx_redirect_url = format!("{}/web/login", state.config.path_prefix);
Ok(
(
jar.remove(Cookie::from("session_id")),
htmx_redirect("/web/login"),
htmx_redirect(&htmx_redirect_url),
)
.into_response(),
)
@ -599,11 +606,15 @@ async fn session_login(
None
},
});
let default_htmx_redirect_url = format!("{}/web/home", state.config.path_prefix);
Ok(
(
jar.add(new_session_cookie(new_session_id)),
htmx_redirect(decoded_redirect_to.as_deref().unwrap_or("/web/home")),
htmx_redirect(
decoded_redirect_to
.as_deref()
.unwrap_or(&default_htmx_redirect_url),
),
)
.into_response(),
)
@ -620,7 +631,7 @@ async fn send_magic_link(
email: email.to_owned(),
..Default::default()
},
Some("/web/login-callback".to_owned()),
Some(format!("{}/web/login-callback", state.config.path_prefix)),
)
.await?;
Ok(WebApiResponse::<()>::from_str("Magic Link Sent".into()))

View file

@ -17,6 +17,8 @@ use gotrue_entity::dto::User;
use crate::{templates, AppState};
static DEFAULT_OAUTH_REDIRECT_TO_WITHOUT_PREFIX: &str = "/web/login-callback";
pub fn router(state: AppState) -> Router<AppState> {
Router::new()
.nest_service("/", page_router().with_state(state.clone()))
@ -118,7 +120,8 @@ async fn login_callback_query_handler(
.token(&gotrue::grant::Grant::RefreshToken(
gotrue::grant::RefreshTokenGrant { refresh_token },
))
.await?;
.await
.map_err(|_| WebAppError::LoginRedirectRequired(state.config.path_prefix.clone()))?;
verify_token_cloud(
token.access_token.as_str(),
@ -205,7 +208,8 @@ async fn admin_sso_detail_handler(
let sso_provider = state
.gotrue_client
.admin_get_sso_provider(&session.token.access_token, &sso_provider_id)
.await?;
.await
.map_err(|_| WebAppError::LoginRedirectRequired(state.config.path_prefix.clone()))?;
let mapping_json =
serde_json::to_string_pretty(&sso_provider.saml.attribute_mapping).unwrap_or("".to_owned());
@ -227,7 +231,8 @@ async fn admin_sso_handler(
let sso_providers = state
.gotrue_client
.admin_list_sso_providers(&session.token.access_token)
.await?
.await
.map_err(|_| WebAppError::LoginRedirectRequired(state.config.path_prefix.clone()))?
.items
.unwrap_or_default();
@ -354,7 +359,8 @@ async fn user_user_handler(
let user = state
.gotrue_client
.user_info(&session.token.access_token)
.await?;
.await
.map_err(|_| WebAppError::LoginRedirectRequired(state.config.path_prefix.clone()))?;
render_template(templates::UserDetails { user: &user })
}
@ -368,38 +374,62 @@ async fn login_handler(
.map(|r| urlencoding::encode(r).to_string());
let oauth_redirect_to = login.redirect_to.as_ref().map(|r| {
urlencoding::encode(&format!(
"/web/login-callback?redirect_to={}",
"{}/web/login-callback?redirect_to={}",
state.config.path_prefix,
urlencoding::encode(r)
))
.to_string()
});
let external = state.gotrue_client.settings().await?.external;
let external = state
.gotrue_client
.settings()
.await
.map_err(|_| WebAppError::LoginRedirectRequired(state.config.path_prefix.clone()))?
.external;
let oauth_providers = external.oauth_providers();
let default_oauth_redirect_to = format!(
"{}{}",
state.config.path_prefix, DEFAULT_OAUTH_REDIRECT_TO_WITHOUT_PREFIX
);
render_template(templates::Login {
path_prefix: &state.config.path_prefix,
oauth_providers: &oauth_providers,
redirect_to: redirect_to.as_deref(),
oauth_redirect_to: oauth_redirect_to.as_deref(),
oauth_redirect_to: oauth_redirect_to
.as_deref()
.unwrap_or(&default_oauth_redirect_to),
})
}
async fn login_v2_handler(Query(login): Query<LoginParams>) -> Result<Html<String>, WebAppError> {
async fn login_v2_handler(
State(state): State<AppState>,
Query(login): Query<LoginParams>,
) -> Result<Html<String>, WebAppError> {
let redirect_to = login
.redirect_to
.as_ref()
.map(|r| urlencoding::encode(r).to_string());
let oauth_redirect_to = login.redirect_to.as_ref().map(|r| {
urlencoding::encode(&format!(
"/web/login-callback?redirect_to={}",
"{}/web/login-callback?redirect_to={}",
state.config.path_prefix,
urlencoding::encode(r)
))
.to_string()
});
let default_oauth_redirect_to = format!(
"{}{}",
state.config.path_prefix, DEFAULT_OAUTH_REDIRECT_TO_WITHOUT_PREFIX
);
render_template(templates::LoginV2 {
oauth_providers: &["Google", "Apple", "Github", "Discord"],
redirect_to: redirect_to.as_deref(),
oauth_redirect_to: oauth_redirect_to.as_deref(),
oauth_redirect_to: oauth_redirect_to
.as_deref()
.unwrap_or(&default_oauth_redirect_to),
path_prefix: &state.config.path_prefix,
})
}
@ -412,18 +442,21 @@ pub async fn home_handler(
session: Option<UserSession>,
jar: CookieJar,
) -> Result<axum::response::Response, WebAppError> {
let redirect_url = state.prepend_with_path_prefix("/web/login");
let session = match session {
Some(session) => session,
None => return Ok(Redirect::to("/web/login").into_response()),
None => return Ok(Redirect::to(&redirect_url).into_response()),
};
let user = state
.gotrue_client
.user_info(&session.token.access_token)
.await?;
.await
.map_err(|_| WebAppError::LoginRedirectRequired(state.config.path_prefix.clone()))?;
let home_html_str = render_template(templates::Home {
user: &user,
is_admin: is_admin(&user),
path_prefix: &state.config.path_prefix,
})?;
Ok((jar, home_html_str).into_response())
}
@ -435,8 +468,12 @@ async fn admin_home_handler(
let user = state
.gotrue_client
.user_info(&session.token.access_token)
.await?;
render_template(templates::AdminHome { user: &user })
.await
.map_err(|_| WebAppError::LoginRedirectRequired(state.config.path_prefix.clone()))?;
render_template(templates::AdminHome {
user: &user,
path_prefix: &state.config.path_prefix,
})
}
async fn admin_users_handler(
@ -469,7 +506,8 @@ async fn admin_user_details_handler(
let user = state
.gotrue_client
.admin_user_details(&session.token.access_token, &user_id)
.await?;
.await
.map_err(|_| WebAppError::LoginRedirectRequired(state.config.path_prefix.clone()))?;
render_template(templates::AdminUserDetails { user: &user })
}

View file

@ -4,35 +4,35 @@
<div
class="sidebar-item"
hx-target="#sidebar-content"
hx-get="/web/components/admin/navigate"
hx-get="../../web/components/admin/navigate"
>
Navigate
</div>
<div
class="sidebar-item"
hx-target="#sidebar-content"
hx-get="/web/components/admin/users"
hx-get="../../web/components/admin/users"
>
List Users
</div>
<div
class="sidebar-item"
hx-target="#sidebar-content"
hx-get="/web/components/admin/users/create"
hx-get="../../web/components/admin/users/create"
>
Create User
</div>
<div
class="sidebar-item"
hx-target="#sidebar-content"
hx-get="/web/components/admin/sso"
hx-get="../../web/components/admin/sso"
>
List SSO
</div>
<div
class="sidebar-item"
hx-target="#sidebar-content"
hx-get="/web/components/admin/sso/create"
hx-get="../../web/components/admin/sso/create"
>
Create SSO
</div>

View file

@ -1,6 +1,6 @@
<div>
<h4>Please enter the following information to create new SSO</h4>
<form hx-post="/web-api/admin/sso" hx-target="#none">
<form hx-post="../../web-api/admin/sso" hx-target="#none">
<table>
<tr>
<td>Email</td>

View file

@ -15,13 +15,13 @@
<button
class="button cyan"
hx-target="#sso-list"
hx-get="/web/components/admin/sso/{{ sso_provider.id|escape }}"
hx-get="../../web/components/admin/sso/{{ sso_provider.id|escape }}"
>
More Info
</button>
<button
class="deleteUserBtn button red"
hx-delete="/web-api/admin/sso/{{ sso_provider.id|escape }}"
hx-delete="../../web-api/admin/sso/{{ sso_provider.id|escape }}"
hx-confirm="Are you sure?"
hx-target="closest tr"
hx-swap="delete"

View file

@ -5,7 +5,7 @@
<div
hx-target="#sidebar-content"
hx-get="/web/components/user/user"
hx-get="../../web/components/user/user"
class="button red"
>
{{ user.email|escape }}
@ -18,11 +18,11 @@
document
.getElementById("adminBtn")
.addEventListener("click", function () {
window.location.href = "/web/home";
window.location.href = "{{ path_prefix }}/web/home";
});
</script>
<div class="button yellow" id="logoutBtn" hx-post="/web-api/logout">
<div class="button yellow" id="logoutBtn" hx-post="../../web-api/logout">
Logout
</div>
</div>

View file

@ -2,7 +2,10 @@
{% include "user_details.html" %}
<div>
<form hx-put="/web-api/admin/user/{{ user.id|escape }}" hx-target="#none">
<form
hx-put="../../web-api/admin/user/{{ user.id|escape }}"
hx-target="#none"
>
<table>
<tr>
<td>Set Password:</td>
@ -30,7 +33,7 @@
<td>
<button
class="button cyan"
hx-post="/web-api/admin/user/{{ user.email|escape }}/generate-link"
hx-post="../../web-api/admin/user/{{ user.email|escape }}/generate-link"
hx-target="#inviteLink"
hx-trigger="click"
>

View file

@ -14,13 +14,13 @@
<button
class="button cyan"
hx-target="#admin-users"
hx-get="/web/components/admin/users/{{ user.id|escape }}"
hx-get="../../web/components/admin/users/{{ user.id|escape }}"
>
More Info
</button>
<button
class="deleteUserBtn button red"
hx-delete="/web-api/admin/user/{{ user.id|escape }}"
hx-delete="../../web-api/admin/user/{{ user.id|escape }}"
hx-confirm="Are you sure?"
hx-target="closest tr"
hx-swap="delete"

View file

@ -1,6 +1,6 @@
<div>
<h3>Password Change</h3>
<form hx-post="/web-api/change-password" hx-target="#none">
<form hx-post="../web-api/change-password" hx-target="#none">
<table>
<tr>
<td>New Password:</td>

View file

@ -1,6 +1,6 @@
<div id="create-user">
<h4>Please enter the following information to create a new user</h4>
<form hx-post="/web-api/admin/user" hx-target="#none">
<form hx-post="../../web-api/admin/user" hx-target="#none">
<table>
<tr>
<td>Email:</td>

View file

@ -1,6 +1,6 @@
<div id="invite-user">
<h4>Invite another user to AppFlowy</h4>
<form hx-post="/web-api/invite" hx-target="#none">
<form hx-post="../web-api/invite" hx-target="#none">
<table>
<tr>
<td>Email:</td>
@ -25,7 +25,7 @@
<br />
<h4>Workspaces shared with you</h4>
<div
hx-get="/web/components/user/shared-workspaces"
hx-get="../web/components/user/shared-workspaces"
hx-trigger="workspaceInvitationAccepted from:body"
hx-swap="innerHTML"
>
@ -35,23 +35,26 @@
<br />
<h4>Invite another user to your workspace</h4>
<table class="red-table table">
<thead>
<thead>
<tr>
<th>Workspace Name</th>
<th>Members</th>
<th>Invite</th>
</tr>
</thead>
{% for owned_workspace in owned_workspaces %}
<tr>
<th>Workspace Name</th>
<th>Members</th>
<th>Invite</th>
</tr>
</thead>
{% for owned_workspace in owned_workspaces %}
<tr>
<td> {{ owned_workspace.workspace.workspace_name|escape }} </td>
<td>{{ owned_workspace.workspace.workspace_name|escape }}</td>
<td>
{% for member in owned_workspace.members %}
{{ member.email|escape }} <br />
{% for member in owned_workspace.members %} {{ member.email|escape }}
<br />
{% endfor %}
</td>
<td>
<form hx-post="/web-api/workspace/{{ owned_workspace.workspace.workspace_id|escape }}/invite" hx-target="#none">
<form
hx-post="../web-api/workspace/{{ owned_workspace.workspace.workspace_id|escape }}/invite"
hx-target="#none"
>
<input
class="input"
name="email"
@ -62,26 +65,30 @@
</form>
</td>
</tr>
{% endfor %}
{% endfor %}
</table>
<br />
<h4>Invitation(s) from other user(s)</h4>
<table class="purple-table table">
<thead>
<thead>
<tr>
<th>Workspace Name</th>
<th>Inviter</th>
<th>Action</th>
</tr>
</thead>
{% for pending_workspace_invitation in pending_workspace_invitations %}
<tr>
<th>Workspace Name</th>
<th>Inviter</th>
<th>Action</th>
</tr>
</thead>
{% for pending_workspace_invitation in pending_workspace_invitations %}
<tr>
<td> {{ pending_workspace_invitation.workspace_name|default("")|escape }} </td>
<td> {{ pending_workspace_invitation.inviter_email|default("")|escape }} </td>
<td>
{{ pending_workspace_invitation.workspace_name|default("")|escape }}
</td>
<td>
{{ pending_workspace_invitation.inviter_email|default("")|escape }}
</td>
<td>
<form
hx-post="/web-api/invite/{{ pending_workspace_invitation.invite_id|escape }}/accept"
hx-post="../web-api/invite/{{ pending_workspace_invitation.invite_id|escape }}/accept"
hx-target="closest tr"
hx-swap="delete"
>
@ -89,7 +96,6 @@
</form>
</td>
</tr>
{% endfor %}
{% endfor %}
</table>
</div>

View file

@ -2,7 +2,7 @@
<tr class="nav-item">
<td>Open AppFlowy</td>
<td>
<div hx-post="/web-api/open_app" class="svg-container button">
<div hx-post="../web-api/open_app" class="svg-container button">
{% include "../assets/logo.html" %}
</div>
</td>
@ -10,7 +10,10 @@
<tr class="nav-item">
<td>Download AppFlowy</td>
<td>
<div onclick="window.location.href='https://appflowy.io/download';" class="svg-container button">
<div
onclick="window.location.href='https://appflowy.io/download';"
class="svg-container button"
>
{% include "../assets/logo.html" %}
</div>
</td>

View file

@ -1,19 +1,19 @@
<table class="cyan-table table">
<thead>
<thead>
<tr>
<th>Workspace Name</th>
<th>Owner Name</th>
<th>Action</th>
</tr>
</thead>
{% for shared_workspace in shared_workspaces %}
<tr>
<th>Workspace Name</th>
<th>Owner Name</th>
<th>Action</th>
</tr>
</thead>
{% for shared_workspace in shared_workspaces %}
<tr>
<td> {{ shared_workspace.workspace_name|escape }} </td>
<td> {{ shared_workspace.owner_name|escape }} </td>
<td>{{ shared_workspace.workspace_name|escape }}</td>
<td>{{ shared_workspace.owner_name|escape }}</td>
<td>
<button
class="button red"
hx-post="/web-api/workspace/{{ shared_workspace.workspace_id|escape }}/leave"
hx-post="../web-api/workspace/{{ shared_workspace.workspace_id|escape }}/leave"
hx-confirm="Are you sure?"
hx-target="closest tr"
hx-swap="delete"
@ -22,5 +22,5 @@
</button>
</td>
</tr>
{% endfor %}
{% endfor %}
</table>

View file

@ -2,7 +2,7 @@
<div
class="sidebar-item"
hx-target="#sidebar-content"
hx-get="/web/components/user/navigate"
hx-get="../web/components/user/navigate"
data-section="navigate"
>
Navigate
@ -10,7 +10,7 @@
<div
class="sidebar-item"
hx-target="#sidebar-content"
hx-get="/web/components/user/change-password"
hx-get="../web/components/user/change-password"
data-section="change-password"
>
Change Password
@ -18,7 +18,7 @@
<div
class="sidebar-item"
hx-target="#sidebar-content"
hx-get="/web/components/user/invite"
hx-get="../web/components/user/invite"
data-section="invite"
>
Invite
@ -26,7 +26,7 @@
<div
class="sidebar-item"
hx-target="#sidebar-content"
hx-get="/web/components/user/user-usage"
hx-get="../web/components/user/user-usage"
data-section="user-usage"
>
User Usage
@ -34,7 +34,7 @@
<div
class="sidebar-item"
hx-target="#sidebar-content"
hx-get="/web/components/user/workspace-usage"
hx-get="../web/components/user/workspace-usage"
data-section="workspace-usage"
>
Workspace Usage
@ -42,14 +42,14 @@
</div>
<script>
document.addEventListener('DOMContentLoaded', (event) => {
const frag = window.location.href.split('#');
document.addEventListener("DOMContentLoaded", (event) => {
const frag = window.location.href.split("#");
if (frag.length > 1) {
const section = frag[1];
const sidebarItems = document.querySelectorAll('.sidebar-item');
const sidebarItems = document.querySelectorAll(".sidebar-item");
sidebarItems.forEach(item => {
if (item.getAttribute('data-section') === section) {
sidebarItems.forEach((item) => {
if (item.getAttribute("data-section") === section) {
item.click();
}
});

View file

@ -5,13 +5,13 @@
<div
hx-target="#sidebar-content"
hx-get="/web/components/user/user"
hx-get="../web/components/user/user"
class="button cyan"
>
{{ user.email|escape }}
</div>
<div
hx-delete="/web-api/delete-account"
hx-delete="../web-api/delete-account"
hx-confirm="This will erase all data associated with this account. Are you sure?"
class="button red"
>
@ -27,12 +27,12 @@
document
.getElementById("adminBtn")
.addEventListener("click", function () {
window.location.href = "/web/admin/home";
window.location.href = "../web/admin/home";
});
</script>
{% endif %}
<div class="button yellow" id="logoutBtn" hx-post="/web-api/logout">
<div class="button yellow" id="logoutBtn" hx-post="../web-api/logout">
Logout
</div>
</div>

View file

@ -2,8 +2,8 @@
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link href="/assets/base.css" rel="stylesheet" />
<link href="/assets/message.css" rel="stylesheet" />
<link href="{{ path_prefix }}/assets/base.css" rel="stylesheet" />
<link href="{{ path_prefix }}/assets/message.css" rel="stylesheet" />
<title>{% block title %}{{ title|escape }}{% endblock %}</title>
<script
src="https://unpkg.com/htmx.org@1.9.6"

View file

@ -6,10 +6,10 @@
<!-- prettier-ignore -->
{% block head %}
<link href="/assets/sidebar.css" rel="stylesheet" />
<link href="/assets/top_menu_bar.css" rel="stylesheet" />
<link href="/assets/home.css" rel="stylesheet" />
<link href="/assets/navigate.css" rel="stylesheet" />
<link href="../../assets/sidebar.css" rel="stylesheet" />
<link href="../../assets/top_menu_bar.css" rel="stylesheet" />
<link href="../../assets/home.css" rel="stylesheet" />
<link href="../../assets/navigate.css" rel="stylesheet" />
{% endblock %}
<!-- prettier-ignore -->

View file

@ -6,10 +6,10 @@
<!-- prettier-ignore -->
{% block head %}
<link href="/assets/sidebar.css" rel="stylesheet" />
<link href="/assets/top_menu_bar.css" rel="stylesheet" />
<link href="/assets/home.css" rel="stylesheet" />
<link href="/assets/navigate.css" rel="stylesheet" />
<link href="{{ path_prefix }}/assets/sidebar.css" rel="stylesheet" />
<link href="{{ path_prefix }}/assets/top_menu_bar.css" rel="stylesheet" />
<link href="{{ path_prefix }}/assets/home.css" rel="stylesheet" />
<link href="{{ path_prefix }}/assets/navigate.css" rel="stylesheet" />
{% endblock %}
<!-- prettier-ignore -->
@ -27,7 +27,7 @@
</div>
</div>
<script>
window.history.replaceState(null, '', '/web/home');
window.history.replaceState(null, "", "{{ path_prefix }}/web/home");
</script>
{% endblock %}

View file

@ -6,8 +6,8 @@
<!-- prettier-ignore -->
{% block head %}
<link href="/assets/login.css" rel="stylesheet" />
<link href="/assets/google/logo.css" rel="stylesheet" />
<link href="../assets/login.css" rel="stylesheet" />
<link href="../assets/google/logo.css" rel="stylesheet" />
{% endblock %}
<!-- prettier-ignore -->
@ -16,14 +16,14 @@
<div id="login-signin">
<div id="login-splash">
{% include "../assets/logo.html" %}
<h2 style="padding: 16px;">AppFlowy Cloud</h2>
<h2 style="padding: 16px">AppFlowy Cloud</h2>
</div>
<h3>Email Login</h3>
<form>
<table style="width: 100%">
{% if let Some(redirect_to) = redirect_to %}
<input type="hidden" name="redirect_to" value="{{ redirect_to }}">
<input type="hidden" name="redirect_to" value="{{ redirect_to }}" />
{% endif %}
<tr>
@ -61,7 +61,7 @@
>
<div style="display: flex; margin: 8px 0px">
<button
hx-post="/web-api/signin"
hx-post="../web-api/signin"
hx-target="#none"
class="button cyan"
type="submit"
@ -70,7 +70,7 @@
Sign In
</button>
<button
hx-post="/web-api/signup"
hx-post="../web-api/signup"
hx-target="#none"
class="button purple"
type="submit"
@ -84,13 +84,13 @@
<!-- Load OAuth Providers if configured -->
{% if oauth_providers.len() > 0 %}
<br />
<table style="width: 100%; border-collapse: collapse;">
<tr style="display: flex; align-items: center;">
<td style="width: 100%; margin: auto;">
<table style="width: 100%; border-collapse: collapse">
<tr style="display: flex; align-items: center">
<td style="width: 100%; margin: auto">
<hr class="divider" />
</td>
<td style="flex: 1; text-align: center;">&nbsp;or&nbsp;</td>
<td style="width: 100%; margin: auto;">
<td style="flex: 1; text-align: center">&nbsp;or&nbsp;</td>
<td style="width: 100%; margin: auto">
<hr class="divider" />
</td>
</tr>
@ -98,14 +98,30 @@
<h3>OAuth Login</h3>
<div id="oauth-container">
<div style="display: flex; flex-wrap: wrap; align-items: center; justify-content: center">
<div
style="
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
"
>
{% for provider in oauth_providers %}
<a
href="/gotrue/authorize?provider={{ provider|escape }}&redirect_to={{ oauth_redirect_to|default("/web/login-callback")|escape }}"
href="/gotrue/authorize?provider={{ provider|escape }}&redirect_to={{ oauth_redirect_to|escape }}"
style="text-decoration: none; color: inherit"
>
<div style="display: flex; align-items: center; border: 1px solid #384967; margin: 4px; border-radius: 4px; height: 64px">
<div> &nbsp&nbsp{{ provider }} </div>
<div
style="
display: flex;
align-items: center;
border: 1px solid #384967;
margin: 4px;
border-radius: 4px;
height: 64px;
"
>
<div>&nbsp&nbsp{{ provider }}</div>
<div class="oauth-icon">
<div
hx-get="../assets/{{ provider|escape }}/logo.html"
@ -122,7 +138,12 @@
<span> &nbsp </span>
<div style="max-width: 256px; display: flex; align-items: center">
<img src="https://cdn.prod.website-files.com/5c14e387dab576fe667689cf/61e1116779fc0a9bd5bdbcc7_Frame%206.png" alt="kofi" width="32" height="32">
<img
src="https://cdn.prod.website-files.com/5c14e387dab576fe667689cf/61e1116779fc0a9bd5bdbcc7_Frame%206.png"
alt="kofi"
width="32"
height="32"
/>
<i>
&nbsp Support AppFlowy on <a href="https://ko-fi.com/appflowy">Ko-fi</a>
</i>
@ -130,12 +151,14 @@
<span> &nbsp </span>
<div style="max-width: 256px">
<small style="color: #888; text-align: center;"><i>
&nbsp
By clicking logging in or signing up, you confirm that you have read, understood, and agreed to AppFlowy's
<a href="https://appflowy.io/terms">Terms</a> and
<a href="https://appflowy.io/privacy">Privacy Policy</a>.
</i></small>
<small style="color: #888; text-align: center"
><i>
&nbsp By clicking logging in or signing up, you confirm that you have
read, understood, and agreed to AppFlowy's
<a href="https://appflowy.io/terms">Terms</a> and
<a href="https://appflowy.io/privacy">Privacy Policy</a>.
</i></small
>
</div>
</div>
{% endblock %}

View file

@ -1,12 +1,17 @@
<script>
window.onload = function() {
window.onload = function () {
const current_url = window.location.href;
if (current_url.includes('/login-callback')) {
var redirect_url = current_url.replace('/login-callback', '/login-callback-query');
if (redirect_url.includes('?')) { // If '?' exists, replace '#' with '&'
redirect_url = redirect_url.replace('#', '&');
} else { // If '?' does not exist, replace '#' with '?'
redirect_url = redirect_url.replace('#', '?');
if (current_url.includes("/login-callback")) {
var redirect_url = current_url.replace(
"/login-callback",
"/login-callback-query",
);
if (redirect_url.includes("?")) {
// If '?' exists, replace '#' with '&'
redirect_url = redirect_url.replace("#", "&");
} else {
// If '?' does not exist, replace '#' with '?'
redirect_url = redirect_url.replace("#", "?");
}
window.location.href = redirect_url;
}

View file

@ -6,8 +6,8 @@
<!-- prettier-ignore -->
{% block head %}
<link href="/assets/login.css" rel="stylesheet" />
<link href="/assets/google/logo.css" rel="stylesheet" />
<link href="../assets/login.css" rel="stylesheet" />
<link href="../assets/google/logo.css" rel="stylesheet" />
{% endblock %}
<!-- prettier-ignore -->
@ -33,7 +33,7 @@
placeholder="Please enter your email address"
/>
<button
hx-post="/web-api/signin"
hx-post="../web-api/signin"
hx-target="#none"
class="button cyan"
type="submit"
@ -63,7 +63,7 @@
{% for provider in oauth_providers %}
<div class="oauth-item-inner">
<a
href="/gotrue/authorize?provider={{ provider|escape }}&redirect_to={{ oauth_redirect_to|default("/web/login-callback")|escape }}"
href="/gotrue/authorize?provider={{ provider|escape }}&redirect_to={{ oauth_redirect_to|escape }}"
style="text-decoration: none; color: inherit"
>
<div style="display: flex; align-items: center; justify-content: center; color: inherit">
@ -122,4 +122,3 @@
</div>
{% endblock %}
</div>

View file

@ -30,7 +30,10 @@ impl AdminFrontendClient {
}
pub async fn web_api_sign_in(&mut self, email: &str, password: &str) {
let url = format!("{}/web-api/signin", self.test_config.hostname);
let url = format!(
"{}{}/web-api/signin",
self.test_config.hostname, self.server_config.path_prefix
);
let resp = self
.http_client
.post(&url)
@ -51,7 +54,10 @@ impl AdminFrontendClient {
&mut self,
oauth_redirect: &OAuthRedirect,
) -> reqwest::Response {
let url = format!("{}/web-api/oauth-redirect", self.test_config.hostname);
let url = format!(
"{}{}/web-api/oauth-redirect",
self.test_config.hostname, self.server_config.path_prefix
);
let http_client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
@ -70,7 +76,10 @@ impl AdminFrontendClient {
&mut self,
oauth_redirect: &OAuthRedirectToken,
) -> reqwest::Response {
let url = format!("{}/web-api/oauth-redirect/token", self.test_config.hostname);
let url = format!(
"{}{}/web-api/oauth-redirect/token",
self.test_config.hostname, self.server_config.path_prefix
);
self
.http_client
.get(&url)

View file

@ -45,6 +45,10 @@ ADMIN_FRONTEND_REDIS_URL=redis://${REDIS_HOST}:${REDIS_PORT}
ADMIN_FRONTEND_GOTRUE_URL=http://gotrue:9999
## URL that connects to the cloud docker container
ADMIN_FRONTEND_APPFLOWY_CLOUD_URL=http://appflowy_cloud:8000
## Base Url for the admin frontend. If you use the default Nginx conf provided here, this value should be /console.
## If you want to keep the previous behaviour where admin frontend is served at the root, don't set this env variable,
## or set it to empty string.
ADMIN_FRONTEND_PATH_PREFIX=/console
# authentication key, change this and keep the key safe and secret
# self defined key, you can use any string

View file

@ -212,4 +212,5 @@ performed via the admin portal as opposed to links provided in emails.
- Update the docker compose file such that the ports for `appflowy_cloud`, `gotrue`, and `admin_frontend` are mapped
to different ports on the host server. If possible, use firewall to make sure that these ports are not accessible
from the internet.
- Update `proxy_pass` in `nginx/nginx.conf` to point to the above ports.
- Update `proxy_pass` in `nginx/nginx.conf` to point to the above ports. Then adapt this configuration for your
existing Nginx configuration.

View file

@ -30,7 +30,7 @@ After executing `docker-compose up -d`, AppFlowy-Cloud is accessible at `http://
- `/gotrue`: Redirects to the GoTrue Auth Server.
- `/api`: AppFlowy-Cloud's HTTP API endpoint.
- `/ws`: WebSocket endpoint for AppFlowy-Cloud.
- `/web`: User Admin Frontend for AppFlowy.
- `/console`: User Admin Frontend for AppFlowy.
- `/pgadmin`: Interface for Postgres database management.
- `/minio`: User interface for Minio object storage.
- `/portainer`: Tool for container management.

View file

@ -141,6 +141,7 @@ services:
- APPFLOWY_MAILER_SMTP_EMAIL=${APPFLOWY_MAILER_SMTP_EMAIL}
- APPFLOWY_MAILER_SMTP_PASSWORD=${APPFLOWY_MAILER_SMTP_PASSWORD}
- AI_OPENAI_API_KEY=${AI_OPENAI_API_KEY}
- APPFLOWY_ADMIN_FRONTEND_PATH_PREFIX=${ADMIN_FRONTEND_PATH_PREFIX}
build:
context: .
dockerfile: Dockerfile
@ -165,6 +166,7 @@ services:
- ADMIN_FRONTEND_REDIS_URL=${ADMIN_FRONTEND_REDIS_URL:-redis://redis:6379}
- ADMIN_FRONTEND_GOTRUE_URL=${ADMIN_FRONTEND_GOTRUE_URL:-http://gotrue:9999}
- ADMIN_FRONTEND_APPFLOWY_CLOUD_URL=${ADMIN_FRONTEND_APPFLOWY_CLOUD_URL:-http://appflowy_cloud:8000}
- ADMIN_FRONTEND_PATH_PREFIX=${ADMIN_FRONTEND_PATH_PREFIX:-}
depends_on:
appflowy_cloud:
condition: service_started

View file

@ -136,6 +136,7 @@ services:
- AI_SERVER_HOST=${AI_SERVER_HOST}
- AI_SERVER_PORT=${AI_SERVER_PORT}
- AI_OPENAI_API_KEY=${AI_OPENAI_API_KEY}
- APPFLOWY_ADMIN_FRONTEND_PATH_PREFIX=${ADMIN_FRONTEND_PATH_PREFIX}
# Uncomment this line if AppFlowy Web has been deployed
# - APPFLOWY_WEB_URL=${APPFLOWY_WEB_URL}
build:
@ -159,6 +160,7 @@ services:
- ADMIN_FRONTEND_REDIS_URL=${ADMIN_FRONTEND_REDIS_URL:-redis://redis:6379}
- ADMIN_FRONTEND_GOTRUE_URL=${ADMIN_FRONTEND_GOTRUE_URL:-http://gotrue:9999}
- ADMIN_FRONTEND_APPFLOWY_CLOUD_URL=${ADMIN_FRONTEND_APPFLOWY_CLOUD_URL:-http://appflowy_cloud:8000}
- ADMIN_FRONTEND_PATH_PREFIX=${ADMIN_FRONTEND_PATH_PREFIX:-}
depends_on:
appflowy_cloud:
condition: service_started

View file

@ -218,7 +218,7 @@ http {
# Admin Frontend
# Optional Module, comment this section if you are did not deploy admin_frontend in docker-compose.yml
location / {
location /console {
proxy_pass $admin_frontend_backend;
proxy_set_header X-Scheme $scheme;

View file

@ -453,6 +453,7 @@ async fn post_workspace_invite_handler(
&workspace_id,
invitations,
state.config.appflowy_web_url.as_deref(),
&state.config.admin_frontend_path_prefix,
)
.await?;
Ok(AppResponse::Ok().into())

View file

@ -361,6 +361,7 @@ pub async fn invite_workspace_members(
workspace_id: &Uuid,
invitations: Vec<WorkspaceMemberInvitation>,
appflowy_web_url: Option<&str>,
admin_frontend_path_prefix: &str,
) -> Result<(), AppError> {
let mut txn = pg_pool
.begin()
@ -432,7 +433,10 @@ pub async fn invite_workspace_members(
// Generate a link such that when clicked, the user is added to the workspace.
let accept_url = {
match appflowy_web_url {
Some(appflowy_web_url) => format!("{}/accept-invitation?invited_id={}", appflowy_web_url, invite_id),
Some(appflowy_web_url) => format!(
"{}/accept-invitation?invited_id={}",
appflowy_web_url, invite_id
),
None => {
gotrue_client
.admin_generate_link(
@ -441,8 +445,10 @@ pub async fn invite_workspace_members(
type_: GenerateLinkType::MagicLink,
email: invitation.email.clone(),
redirect_to: format!(
"/web/login-callback?action=accept_workspace_invite&workspace_invitation_id={}&workspace_name={}&workspace_icon={}&user_name={}&user_icon={}&workspace_member_count={}",
invite_id, workspace_name,
"{}/web/login-callback?action=accept_workspace_invite&workspace_invitation_id={}&workspace_name={}&workspace_icon={}&user_name={}&user_icon={}&workspace_member_count={}",
admin_frontend_path_prefix,
invite_id,
workspace_name,
workspace_icon_url,
inviter_name,
user_icon_url,

View file

@ -27,6 +27,7 @@ pub struct Config {
pub mailer: MailerSetting,
pub apple_oauth: AppleOAuthSetting,
pub appflowy_web_url: Option<String>,
pub admin_frontend_path_prefix: String,
}
#[derive(serde::Deserialize, Clone, Debug)]
@ -262,6 +263,7 @@ pub fn get_configuration() -> Result<Config, anyhow::Error> {
client_secret: get_env_var("APPFLOWY_APPLE_OAUTH_CLIENT_SECRET", "").into(),
},
appflowy_web_url: get_env_var_opt("APPFLOWY_WEB_URL"),
admin_frontend_path_prefix: get_env_var("APPFLOWY_ADMIN_FRONTEND_PATH_PREFIX", ""),
};
Ok(config)
}