Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions PR_DOCUMENTATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# PR Documentation: GitHub OAuth Account Linking & Workspace Header Refinement

## Summary

This PR implements GitHub OAuth account linking functionality and refines the workspace header layout.

### Backend

- Added `GitHubAccount` struct and `github_account` field to the `User` model in `backend/api/src/model/user.rs`
- Added `update_user_github_account` method to `AuthService` in `backend/api/src/services/auth_service.rs`
- Updated `github_callback` handler in `backend/api/src/api/handlers/auth_handler.rs` to link GitHub accounts after first OAuth authentication
- Added `github_unlink` endpoint in `auth_handler.rs` to allow users to disconnect their GitHub account
- Registered the `github_unlink` route in `backend/api/src/api/routers/auth_router.rs`

### Frontend

- Added "Unlink GitHub" button in `frontend/src/features/ProfilePage.tsx` under the GitHub connection field
- Added account linking section in `frontend/src/features/SignInPage.tsx` for GitHub OAuth flow
- Added account linking section in `frontend/src/features/GetStartedPage.tsx` for GitHub OAuth flow
- Streamlined the workspace header in `frontend/src/components/Layout.tsx` to reduce dead space:
- Reduced header padding and gaps for a tighter layout
- Made the search bar more compact (`w-48` instead of `w-64`)
- Reduced logo and icon sizes for better visual balance
- Narrowed the network selector dropdown

### Key Changes

| File | Change |
|------|--------|
| `backend/api/src/model/user.rs` | Added `GitHubAccount` struct and `github_account` field |
| `backend/api/src/services/auth_service.rs` | Added `update_user_github_account` method |
| `backend/api/src/api/handlers/auth_handler.rs` | Added GitHub account linking in callback and `github_unlink` handler |
| `backend/api/src/api/routers/auth_router.rs` | Registered `/auth/github/unlink` POST route |
| `frontend/src/features/ProfilePage.tsx` | Added Unlink GitHub button and connection UI |
| `frontend/src/components/Layout.tsx` | Streamlined header spacing and sizing |

## Environment Variables

Required environment variables for GitHub OAuth:

- `GITHUB_CLIENT_ID`
- `GITHUB_CLIENT_SECRET`
- `GITHUB_REDIRECT_URL`
- `FRONTEND_URL`

The GitHub redirect URL should be configured in the GitHub OAuth app and match the backend callback endpoint (`/auth/github/callback`).

## Notes

- The backend uses GitHub's `user:email` scope to request email access.
- If the GitHub profile does not include a public email, the callback also fetches the authenticated user's email list and selects the primary verified address.
- The generated JWT flow and existing auth state handling are reused so no duplicate auth path logic was added.
- The GitHub account linking is triggered on first OAuth login. Subsequent logins reuse the existing link.
- Users can unlink their GitHub account from the Profile page.

## Validation

- Backend compile validation was attempted, but `cargo` was not available in the current terminal environment.

## Follow-up

- Confirm `FRONTEND_URL` and GitHub redirect URL values in deployment.
- Add tests for GitHub callback path and UI coverage if desired.
- Consider adding GitHub connection status to the user profile display.
- Consider adding support for linking additional OAuth providers (Google, etc.)
32 changes: 31 additions & 1 deletion backend/api/src/api/handlers/auth_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use crate::dtos::{
},
response::{AuthResponse, UserResponse},
};
use crate::model::user::GitHubAccount;
use crate::services::auth_service::AuthService;
use crate::utils::error::AppError;
use axum::{Json, extract::State, http::header, response::IntoResponse};
Expand Down Expand Up @@ -90,6 +91,23 @@ pub async fn logout() -> Result<Json<Value>, AppError> {
Ok(Json(json!({ "message": "Logged out successfully" })))
}

pub async fn github_unlink(
State(service): State<AuthService>,
claims: crate::utils::auth_jwt::Claims,
) -> Result<Json<Value>, AppError> {
let user = service.get_user_profile_by_email(&claims.email).await?;

if user.github_account.is_none() {
return Err(AppError::BadRequest("No GitHub account linked".into()));
}

service
.update_user_github_account(&claims.email, None)
.await?;

Ok(Json(json!({ "message": "GitHub account unlinked successfully" })))
}

