@@ -91,6 +91,85 @@ pub async fn cloud_balance(
9191 } ) )
9292}
9393
94+ /// Login response — returns the OAuth login URL.
95+ #[ derive( Debug , Serialize ) ]
96+ pub struct LoginResponse {
97+ pub login_url : String ,
98+ }
99+
100+ /// POST /api/auth/login — returns the OAuth login URL.
101+ #[ tracing:: instrument( skip( state) ) ]
102+ pub async fn auth_login (
103+ State ( state) : State < Arc < AppState > > ,
104+ ) -> Result < Json < LoginResponse > , ( StatusCode , Json < serde_json:: Value > ) > {
105+ let cloud = state. cloud_client . as_ref ( ) . ok_or_else ( || {
106+ (
107+ StatusCode :: SERVICE_UNAVAILABLE ,
108+ Json ( serde_json:: json!( {
109+ "error" : "Cloud features not available"
110+ } ) ) ,
111+ )
112+ } ) ?;
113+
114+ Ok ( Json ( LoginResponse {
115+ login_url : cloud. login_url ( None , None ) ,
116+ } ) )
117+ }
118+
119+ /// Profile response — user profile information.
120+ #[ derive( Debug , Serialize ) ]
121+ pub struct ProfileResponse {
122+ pub user_id : String ,
123+ pub email : Option < String > ,
124+ pub display_name : Option < String > ,
125+ pub plan : String ,
126+ pub credits_balance : u32 ,
127+ }
128+
129+ /// GET /api/auth/profile — returns the cached user profile.
130+ #[ tracing:: instrument]
131+ pub async fn auth_profile ( ) -> Result < Json < ProfileResponse > , ( StatusCode , Json < serde_json:: Value > ) >
132+ {
133+ if !cloud:: auth:: is_authenticated ( ) {
134+ return Err ( (
135+ StatusCode :: UNAUTHORIZED ,
136+ Json ( serde_json:: json!( {
137+ "error" : "Not authenticated"
138+ } ) ) ,
139+ ) ) ;
140+ }
141+
142+ let profile = cloud:: auth:: load_cached_profile ( ) . ok_or_else ( || {
143+ (
144+ StatusCode :: NOT_FOUND ,
145+ Json ( serde_json:: json!( {
146+ "error" : "No cached profile found"
147+ } ) ) ,
148+ )
149+ } ) ?;
150+
151+ Ok ( Json ( ProfileResponse {
152+ user_id : profile. user_id ,
153+ email : profile. email ,
154+ display_name : profile. github_handle ,
155+ plan : profile. plan . to_string ( ) ,
156+ credits_balance : profile. credits_balance ,
157+ } ) )
158+ }
159+
160+ /// Logout response.
161+ #[ derive( Debug , Serialize ) ]
162+ pub struct LogoutResponse {
163+ pub success : bool ,
164+ }
165+
166+ /// POST /api/auth/logout — clears auth credentials and cached profile.
167+ #[ tracing:: instrument]
168+ pub async fn auth_logout ( ) -> Json < LogoutResponse > {
169+ let _ = cloud:: auth:: logout ( ) ;
170+ Json ( LogoutResponse { success : true } )
171+ }
172+
94173/// Feature cost info for the frontend.
95174#[ derive( Debug , Serialize ) ]
96175pub struct FeatureCostResponse {
@@ -271,6 +350,69 @@ mod tests {
271350 assert_eq ! ( features[ 1 ] [ "credits" ] , 3 ) ;
272351 }
273352
353+ #[ test]
354+ fn auth_login_response_serialize ( ) {
355+ let resp = LoginResponse {
356+ login_url : "https://api.shepherd.codes/api/auth/login?provider=github" . to_string ( ) ,
357+ } ;
358+ let json = serde_json:: to_value ( & resp) . unwrap ( ) ;
359+ assert_eq ! (
360+ json[ "login_url" ] ,
361+ "https://api.shepherd.codes/api/auth/login?provider=github"
362+ ) ;
363+ // Ensure only expected fields are present
364+ let obj = json. as_object ( ) . unwrap ( ) ;
365+ assert_eq ! ( obj. len( ) , 1 ) ;
366+ assert ! ( obj. contains_key( "login_url" ) ) ;
367+ }
368+
369+ #[ test]
370+ fn auth_profile_response_serialize ( ) {
371+ let resp = ProfileResponse {
372+ user_id : "u-123" . to_string ( ) ,
373+ email : Some ( "user@example.com" . to_string ( ) ) ,
374+ display_name : Some ( "testuser" . to_string ( ) ) ,
375+ plan : "pro" . to_string ( ) ,
376+ credits_balance : 42 ,
377+ } ;
378+ let json = serde_json:: to_value ( & resp) . unwrap ( ) ;
379+ assert_eq ! ( json[ "user_id" ] , "u-123" ) ;
380+ assert_eq ! ( json[ "email" ] , "user@example.com" ) ;
381+ assert_eq ! ( json[ "display_name" ] , "testuser" ) ;
382+ assert_eq ! ( json[ "plan" ] , "pro" ) ;
383+ assert_eq ! ( json[ "credits_balance" ] , 42 ) ;
384+ // Ensure all expected fields are present
385+ let obj = json. as_object ( ) . unwrap ( ) ;
386+ assert_eq ! ( obj. len( ) , 5 ) ;
387+ }
388+
389+ #[ test]
390+ fn auth_profile_response_serialize_with_nulls ( ) {
391+ let resp = ProfileResponse {
392+ user_id : "u-456" . to_string ( ) ,
393+ email : None ,
394+ display_name : None ,
395+ plan : "free" . to_string ( ) ,
396+ credits_balance : 0 ,
397+ } ;
398+ let json = serde_json:: to_value ( & resp) . unwrap ( ) ;
399+ assert_eq ! ( json[ "user_id" ] , "u-456" ) ;
400+ assert ! ( json[ "email" ] . is_null( ) ) ;
401+ assert ! ( json[ "display_name" ] . is_null( ) ) ;
402+ assert_eq ! ( json[ "plan" ] , "free" ) ;
403+ assert_eq ! ( json[ "credits_balance" ] , 0 ) ;
404+ }
405+
406+ #[ test]
407+ fn auth_logout_response_serialize ( ) {
408+ let resp = LogoutResponse { success : true } ;
409+ let json = serde_json:: to_value ( & resp) . unwrap ( ) ;
410+ assert ! ( json[ "success" ] . as_bool( ) . unwrap( ) ) ;
411+ let obj = json. as_object ( ) . unwrap ( ) ;
412+ assert_eq ! ( obj. len( ) , 1 ) ;
413+ assert ! ( obj. contains_key( "success" ) ) ;
414+ }
415+
274416 #[ test]
275417 fn credit_balance_response_all_fields ( ) {
276418 let resp = CreditBalanceResponse {
0 commit comments