feat: configurable OAuth (Google + TikTok SSO), project membership, inline file preview
- Auth: configurable OAuthProvider enum supporting Google OAuth and TikTok SSO - Auth: /auth/provider endpoint for frontend to detect active provider - Auth: user role system (admin via ADMIN_USERS env var sees all projects) - Projects: project_members many-to-many table with role (owner/member) - Projects: membership-based access control, auto-add creator as owner - Projects: member management API (list/add/remove) - Files: remove Content-Disposition attachment header, let browser decide - Health: public /tori/api/health endpoint for k8s probes
This commit is contained in:
228
src/api/auth.rs
228
src/api/auth.rs
@@ -19,10 +19,70 @@ const CSRF_COOKIE: &str = "tori_session_csrf";
|
|||||||
const COOKIE_PATH: &str = "/";
|
const COOKIE_PATH: &str = "/";
|
||||||
const SESSION_SECS: i64 = 7 * 86400;
|
const SESSION_SECS: i64 = 7 * 86400;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum OAuthProvider {
|
||||||
|
Google {
|
||||||
|
client_id: String,
|
||||||
|
client_secret: String,
|
||||||
|
},
|
||||||
|
TikTokSso {
|
||||||
|
client_id: String,
|
||||||
|
client_secret: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OAuthProvider {
|
||||||
|
fn authorize_url(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::Google { .. } => "https://accounts.google.com/o/oauth2/v2/auth",
|
||||||
|
Self::TikTokSso { .. } => "https://sso.tiktok-intl.com/oauth2/authorize",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token_url(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::Google { .. } => "https://oauth2.googleapis.com/token",
|
||||||
|
Self::TikTokSso { .. } => "https://sso.tiktok-intl.com/oauth2/access_token",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn userinfo_url(&self) -> Option<&str> {
|
||||||
|
match self {
|
||||||
|
Self::Google { .. } => None, // uses id_token
|
||||||
|
Self::TikTokSso { .. } => Some("https://sso.tiktok-intl.com/oauth2/userinfo"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_id(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::Google { client_id, .. } | Self::TikTokSso { client_id, .. } => client_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_secret(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::Google { client_secret, .. } | Self::TikTokSso { client_secret, .. } => client_secret,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scope(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::Google { .. } => "openid%20email%20profile",
|
||||||
|
Self::TikTokSso { .. } => "read",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::Google { .. } => "google",
|
||||||
|
Self::TikTokSso { .. } => "tiktok-sso",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct AuthConfig {
|
pub struct AuthConfig {
|
||||||
pub google_client_id: String,
|
pub provider: OAuthProvider,
|
||||||
pub google_client_secret: String,
|
|
||||||
pub jwt_secret: String,
|
pub jwt_secret: String,
|
||||||
pub public_url: String,
|
pub public_url: String,
|
||||||
}
|
}
|
||||||
@@ -110,7 +170,7 @@ async fn generate_token(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Google OAuth ---
|
// --- OAuth login/callback ---
|
||||||
|
|
||||||
pub fn router(state: Arc<AppState>) -> Router {
|
pub fn router(state: Arc<AppState>) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
@@ -119,9 +179,15 @@ pub fn router(state: Arc<AppState>) -> Router {
|
|||||||
.route("/me", get(me))
|
.route("/me", get(me))
|
||||||
.route("/logout", post(logout))
|
.route("/logout", post(logout))
|
||||||
.route("/token", post(generate_token))
|
.route("/token", post(generate_token))
|
||||||
|
.route("/provider", get(get_provider))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_provider(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||||
|
let provider = state.auth.as_ref().map(|a| a.provider.name());
|
||||||
|
Json(serde_json::json!({ "provider": provider }))
|
||||||
|
}
|
||||||
|
|
||||||
fn build_cookie(name: &str, value: String, max_age_secs: i64) -> Cookie<'static> {
|
fn build_cookie(name: &str, value: String, max_age_secs: i64) -> Cookie<'static> {
|
||||||
let mut c = Cookie::new(name.to_owned(), value);
|
let mut c = Cookie::new(name.to_owned(), value);
|
||||||
c.set_path(COOKIE_PATH);
|
c.set_path(COOKIE_PATH);
|
||||||
@@ -144,12 +210,14 @@ async fn login(State(state): State<Arc<AppState>>) -> Response {
|
|||||||
|
|
||||||
let csrf = uuid::Uuid::new_v4().to_string();
|
let csrf = uuid::Uuid::new_v4().to_string();
|
||||||
let redirect_uri = format!("{}/tori/api/auth/callback", auth.public_url);
|
let redirect_uri = format!("{}/tori/api/auth/callback", auth.public_url);
|
||||||
|
let provider = &auth.provider;
|
||||||
|
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"https://accounts.google.com/o/oauth2/v2/auth?\
|
"{}?client_id={}&redirect_uri={}&response_type=code&scope={}&state={}",
|
||||||
client_id={}&redirect_uri={}&response_type=code&\
|
provider.authorize_url(),
|
||||||
scope=openid%20email%20profile&access_type=online&state={}",
|
pct_encode(provider.client_id()),
|
||||||
pct_encode(&auth.google_client_id),
|
|
||||||
pct_encode(&redirect_uri),
|
pct_encode(&redirect_uri),
|
||||||
|
provider.scope(),
|
||||||
pct_encode(&csrf),
|
pct_encode(&csrf),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -165,16 +233,14 @@ struct CallbackParams {
|
|||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct TokenResponse {
|
struct TokenResponse {
|
||||||
|
access_token: Option<String>,
|
||||||
id_token: Option<String>,
|
id_token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
struct UserInfo {
|
||||||
struct GoogleUserInfo {
|
|
||||||
sub: String,
|
sub: String,
|
||||||
email: String,
|
email: String,
|
||||||
#[serde(default)]
|
|
||||||
name: String,
|
name: String,
|
||||||
#[serde(default)]
|
|
||||||
picture: String,
|
picture: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +253,7 @@ async fn callback(
|
|||||||
Some(a) => a,
|
Some(a) => a,
|
||||||
None => return (StatusCode::SERVICE_UNAVAILABLE, "Auth not configured").into_response(),
|
None => return (StatusCode::SERVICE_UNAVAILABLE, "Auth not configured").into_response(),
|
||||||
};
|
};
|
||||||
|
let provider = &auth.provider;
|
||||||
|
|
||||||
// CSRF check
|
// CSRF check
|
||||||
match jar.get(CSRF_COOKIE) {
|
match jar.get(CSRF_COOKIE) {
|
||||||
@@ -199,11 +266,11 @@ async fn callback(
|
|||||||
// Exchange code for token
|
// Exchange code for token
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let token_res = client
|
let token_res = client
|
||||||
.post("https://oauth2.googleapis.com/token")
|
.post(provider.token_url())
|
||||||
.form(&[
|
.form(&[
|
||||||
("code", params.code.as_str()),
|
("code", params.code.as_str()),
|
||||||
("client_id", &auth.google_client_id),
|
("client_id", provider.client_id()),
|
||||||
("client_secret", &auth.google_client_secret),
|
("client_secret", provider.client_secret()),
|
||||||
("redirect_uri", &redirect_uri),
|
("redirect_uri", &redirect_uri),
|
||||||
("grant_type", "authorization_code"),
|
("grant_type", "authorization_code"),
|
||||||
])
|
])
|
||||||
@@ -217,44 +284,72 @@ async fn callback(
|
|||||||
},
|
},
|
||||||
Ok(r) => {
|
Ok(r) => {
|
||||||
let body = r.text().await.unwrap_or_default();
|
let body = r.text().await.unwrap_or_default();
|
||||||
tracing::error!("Google token exchange failed: {}", body);
|
tracing::error!("{} token exchange failed: {}", provider.name(), body);
|
||||||
return (StatusCode::BAD_GATEWAY, "Google token exchange failed").into_response();
|
return (StatusCode::BAD_GATEWAY, "Token exchange failed").into_response();
|
||||||
}
|
}
|
||||||
Err(e) => return (StatusCode::BAD_GATEWAY, format!("Token request failed: {}", e)).into_response(),
|
Err(e) => return (StatusCode::BAD_GATEWAY, format!("Token request failed: {}", e)).into_response(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let id_token = match token_body.id_token {
|
// Get user info — provider-specific
|
||||||
Some(t) => t,
|
let user_info = match provider.userinfo_url() {
|
||||||
None => return (StatusCode::BAD_GATEWAY, "No id_token in response").into_response(),
|
Some(userinfo_url) => {
|
||||||
};
|
// TikTok SSO: call userinfo endpoint with access_token
|
||||||
|
let access_token = match &token_body.access_token {
|
||||||
// Decode id_token payload (no verification needed - just received from Google over HTTPS)
|
Some(t) => t,
|
||||||
let user_info = match decode_google_id_token(&id_token) {
|
None => return (StatusCode::BAD_GATEWAY, "No access_token in response").into_response(),
|
||||||
Some(u) => u,
|
};
|
||||||
None => return (StatusCode::BAD_GATEWAY, "Failed to decode id_token").into_response(),
|
match fetch_userinfo(&client, userinfo_url, access_token).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(e) => return (StatusCode::BAD_GATEWAY, e).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Google: decode id_token
|
||||||
|
let id_token = match &token_body.id_token {
|
||||||
|
Some(t) => t,
|
||||||
|
None => return (StatusCode::BAD_GATEWAY, "No id_token in response").into_response(),
|
||||||
|
};
|
||||||
|
match decode_jwt_payload(id_token) {
|
||||||
|
Some(u) => u,
|
||||||
|
None => return (StatusCode::BAD_GATEWAY, "Failed to decode id_token").into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Upsert user
|
// Upsert user
|
||||||
let user_id = format!("google:{}", user_info.sub);
|
let user_id = format!("{}:{}", provider.name(), user_info.sub);
|
||||||
|
|
||||||
|
// Determine user role: check ADMIN_USERS env var (comma-separated emails or usernames)
|
||||||
|
let role = {
|
||||||
|
let admin_list = std::env::var("ADMIN_USERS").unwrap_or_default();
|
||||||
|
let is_admin = !admin_list.is_empty() && admin_list.split(',').any(|a| {
|
||||||
|
let a = a.trim();
|
||||||
|
a == user_info.email || a == user_info.name || a == user_info.sub
|
||||||
|
});
|
||||||
|
if is_admin { "admin" } else { "user" }
|
||||||
|
};
|
||||||
|
|
||||||
let _ = sqlx::query(
|
let _ = sqlx::query(
|
||||||
"INSERT INTO users (id, email, name, picture)
|
"INSERT INTO users (id, email, name, picture, role)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
email = excluded.email,
|
email = excluded.email,
|
||||||
name = excluded.name,
|
name = excluded.name,
|
||||||
picture = excluded.picture,
|
picture = excluded.picture,
|
||||||
|
role = excluded.role,
|
||||||
last_login_at = datetime('now')"
|
last_login_at = datetime('now')"
|
||||||
)
|
)
|
||||||
.bind(&user_id)
|
.bind(&user_id)
|
||||||
.bind(&user_info.email)
|
.bind(&user_info.email)
|
||||||
.bind(&user_info.name)
|
.bind(&user_info.name)
|
||||||
.bind(&user_info.picture)
|
.bind(&user_info.picture)
|
||||||
|
.bind(role)
|
||||||
.execute(&state.db.pool)
|
.execute(&state.db.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
tracing::info!("User logged in: {} ({})", user_info.email, user_id);
|
tracing::info!("User logged in: {} ({})", user_info.email, user_id);
|
||||||
|
|
||||||
// Sign JWT
|
// Sign session JWT
|
||||||
let exp = chrono::Utc::now().timestamp() + SESSION_SECS;
|
let exp = chrono::Utc::now().timestamp() + SESSION_SECS;
|
||||||
let claims = Claims {
|
let claims = Claims {
|
||||||
sub: user_id,
|
sub: user_id,
|
||||||
@@ -288,14 +383,14 @@ async fn me(State(state): State<Arc<AppState>>, jar: CookieJar) -> Response {
|
|||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct UserInfo {
|
struct MeResponse {
|
||||||
id: String,
|
id: String,
|
||||||
email: String,
|
email: String,
|
||||||
name: String,
|
name: String,
|
||||||
picture: String,
|
picture: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
let user: Option<UserInfo> = sqlx::query_as::<_, (String, String, String, String)>(
|
let user: Option<MeResponse> = sqlx::query_as::<_, (String, String, String, String)>(
|
||||||
"SELECT id, email, name, picture FROM users WHERE id = ?"
|
"SELECT id, email, name, picture FROM users WHERE id = ?"
|
||||||
)
|
)
|
||||||
.bind(&claims.sub)
|
.bind(&claims.sub)
|
||||||
@@ -303,7 +398,7 @@ async fn me(State(state): State<Arc<AppState>>, jar: CookieJar) -> Response {
|
|||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.flatten()
|
.flatten()
|
||||||
.map(|(id, email, name, picture)| UserInfo { id, email, name, picture });
|
.map(|(id, email, name, picture)| MeResponse { id, email, name, picture });
|
||||||
|
|
||||||
match user {
|
match user {
|
||||||
Some(u) => Json(u).into_response(),
|
Some(u) => Json(u).into_response(),
|
||||||
@@ -349,8 +444,57 @@ fn extract_claims(jar: &CookieJar, jwt_secret: &str) -> Option<Claims> {
|
|||||||
.map(|d| d.claims)
|
.map(|d| d.claims)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn decode_google_id_token(id_token: &str) -> Option<GoogleUserInfo> {
|
/// Fetch user info from an OAuth userinfo endpoint (TikTok SSO style)
|
||||||
let parts: Vec<&str> = id_token.split('.').collect();
|
async fn fetch_userinfo(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
url: &str,
|
||||||
|
access_token: &str,
|
||||||
|
) -> Result<UserInfo, String> {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Raw {
|
||||||
|
#[serde(default)]
|
||||||
|
sub: String,
|
||||||
|
#[serde(default)]
|
||||||
|
email: String,
|
||||||
|
#[serde(default)]
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get(url)
|
||||||
|
.bearer_auth(access_token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Userinfo request failed: {}", e))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("Userinfo failed: {}", body));
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw: Raw = resp.json().await.map_err(|e| format!("Userinfo parse error: {}", e))?;
|
||||||
|
Ok(UserInfo {
|
||||||
|
sub: raw.sub,
|
||||||
|
email: raw.email,
|
||||||
|
name: raw.name,
|
||||||
|
picture: String::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode JWT payload without verification (for Google id_token received over HTTPS)
|
||||||
|
fn decode_jwt_payload(jwt: &str) -> Option<UserInfo> {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Raw {
|
||||||
|
sub: String,
|
||||||
|
#[serde(default)]
|
||||||
|
email: String,
|
||||||
|
#[serde(default)]
|
||||||
|
name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
picture: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let parts: Vec<&str> = jwt.split('.').collect();
|
||||||
if parts.len() != 3 {
|
if parts.len() != 3 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -359,14 +503,16 @@ fn decode_google_id_token(id_token: &str) -> Option<GoogleUserInfo> {
|
|||||||
3 => format!("{}=", parts[1]),
|
3 => format!("{}=", parts[1]),
|
||||||
_ => parts[1].to_string(),
|
_ => parts[1].to_string(),
|
||||||
};
|
};
|
||||||
let payload = base64_decode_url_safe(&padded)?;
|
let standard = padded.replace('-', "+").replace('_', "/");
|
||||||
serde_json::from_slice(&payload).ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn base64_decode_url_safe(input: &str) -> Option<Vec<u8>> {
|
|
||||||
let standard = input.replace('-', "+").replace('_', "/");
|
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
base64::engine::general_purpose::STANDARD.decode(&standard).ok()
|
let payload = base64::engine::general_purpose::STANDARD.decode(&standard).ok()?;
|
||||||
|
let raw: Raw = serde_json::from_slice(&payload).ok()?;
|
||||||
|
Some(UserInfo {
|
||||||
|
sub: raw.sub,
|
||||||
|
email: raw.email,
|
||||||
|
name: raw.name,
|
||||||
|
picture: raw.picture,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pct_encode(s: &str) -> String {
|
fn pct_encode(s: &str) -> String {
|
||||||
|
|||||||
@@ -101,21 +101,7 @@ async fn get_file(
|
|||||||
let mime = mime_guess::from_path(&full)
|
let mime = mime_guess::from_path(&full)
|
||||||
.first_or_octet_stream()
|
.first_or_octet_stream()
|
||||||
.to_string();
|
.to_string();
|
||||||
let filename = full
|
([(axum::http::header::CONTENT_TYPE, mime)], bytes).into_response()
|
||||||
.file_name()
|
|
||||||
.and_then(|n| n.to_str())
|
|
||||||
.unwrap_or("file");
|
|
||||||
(
|
|
||||||
[
|
|
||||||
(axum::http::header::CONTENT_TYPE, mime),
|
|
||||||
(
|
|
||||||
axum::http::header::CONTENT_DISPOSITION,
|
|
||||||
format!("attachment; filename=\"{}\"", filename),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
bytes,
|
|
||||||
)
|
|
||||||
.into_response()
|
|
||||||
}
|
}
|
||||||
Err(_) => (StatusCode::NOT_FOUND, "File not found").into_response(),
|
Err(_) => (StatusCode::NOT_FOUND, "File not found").into_response(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ async fn proxy_impl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
fn render_markdown_page(markdown: &str, title: &str) -> String {
|
fn render_markdown_page(markdown: &str, title: &str) -> String {
|
||||||
use pulldown_cmark::{Parser, Options, html};
|
use pulldown_cmark::{Parser, Options, html};
|
||||||
let mut opts = Options::empty();
|
let mut opts = Options::empty();
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ use axum::{
|
|||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use axum::http::Extensions;
|
use axum::http::Extensions;
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use crate::db::Project;
|
use crate::db::Project;
|
||||||
use super::{ApiResult, db_err};
|
use super::{ApiResult, db_err};
|
||||||
use super::auth::Claims;
|
use super::auth::Claims;
|
||||||
|
|
||||||
fn owner_id(ext: &Extensions) -> &str {
|
fn user_id(ext: &Extensions) -> &str {
|
||||||
ext.get::<Claims>().map(|c| c.sub.as_str()).unwrap_or("")
|
ext.get::<Claims>().map(|c| c.sub.as_str()).unwrap_or("")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,20 +28,86 @@ pub struct UpdateProject {
|
|||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct MemberResponse {
|
||||||
|
user_id: String,
|
||||||
|
role: String,
|
||||||
|
email: String,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AddMemberRequest {
|
||||||
|
user_id: String,
|
||||||
|
#[serde(default = "default_role")]
|
||||||
|
role: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_role() -> String {
|
||||||
|
"owner".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn router(state: Arc<AppState>) -> Router {
|
pub fn router(state: Arc<AppState>) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/projects", get(list_projects).post(create_project))
|
.route("/projects", get(list_projects).post(create_project))
|
||||||
.route("/projects/{id}", get(get_project).put(update_project).delete(delete_project))
|
.route("/projects/{id}", get(get_project).put(update_project).delete(delete_project))
|
||||||
|
.route("/projects/{id}/members", get(list_members).post(add_member))
|
||||||
|
.route("/projects/{id}/members/{user_id}", axum::routing::delete(remove_member))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if user is admin (users.role = 'admin')
|
||||||
|
async fn is_admin(pool: &sqlx::SqlitePool, uid: &str) -> bool {
|
||||||
|
if uid.is_empty() {
|
||||||
|
return true; // auth not configured
|
||||||
|
}
|
||||||
|
sqlx::query_scalar::<_, bool>(
|
||||||
|
"SELECT COALESCE((SELECT role = 'admin' FROM users WHERE id = ?), 0)"
|
||||||
|
)
|
||||||
|
.bind(uid)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if user can access a project. Admin users can access all projects.
|
||||||
|
async fn can_access(pool: &sqlx::SqlitePool, project_id: &str, uid: &str) -> bool {
|
||||||
|
if uid.is_empty() {
|
||||||
|
return true; // auth not configured
|
||||||
|
}
|
||||||
|
if is_admin(pool, uid).await {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
sqlx::query_scalar::<_, bool>(
|
||||||
|
"SELECT COUNT(*) > 0 FROM project_members WHERE project_id = ? AND user_id = ?"
|
||||||
|
)
|
||||||
|
.bind(project_id)
|
||||||
|
.bind(uid)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
async fn list_projects(
|
async fn list_projects(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
ext: Extensions,
|
ext: Extensions,
|
||||||
) -> ApiResult<Vec<Project>> {
|
) -> ApiResult<Vec<Project>> {
|
||||||
let uid = owner_id(&ext);
|
let uid = user_id(&ext);
|
||||||
|
if uid.is_empty() || is_admin(&state.db.pool, uid).await {
|
||||||
|
// Auth not configured or admin user — show all
|
||||||
|
return sqlx::query_as::<_, Project>(
|
||||||
|
"SELECT * FROM projects WHERE deleted = 0 ORDER BY updated_at DESC"
|
||||||
|
)
|
||||||
|
.fetch_all(&state.db.pool)
|
||||||
|
.await
|
||||||
|
.map(Json)
|
||||||
|
.map_err(db_err);
|
||||||
|
}
|
||||||
sqlx::query_as::<_, Project>(
|
sqlx::query_as::<_, Project>(
|
||||||
"SELECT * FROM projects WHERE deleted = 0 AND (owner_id = ? OR owner_id = '') ORDER BY updated_at DESC"
|
"SELECT p.* FROM projects p \
|
||||||
|
JOIN project_members pm ON p.id = pm.project_id \
|
||||||
|
WHERE p.deleted = 0 AND pm.user_id = ? \
|
||||||
|
ORDER BY p.updated_at DESC"
|
||||||
)
|
)
|
||||||
.bind(uid)
|
.bind(uid)
|
||||||
.fetch_all(&state.db.pool)
|
.fetch_all(&state.db.pool)
|
||||||
@@ -56,8 +122,8 @@ async fn create_project(
|
|||||||
Json(input): Json<CreateProject>,
|
Json(input): Json<CreateProject>,
|
||||||
) -> ApiResult<Project> {
|
) -> ApiResult<Project> {
|
||||||
let id = uuid::Uuid::new_v4().to_string();
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
let uid = owner_id(&ext);
|
let uid = user_id(&ext);
|
||||||
sqlx::query_as::<_, Project>(
|
let project = sqlx::query_as::<_, Project>(
|
||||||
"INSERT INTO projects (id, name, description, owner_id) VALUES (?, ?, ?, ?) RETURNING *"
|
"INSERT INTO projects (id, name, description, owner_id) VALUES (?, ?, ?, ?) RETURNING *"
|
||||||
)
|
)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
@@ -66,8 +132,20 @@ async fn create_project(
|
|||||||
.bind(uid)
|
.bind(uid)
|
||||||
.fetch_one(&state.db.pool)
|
.fetch_one(&state.db.pool)
|
||||||
.await
|
.await
|
||||||
.map(Json)
|
.map_err(db_err)?;
|
||||||
.map_err(db_err)
|
|
||||||
|
// Auto-add creator as admin member
|
||||||
|
if !uid.is_empty() {
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"INSERT OR IGNORE INTO project_members (project_id, user_id, role) VALUES (?, ?, 'owner')"
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.bind(uid)
|
||||||
|
.execute(&state.db.pool)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(project))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_project(
|
async fn get_project(
|
||||||
@@ -75,12 +153,14 @@ async fn get_project(
|
|||||||
ext: Extensions,
|
ext: Extensions,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> ApiResult<Option<Project>> {
|
) -> ApiResult<Option<Project>> {
|
||||||
let uid = owner_id(&ext);
|
let uid = user_id(&ext);
|
||||||
|
if !uid.is_empty() && !can_access(&state.db.pool, &id, uid).await {
|
||||||
|
return Ok(Json(None));
|
||||||
|
}
|
||||||
sqlx::query_as::<_, Project>(
|
sqlx::query_as::<_, Project>(
|
||||||
"SELECT * FROM projects WHERE id = ? AND (owner_id = ? OR owner_id = '')"
|
"SELECT * FROM projects WHERE id = ?"
|
||||||
)
|
)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
.bind(uid)
|
|
||||||
.fetch_optional(&state.db.pool)
|
.fetch_optional(&state.db.pool)
|
||||||
.await
|
.await
|
||||||
.map(Json)
|
.map(Json)
|
||||||
@@ -93,30 +173,30 @@ async fn update_project(
|
|||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Json(input): Json<UpdateProject>,
|
Json(input): Json<UpdateProject>,
|
||||||
) -> ApiResult<Option<Project>> {
|
) -> ApiResult<Option<Project>> {
|
||||||
let uid = owner_id(&ext);
|
let uid = user_id(&ext);
|
||||||
|
if !uid.is_empty() && !can_access(&state.db.pool, &id, uid).await {
|
||||||
|
return Ok(Json(None));
|
||||||
|
}
|
||||||
if let Some(name) = &input.name {
|
if let Some(name) = &input.name {
|
||||||
sqlx::query("UPDATE projects SET name = ?, updated_at = datetime('now') WHERE id = ? AND (owner_id = ? OR owner_id = '')")
|
sqlx::query("UPDATE projects SET name = ?, updated_at = datetime('now') WHERE id = ?")
|
||||||
.bind(name)
|
.bind(name)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
.bind(uid)
|
|
||||||
.execute(&state.db.pool)
|
.execute(&state.db.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(db_err)?;
|
.map_err(db_err)?;
|
||||||
}
|
}
|
||||||
if let Some(desc) = &input.description {
|
if let Some(desc) = &input.description {
|
||||||
sqlx::query("UPDATE projects SET description = ?, updated_at = datetime('now') WHERE id = ? AND (owner_id = ? OR owner_id = '')")
|
sqlx::query("UPDATE projects SET description = ?, updated_at = datetime('now') WHERE id = ?")
|
||||||
.bind(desc)
|
.bind(desc)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
.bind(uid)
|
|
||||||
.execute(&state.db.pool)
|
.execute(&state.db.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(db_err)?;
|
.map_err(db_err)?;
|
||||||
}
|
}
|
||||||
sqlx::query_as::<_, Project>(
|
sqlx::query_as::<_, Project>(
|
||||||
"SELECT * FROM projects WHERE id = ? AND (owner_id = ? OR owner_id = '')"
|
"SELECT * FROM projects WHERE id = ?"
|
||||||
)
|
)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
.bind(uid)
|
|
||||||
.fetch_optional(&state.db.pool)
|
.fetch_optional(&state.db.pool)
|
||||||
.await
|
.await
|
||||||
.map(Json)
|
.map(Json)
|
||||||
@@ -128,12 +208,14 @@ async fn delete_project(
|
|||||||
ext: Extensions,
|
ext: Extensions,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> ApiResult<bool> {
|
) -> ApiResult<bool> {
|
||||||
let uid = owner_id(&ext);
|
let uid = user_id(&ext);
|
||||||
|
if !uid.is_empty() && !can_access(&state.db.pool, &id, uid).await {
|
||||||
|
return Ok(Json(false));
|
||||||
|
}
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
"UPDATE projects SET deleted = 1, updated_at = datetime('now') WHERE id = ? AND deleted = 0 AND (owner_id = ? OR owner_id = '')"
|
"UPDATE projects SET deleted = 1, updated_at = datetime('now') WHERE id = ? AND deleted = 0"
|
||||||
)
|
)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
.bind(uid)
|
|
||||||
.execute(&state.db.pool)
|
.execute(&state.db.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(db_err)?;
|
.map_err(db_err)?;
|
||||||
@@ -158,3 +240,75 @@ async fn delete_project(
|
|||||||
|
|
||||||
Ok(Json(true))
|
Ok(Json(true))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Member management ---
|
||||||
|
|
||||||
|
async fn list_members(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
ext: Extensions,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> ApiResult<Vec<MemberResponse>> {
|
||||||
|
let uid = user_id(&ext);
|
||||||
|
if !uid.is_empty() && !can_access(&state.db.pool, &id, uid).await {
|
||||||
|
return Ok(Json(vec![]));
|
||||||
|
}
|
||||||
|
let members: Vec<(String, String, String, String)> = sqlx::query_as(
|
||||||
|
"SELECT pm.user_id, pm.role, COALESCE(u.email, ''), COALESCE(u.name, '') \
|
||||||
|
FROM project_members pm \
|
||||||
|
LEFT JOIN users u ON pm.user_id = u.id \
|
||||||
|
WHERE pm.project_id = ? \
|
||||||
|
ORDER BY pm.created_at ASC"
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.fetch_all(&state.db.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
|
||||||
|
Ok(Json(members.into_iter().map(|(user_id, role, email, name)| {
|
||||||
|
MemberResponse { user_id, role, email, name }
|
||||||
|
}).collect()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_member(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
ext: Extensions,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Json(input): Json<AddMemberRequest>,
|
||||||
|
) -> ApiResult<bool> {
|
||||||
|
let uid = user_id(&ext);
|
||||||
|
if !uid.is_empty() && !can_access(&state.db.pool, &id, uid).await {
|
||||||
|
return Ok(Json(false));
|
||||||
|
}
|
||||||
|
let result = sqlx::query(
|
||||||
|
"INSERT OR IGNORE INTO project_members (project_id, user_id, role) VALUES (?, ?, ?)"
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.bind(&input.user_id)
|
||||||
|
.bind(&input.role)
|
||||||
|
.execute(&state.db.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
|
||||||
|
Ok(Json(result.rows_affected() > 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_member(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
ext: Extensions,
|
||||||
|
Path((id, member_id)): Path<(String, String)>,
|
||||||
|
) -> ApiResult<bool> {
|
||||||
|
let uid = user_id(&ext);
|
||||||
|
if !uid.is_empty() && !can_access(&state.db.pool, &id, uid).await {
|
||||||
|
return Ok(Json(false));
|
||||||
|
}
|
||||||
|
let result = sqlx::query(
|
||||||
|
"DELETE FROM project_members WHERE project_id = ? AND user_id = ?"
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.bind(&member_id)
|
||||||
|
.execute(&state.db.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
|
||||||
|
Ok(Json(result.rows_affected() > 0))
|
||||||
|
}
|
||||||
|
|||||||
73
src/db.rs
73
src/db.rs
@@ -249,6 +249,79 @@ impl Database {
|
|||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// Migration: add role column to users (admin = see all projects)
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'"
|
||||||
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS project_members (
|
||||||
|
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'owner',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (project_id, user_id)
|
||||||
|
)"
|
||||||
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Migration: assign all existing memberless projects to the first user (or leave for manual assignment)
|
||||||
|
// When auth is not configured, owner_id is empty — these projects are visible to everyone
|
||||||
|
// When a user logs in and creates projects, they get auto-added as admin
|
||||||
|
{
|
||||||
|
// Find existing projects with owner_id set but no members yet
|
||||||
|
let owned: Vec<(String, String)> = sqlx::query_as(
|
||||||
|
"SELECT p.id, p.owner_id FROM projects p \
|
||||||
|
WHERE p.deleted = 0 AND p.owner_id != '' \
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id)"
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
for (pid, uid) in owned {
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"INSERT OR IGNORE INTO project_members (project_id, user_id, role) VALUES (?, ?, 'owner')"
|
||||||
|
)
|
||||||
|
.bind(&pid)
|
||||||
|
.bind(&uid)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For orphan projects (no owner, no members), assign to first user if one exists
|
||||||
|
let first_user: Option<(String,)> = sqlx::query_as(
|
||||||
|
"SELECT id FROM users ORDER BY created_at ASC LIMIT 1"
|
||||||
|
)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or(None);
|
||||||
|
|
||||||
|
if let Some((first_uid,)) = first_user {
|
||||||
|
let orphans: Vec<(String,)> = sqlx::query_as(
|
||||||
|
"SELECT p.id FROM projects p \
|
||||||
|
WHERE p.deleted = 0 \
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id)"
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
for (pid,) in orphans {
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"INSERT OR IGNORE INTO project_members (project_id, user_id, role) VALUES (?, ?, 'owner')"
|
||||||
|
)
|
||||||
|
.bind(&pid)
|
||||||
|
.bind(&first_uid)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
50
src/main.rs
50
src/main.rs
@@ -123,25 +123,41 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
let obj_root = std::env::var("OBJ_ROOT").unwrap_or_else(|_| "/data/obj".to_string());
|
let obj_root = std::env::var("OBJ_ROOT").unwrap_or_else(|_| "/data/obj".to_string());
|
||||||
|
|
||||||
let auth_config = match (
|
let auth_config = {
|
||||||
std::env::var("GOOGLE_CLIENT_ID"),
|
let jwt_secret = std::env::var("JWT_SECRET")
|
||||||
std::env::var("GOOGLE_CLIENT_SECRET"),
|
.unwrap_or_else(|_| uuid::Uuid::new_v4().to_string());
|
||||||
) {
|
let public_url = std::env::var("PUBLIC_URL")
|
||||||
(Ok(client_id), Ok(client_secret)) => {
|
.unwrap_or_else(|_| "https://tori.euphon.cloud".to_string());
|
||||||
let jwt_secret = std::env::var("JWT_SECRET")
|
|
||||||
.unwrap_or_else(|_| uuid::Uuid::new_v4().to_string());
|
// Try TikTok SSO first, then Google OAuth
|
||||||
let public_url = std::env::var("PUBLIC_URL")
|
if let (Ok(id), Ok(secret)) = (
|
||||||
.unwrap_or_else(|_| "https://tori.euphon.cloud".to_string());
|
std::env::var("SSO_CLIENT_ID"),
|
||||||
tracing::info!("Google OAuth enabled (public_url={})", public_url);
|
std::env::var("SSO_CLIENT_SECRET"),
|
||||||
|
) {
|
||||||
|
tracing::info!("TikTok SSO enabled (public_url={})", public_url);
|
||||||
Some(api::auth::AuthConfig {
|
Some(api::auth::AuthConfig {
|
||||||
google_client_id: client_id,
|
provider: api::auth::OAuthProvider::TikTokSso {
|
||||||
google_client_secret: client_secret,
|
client_id: id,
|
||||||
|
client_secret: secret,
|
||||||
|
},
|
||||||
jwt_secret,
|
jwt_secret,
|
||||||
public_url,
|
public_url,
|
||||||
})
|
})
|
||||||
}
|
} else if let (Ok(id), Ok(secret)) = (
|
||||||
_ => {
|
std::env::var("GOOGLE_CLIENT_ID"),
|
||||||
tracing::warn!("GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET not set, auth disabled");
|
std::env::var("GOOGLE_CLIENT_SECRET"),
|
||||||
|
) {
|
||||||
|
tracing::info!("Google OAuth enabled (public_url={})", public_url);
|
||||||
|
Some(api::auth::AuthConfig {
|
||||||
|
provider: api::auth::OAuthProvider::Google {
|
||||||
|
client_id: id,
|
||||||
|
client_secret: secret,
|
||||||
|
},
|
||||||
|
jwt_secret,
|
||||||
|
public_url,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
tracing::warn!("No OAuth configured (set SSO_CLIENT_ID/SSO_CLIENT_SECRET or GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET)");
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -156,6 +172,10 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
|
// Health check (public, for k8s probes)
|
||||||
|
.route("/tori/api/health", axum::routing::get(|| async {
|
||||||
|
axum::Json(serde_json::json!({"status": "ok"}))
|
||||||
|
}))
|
||||||
// Auth routes are public
|
// Auth routes are public
|
||||||
.nest("/tori/api/auth", api::auth::router(state.clone()))
|
.nest("/tori/api/auth", api::auth::router(state.clone()))
|
||||||
// Protected API routes
|
// Protected API routes
|
||||||
|
|||||||
@@ -1,5 +1,25 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
import { auth } from '../api'
|
import { auth } from '../api'
|
||||||
|
|
||||||
|
const provider = ref<string | null>(null)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${import.meta.env.BASE_URL.replace(/\/$/, '')}/api/auth/provider`)
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
provider.value = data.provider
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fallback to generic
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const providerLabel: Record<string, string> = {
|
||||||
|
'google': 'Google',
|
||||||
|
'tiktok-sso': 'TikTok SSO',
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -7,14 +27,20 @@ import { auth } from '../api'
|
|||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
<h1 class="login-title">Tori</h1>
|
<h1 class="login-title">Tori</h1>
|
||||||
<p class="login-subtitle">Sign in to continue</p>
|
<p class="login-subtitle">Sign in to continue</p>
|
||||||
<a :href="auth.loginUrl" class="google-btn">
|
<a :href="auth.loginUrl" class="login-btn">
|
||||||
<svg class="google-icon" viewBox="0 0 24 24" width="18" height="18">
|
<!-- Google icon -->
|
||||||
|
<svg v-if="provider === 'google'" class="provider-icon" viewBox="0 0 24 24" width="18" height="18">
|
||||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"/>
|
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"/>
|
||||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||||
</svg>
|
</svg>
|
||||||
Sign in with Google
|
<!-- Generic lock icon for other providers -->
|
||||||
|
<svg v-else class="provider-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||||
|
</svg>
|
||||||
|
Sign in{{ provider ? ` with ${providerLabel[provider] || provider}` : '' }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,7 +77,7 @@ import { auth } from '../api'
|
|||||||
margin: 0 0 32px;
|
margin: 0 0 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.google-btn {
|
.login-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -67,12 +93,12 @@ import { auth } from '../api'
|
|||||||
transition: background 0.15s, box-shadow 0.15s;
|
transition: background 0.15s, box-shadow 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.google-btn:hover {
|
.login-btn:hover {
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.google-icon {
|
.provider-icon {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user