pub async fn get_user_profile(
State(service): State<AuthService>,
claims: crate::utils::auth_jwt::Claims,
Expand Down Expand Up @@ -452,7 +470,8 @@ pub async fn google_callback(

pub async fn github_login() -> Result<axum::response::Response, AppError> {
let client_id = std::env::var("GITHUB_CLIENT_ID").unwrap_or_default();
if client_id.trim().is_empty() {
let client_secret = std::env::var("GITHUB_CLIENT_SECRET").unwrap_or_default();
if client_id.trim().is_empty() || client_secret.trim().is_empty() {
return Err(AppError::BadRequest(
"GitHub OAuth is not configured. Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET.".into(),
));
Expand Down Expand Up @@ -591,6 +610,17 @@ pub async fn github_callback(
.oauth_login_or_register(github_id.to_string(), email)
.await?;

if auth_res.user.github_account.is_none() {
let github_account = GitHubAccount {
id: user_data["id"].to_string(),
login: user_data["login"].as_str().unwrap_or("").to_string(),
access_token: Some(access_token.to_string()),
};
service
.update_user_github_account(&auth_res.user.email, Some(github_account))
.await?;
}

// See the matching comment in google_callback: a cookie can't bridge
// the backend's and frontend's separate domains, so the token goes in
// a URL fragment instead, which never reaches any server.
Expand Down
4 changes: 4 additions & 0 deletions backend/api/src/api/routers/auth_router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,5 +60,9 @@ pub fn router(service: AuthService) -> Router {
"/github/callback",
axum::routing::get(auth_handler::github_callback),
)
.route(
"/github/unlink",
axum::routing::post(auth_handler::github_unlink),
)
.with_state(service)
}
3 changes: 2 additions & 1 deletion backend/api/src/dtos/response.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::model::user::NotificationPreferences;
use crate::model::user::{GitHubAccount, NotificationPreferences};
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
Expand All @@ -8,6 +8,7 @@ pub struct UserResponse {
pub email: String,
pub created_at: String,
pub notification_preferences: NotificationPreferences,
pub github_account: Option<GitHubAccount>,
}

#[derive(Debug, Serialize, Deserialize)]
Expand Down
11 changes: 11 additions & 0 deletions backend/api/src/model/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ use serde::{Deserialize, Serialize};

use crate::model::network::Network;

#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct GitHubAccount {
pub id: String,
pub login: String,
#[serde(skip_serializing)]
pub access_token: Option<String>,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct User {
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
Expand All @@ -17,6 +25,8 @@ pub struct User {
pub network: Network,
pub created_at: DateTime<Utc>,
#[serde(default)]
pub github_account: Option<GitHubAccount>,
#[serde(default)]
pub notification_preferences: NotificationPreferences,
}

Expand Down Expand Up @@ -56,6 +66,7 @@ impl User {
tier: PlanTier::Free,
network: Network::default(),
created_at: Utc::now(),
github_account: None,
notification_preferences: NotificationPreferences::default(),
}
}
Expand Down
13 changes: 13 additions & 0 deletions backend/api/src/services/auth_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ impl AuthService {
email: user.email.clone(),
created_at: user.created_at.to_string(),
notification_preferences: user.notification_preferences.clone(),
github_account: user.github_account.clone(),
}
}

Expand Down Expand Up @@ -328,6 +329,17 @@ impl AuthService {
Ok(Self::to_user_response(&updated_user))
}

pub async fn update_user_github_account(
&self,
email: &str,
github_account: Option<crate::model::user::GitHubAccount>,
) -> Result<User, AppError> {
let mut user = self.repo.find_by_email(email).await?;
user.github_account = github_account;

self.repo.update(&user).await
}

pub async fn oauth_login_or_register(
&self,
google_sub: String,
Expand Down Expand Up @@ -446,6 +458,7 @@ mod oauth_tests {
tier: crate::model::user::PlanTier::Free,
network: crate::model::network::Network::Mainnet,
created_at: Utc::now(),
github_account: None,
notification_preferences: crate::model::user::NotificationPreferences::default(),
}
}
Expand Down
54 changes: 28 additions & 26 deletions frontend/src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,47 +150,49 @@ export const Layout: React.FC<LayoutProps> = ({
</div>
)}

<header className="h-12 bg-slate-50 dark:bg-near-black border-b border-slate-200 dark:border-white/10 flex items-center justify-between px-4 shrink-0 z-20">
<div className="flex items-center gap-4">
<header className="h-12 bg-slate-50 dark:bg-near-black border-b border-slate-200 dark:border-white/10 flex items-center justify-between px-3 shrink-0 z-20">
<div className="flex items-center gap-2">
<button
className="flex items-center gap-2 font-bold text-slate-900 dark:text-slate-100 group cursor-pointer"
className="flex items-center gap-1.5 font-bold text-slate-900 dark:text-slate-100 group cursor-pointer"
onClick={() => appStore.setActiveTab(null)}
>
<div className="w-6 h-6 rounded flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
<div className="w-5 h-5 rounded flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
<TxioLogoSmall />
</div>
<span className="text-sm tracking-tight group-hover:text-sui-300 transition-colors">txio</span>
<span className="text-xs tracking-tight group-hover:text-sui-300 transition-colors">txio</span>
</button>
<div className="h-4 w-px bg-white/10 mx-2"></div>
<button onClick={() => appStore.toggleSidebar()} className={`p-1.5 rounded hover:bg-slate-100 dark:hover:bg-white/10 transition-colors ${isSidebarOpen ? 'text-electric-violet' : 'text-slate-500'}`}>
<PanelLeft size={16} />
<div className="h-3 w-px bg-white/10 mx-1.5"></div>
<button onClick={() => appStore.toggleSidebar()} className={`p-1.5 rounded hover:bg-slate-100 dark:hover:bg-white/10 transition-colors ${isSidebarOpen ? 'text-electric-violet' : 'text-slate-500'}`} title="Toggle sidebar">
<PanelLeft size={14} />
</button>
<button
onClick={() => appStore.setCommandPalette(true)}
className="flex items-center gap-2 bg-white dark:bg-dark-indigo-glow border border-slate-200 dark:border-white/5 hover:border-slate-300 dark:border-white/20 hover:bg-slate-100 dark:hover:bg-[#111] px-3 py-1.5 rounded-full text-xs text-slate-400 w-64 transition-all group shadow-inner"
className="flex items-center gap-1.5 bg-white dark:bg-dark-indigo-glow border border-slate-200 dark:border-white/5 hover:border-slate-300 dark:border-white/20 hover:bg-slate-100 dark:hover:bg-[#111] px-2 py-1 rounded-full text-xs text-slate-400 w-48 transition-all group shadow-inner"
title="Search commands (Ctrl+K)"
>
<Search size={12} className="group-hover:text-electric-violet" />
<span>Search commands...</span>
<div className="ml-auto flex items-center gap-1">
<span className="bg-slate-100 dark:bg-white/5 px-1 rounded text-[10px] text-slate-500 group-hover:text-slate-600 dark:text-slate-300">⌘</span>
<span className="bg-slate-100 dark:bg-white/5 px-1 rounded text-[10px] text-slate-500 group-hover:text-slate-600 dark:text-slate-300">K</span>
<Search size={11} className="group-hover:text-electric-violet" />
<span className="whitespace-nowrap">Search...</span>
<div className="ml-auto hidden md:flex items-center gap-0.5">
<span className="bg-slate-100 dark:bg-white/5 px-1 rounded text-[9px] text-slate-500 group-hover:text-slate-600 dark:text-slate-300">⌘</span>
<span className="bg-slate-100 dark:bg-white/5 px-1 rounded text-[9px] text-slate-500 group-hover:text-slate-600 dark:text-slate-300">K</span>
</div>
</button>
</div>

<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5">
<div className="relative" ref={networkMenuRef}>
<button
onClick={() => setIsNetworkMenuOpen(!isNetworkMenuOpen)}
className={`flex items-center gap-2 px-3 py-1 rounded-full bg-white dark:bg-dark-indigo-glow border border-slate-200 dark:border-white/10 text-xs hover:bg-slate-100 dark:hover:bg-[#111] transition-all hover:border-slate-300 dark:border-white/20 shadow-sm ${isNetworkMenuOpen ? 'border-slate-600 bg-slate-200 dark:bg-slate-800' : ''}`}
className={`flex items-center gap-1 px-2 py-1 rounded-full bg-white dark:bg-dark-indigo-glow border border-slate-200 dark:border-white/10 text-xs hover:bg-slate-100 dark:hover:bg-[#111] transition-all hover:border-slate-300 dark:border-white/20 shadow-sm ${isNetworkMenuOpen ? 'border-slate-600 bg-slate-200 dark:bg-slate-800' : ''} w-24`}
title="Network"
>
<div className={`w-2 h-2 rounded-full ${rpcHealth?.status === 'healthy' ? 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.6)]' : rpcHealth?.status === 'degraded' ? 'bg-amber-500' : 'bg-red-500'} animate-pulse`}></div>
<span className="text-slate-600 dark:text-slate-300 capitalize font-medium">{network}</span>
<ChevronDown size={10} className={`text-slate-500 transition-transform duration-200 ${isNetworkMenuOpen ? 'rotate-180' : ''}`}/>
<div className={`w-1.5 h-1.5 rounded-full ${rpcHealth?.status === 'healthy' ? 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.6)]' : rpcHealth?.status === 'degraded' ? 'bg-amber-500' : 'bg-red-500'} animate-pulse`}></div>
<span className="text-slate-600 dark:text-slate-300 capitalize font-medium truncate max-w-[40px]">{network}</span>
<ChevronDown size={10} className={`text-slate-500 transition-transform duration-200 shrink-0 ${isNetworkMenuOpen ? 'rotate-180' : ''}`}/>
</button>

{isNetworkMenuOpen && (
<div className="absolute top-full right-0 mt-2 w-48 bg-white dark:bg-[#18181b] border border-slate-200 dark:border-white/10 rounded-xl shadow-2xl overflow-hidden z-50 animate-in fade-in zoom-in-95 duration-100">
<div className="absolute top-full right-0 mt-2 w-44 bg-white dark:bg-[#18181b] border border-slate-200 dark:border-white/10 rounded-xl shadow-2xl overflow-hidden z-50 animate-in fade-in zoom-in-95 duration-100">
<div className="p-1">
{ALL_NETWORKS.map((net) => (
<button
Expand All @@ -202,7 +204,7 @@ export const Layout: React.FC<LayoutProps> = ({
: 'text-slate-400 hover:bg-slate-100 dark:bg-white/5 hover:text-slate-700 dark:text-slate-200'
}`}
>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<div className={`w-1.5 h-1.5 rounded-full ${
net === 'mainnet' ? 'bg-emerald-500' :
net === 'testnet' ? 'bg-amber-500' :
Expand All @@ -214,8 +216,8 @@ export const Layout: React.FC<LayoutProps> = ({
</button>
))}
</div>
<div className="border-t border-slate-200 dark:border-white/10 p-2 bg-slate-100/60 dark:bg-near-black/20">
<div className="flex justify-between text-[9px] text-slate-500 font-mono">
<div className="border-t border-slate-200 dark:border-white/10 p-1.5 bg-slate-100/60 dark:bg-near-black/20">
<div className="flex justify-between text-[8px] text-slate-500 font-mono">
<span>Latency</span>
<span className={rpcHealth?.status === 'healthy' ? 'text-emerald-500' : rpcHealth?.status === 'degraded' ? 'text-amber-500' : 'text-red-400'}>
{rpcHealth?.latency?.[0]
Expand All @@ -228,11 +230,11 @@ export const Layout: React.FC<LayoutProps> = ({
)}
</div>

<button onClick={() => appStore.toggleInspector()} className={`p-1.5 rounded hover:bg-slate-100 dark:hover:bg-white/10 transition-colors ${isInspectorOpen ? 'text-electric-violet' : 'text-slate-500'}`}>
<PanelRight size={16} />
<button onClick={() => appStore.toggleInspector()} className={`p-1.5 rounded hover:bg-slate-100 dark:hover:bg-white/10 transition-colors ${isInspectorOpen ? 'text-electric-violet' : 'text-slate-500'}`} title="Toggle inspector">
<PanelRight size={14} />
</button>

<button onClick={() => appStore.setAuthModal(true)} className="w-8 h-8 cursor-pointer hover:ring-2 ring-electric-violet/50 rounded-xl transition-all">
<button onClick={() => appStore.setAuthModal(true)} className="w-7 h-7 cursor-pointer hover:ring-2 ring-electric-violet/50 rounded-lg transition-all" title="Account">
<Avatar size="sm" src={user?.avatarUrl} />
</button>
</div>
Expand Down
Loading
Loading