@@ -61,6 +61,13 @@ pub struct ProfileInfo {
6161 pub api_key_hint : Option < String > ,
6262}
6363
64+ #[ derive( Debug , Clone ) ]
65+ pub ( crate ) struct StoredProfileInfo {
66+ pub name : String ,
67+ pub is_oauth : bool ,
68+ pub org_name : Option < String > ,
69+ }
70+
6471#[ derive( Debug , Clone , PartialEq , Eq ) ]
6572pub struct AvailableOrg {
6673 pub id : String ,
@@ -125,6 +132,19 @@ pub fn list_profiles() -> Result<Vec<ProfileInfo>> {
125132 . collect ( ) )
126133}
127134
135+ pub ( crate ) fn list_stored_profiles ( ) -> Result < Vec < StoredProfileInfo > > {
136+ let store = load_auth_store ( ) ?;
137+ Ok ( store
138+ . profiles
139+ . iter ( )
140+ . map ( |( name, profile) | StoredProfileInfo {
141+ name : name. clone ( ) ,
142+ is_oauth : profile. auth_kind == AuthKind :: Oauth ,
143+ org_name : profile. org_name . clone ( ) ,
144+ } )
145+ . collect ( ) )
146+ }
147+
128148pub fn resolve_org_to_profile ( identifier : & str , profiles : & [ ProfileInfo ] ) -> Result < String > {
129149 if profiles. is_empty ( ) {
130150 bail ! ( "no auth profiles found. Run `bt auth login` to create one." ) ;
@@ -233,6 +253,28 @@ pub async fn list_available_orgs(base: &BaseArgs) -> Result<Vec<AvailableOrg>> {
233253 . collect ( ) )
234254}
235255
256+ pub ( crate ) async fn list_available_orgs_for_api_key (
257+ api_key : & str ,
258+ app_url : & str ,
259+ ) -> Result < Vec < AvailableOrg > > {
260+ let mut orgs = fetch_login_orgs ( api_key, app_url) . await ?;
261+ orgs. sort_by ( |a, b| {
262+ a. name
263+ . to_ascii_lowercase ( )
264+ . cmp ( & b. name . to_ascii_lowercase ( ) )
265+ . then_with ( || a. name . cmp ( & b. name ) )
266+ } ) ;
267+
268+ Ok ( orgs
269+ . into_iter ( )
270+ . map ( |org| AvailableOrg {
271+ id : org. id ,
272+ name : org. name ,
273+ api_url : org. api_url ,
274+ } )
275+ . collect ( ) )
276+ }
277+
236278#[ derive( Debug , Clone , Serialize , Deserialize , Default ) ]
237279struct AuthStore {
238280 #[ serde( default ) ]
@@ -762,11 +804,16 @@ async fn run_login_set(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> {
762804 ) ?;
763805 let selected_api_url =
764806 resolve_profile_api_url ( base. api_url . clone ( ) , selected_org. as_ref ( ) , & login_orgs) ?;
765- let profile_name = resolve_profile_name (
807+ let store = load_auth_store ( ) ?;
808+ let ( profile_name, should_confirm_overwrite) = resolve_api_key_login_profile_name (
766809 base. profile . as_deref ( ) ,
767810 selected_org. as_ref ( ) . map ( |org| org. name . as_str ( ) ) ,
811+ & selected_api_url,
812+ & store,
768813 ) ?;
769- confirm_profile_overwrite ( & profile_name) ?;
814+ if should_confirm_overwrite {
815+ confirm_profile_overwrite ( & profile_name) ?;
816+ }
770817
771818 commit_api_key_profile (
772819 & profile_name,
@@ -862,11 +909,19 @@ async fn run_login_oauth(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> {
862909 ) ?;
863910 let selected_api_url =
864911 resolve_profile_api_url ( base. api_url . clone ( ) , selected_org. as_ref ( ) , & login_orgs) ?;
865- let profile_name = resolve_profile_name (
912+ let store = load_auth_store ( ) ?;
913+ let jwt_id = decode_jwt_identity ( & oauth_tokens. access_token ) ;
914+ let ( profile_name, should_confirm_overwrite) = resolve_oauth_login_profile_name (
866915 base. profile . as_deref ( ) ,
867916 selected_org. as_ref ( ) . map ( |org| org. name . as_str ( ) ) ,
917+ & selected_api_url,
918+ & app_url,
919+ & jwt_id,
920+ & store,
868921 ) ?;
869- confirm_profile_overwrite ( & profile_name) ?;
922+ if should_confirm_overwrite {
923+ confirm_profile_overwrite ( & profile_name) ?;
924+ }
870925
871926 commit_oauth_profile (
872927 & profile_name,
@@ -960,11 +1015,19 @@ pub(crate) async fn login_interactive_oauth(base: &mut BaseArgs) -> Result<Strin
9601015 ) ?;
9611016 let selected_api_url =
9621017 resolve_profile_api_url ( base. api_url . clone ( ) , selected_org. as_ref ( ) , & login_orgs) ?;
963- let profile_name = resolve_profile_name (
1018+ let store = load_auth_store ( ) ?;
1019+ let jwt_id = decode_jwt_identity ( & oauth_tokens. access_token ) ;
1020+ let ( profile_name, should_confirm_overwrite) = resolve_oauth_login_profile_name (
9641021 base. profile . as_deref ( ) ,
9651022 selected_org. as_ref ( ) . map ( |org| org. name . as_str ( ) ) ,
1023+ & selected_api_url,
1024+ & app_url,
1025+ & jwt_id,
1026+ & store,
9661027 ) ?;
967- confirm_profile_overwrite ( & profile_name) ?;
1028+ if should_confirm_overwrite {
1029+ confirm_profile_overwrite ( & profile_name) ?;
1030+ }
9681031
9691032 commit_oauth_profile (
9701033 & profile_name,
@@ -979,7 +1042,7 @@ pub(crate) async fn login_interactive_oauth(base: &mut BaseArgs) -> Result<Strin
9791042 Ok ( profile_name)
9801043}
9811044
982- fn commit_api_key_profile (
1045+ pub ( crate ) fn commit_api_key_profile (
9831046 profile_name : & str ,
9841047 api_key : & str ,
9851048 api_url : String ,
@@ -1172,6 +1235,102 @@ fn resolve_profile_name(
11721235 . to_string ( ) )
11731236}
11741237
1238+ fn default_profile_name ( suggested_org_name : Option < & str > ) -> String {
1239+ suggested_org_name
1240+ . map ( str:: trim)
1241+ . filter ( |name| !name. is_empty ( ) )
1242+ . unwrap_or ( "profile" )
1243+ . to_string ( )
1244+ }
1245+
1246+ fn next_available_profile_name ( base_name : & str , store : & AuthStore ) -> String {
1247+ if !store. profiles . contains_key ( base_name) {
1248+ return base_name. to_string ( ) ;
1249+ }
1250+
1251+ ( 2u32 ..)
1252+ . map ( |idx| format ! ( "{base_name}-{idx}" ) )
1253+ . find ( |candidate| !store. profiles . contains_key ( candidate) )
1254+ . expect ( "profile name sequence is infinite" )
1255+ }
1256+
1257+ fn resolve_api_key_login_profile_name (
1258+ explicit_profile : Option < & str > ,
1259+ suggested_org_name : Option < & str > ,
1260+ selected_api_url : & str ,
1261+ store : & AuthStore ,
1262+ ) -> Result < ( String , bool ) > {
1263+ if let Some ( profile_name) = explicit_profile {
1264+ let profile_name = resolve_profile_name ( Some ( profile_name) , suggested_org_name) ?;
1265+ return Ok ( (
1266+ profile_name. clone ( ) ,
1267+ store. profiles . contains_key ( & profile_name) ,
1268+ ) ) ;
1269+ }
1270+
1271+ let default_name = default_profile_name ( suggested_org_name) ;
1272+ let has_matching_api_key_profile = store. profiles . values ( ) . any ( |profile| {
1273+ profile. auth_kind == AuthKind :: ApiKey
1274+ && profile. api_url . as_deref ( ) == Some ( selected_api_url)
1275+ && profile. org_name . as_deref ( ) == suggested_org_name
1276+ } ) ;
1277+
1278+ if has_matching_api_key_profile {
1279+ return Ok ( ( next_available_profile_name ( & default_name, store) , false ) ) ;
1280+ }
1281+
1282+ Ok ( (
1283+ default_name. clone ( ) ,
1284+ store. profiles . contains_key ( & default_name) ,
1285+ ) )
1286+ }
1287+
1288+ fn resolve_oauth_login_profile_name (
1289+ explicit_profile : Option < & str > ,
1290+ suggested_org_name : Option < & str > ,
1291+ selected_api_url : & str ,
1292+ app_url : & str ,
1293+ jwt_id : & JwtIdentity ,
1294+ store : & AuthStore ,
1295+ ) -> Result < ( String , bool ) > {
1296+ if let Some ( profile_name) = explicit_profile {
1297+ let profile_name = resolve_profile_name ( Some ( profile_name) , suggested_org_name) ?;
1298+ return Ok ( (
1299+ profile_name. clone ( ) ,
1300+ store. profiles . contains_key ( & profile_name) ,
1301+ ) ) ;
1302+ }
1303+
1304+ let matched_profile = store
1305+ . profiles
1306+ . iter ( )
1307+ . filter ( |( _, profile) | {
1308+ profile. auth_kind == AuthKind :: Oauth
1309+ && profile. api_url . as_deref ( ) == Some ( selected_api_url)
1310+ && profile. app_url . as_deref ( ) == Some ( app_url)
1311+ && profile. org_name . as_deref ( ) == suggested_org_name
1312+ && profile. user_name == jwt_id. name
1313+ && profile. email == jwt_id. email
1314+ } )
1315+ . max_by ( |( left_name, left) , ( right_name, right) | {
1316+ left. oauth_access_expires_at
1317+ . unwrap_or_default ( )
1318+ . cmp ( & right. oauth_access_expires_at . unwrap_or_default ( ) )
1319+ . then_with ( || left_name. cmp ( right_name) )
1320+ } )
1321+ . map ( |( name, _) | name. clone ( ) ) ;
1322+
1323+ if let Some ( profile_name) = matched_profile {
1324+ return Ok ( ( profile_name, false ) ) ;
1325+ }
1326+
1327+ let default_name = default_profile_name ( suggested_org_name) ;
1328+ Ok ( (
1329+ default_name. clone ( ) ,
1330+ store. profiles . contains_key ( & default_name) ,
1331+ ) )
1332+ }
1333+
11751334fn confirm_profile_overwrite ( profile_name : & str ) -> Result < ( ) > {
11761335 let store = load_auth_store ( ) ?;
11771336 if !store. profiles . contains_key ( profile_name) {
@@ -3197,6 +3356,79 @@ mod tests {
31973356 assert_eq ! ( resolved. org_name. as_deref( ) , Some ( "acme-corp" ) ) ;
31983357 }
31993358
3359+ #[ test]
3360+ fn resolve_api_key_login_profile_name_creates_new_profile_for_matching_org ( ) {
3361+ let mut store = AuthStore :: default ( ) ;
3362+ store. profiles . insert (
3363+ "acme" . into ( ) ,
3364+ AuthProfile {
3365+ auth_kind : AuthKind :: ApiKey ,
3366+ api_url : Some ( "https://api.acme.example" . into ( ) ) ,
3367+ org_name : Some ( "acme" . into ( ) ) ,
3368+ ..Default :: default ( )
3369+ } ,
3370+ ) ;
3371+
3372+ let ( profile_name, should_confirm) = resolve_api_key_login_profile_name (
3373+ None ,
3374+ Some ( "acme" ) ,
3375+ "https://api.acme.example" ,
3376+ & store,
3377+ )
3378+ . expect ( "resolve" ) ;
3379+
3380+ assert_eq ! ( profile_name, "acme-2" ) ;
3381+ assert ! ( !should_confirm) ;
3382+ }
3383+
3384+ #[ test]
3385+ fn resolve_oauth_login_profile_name_reuses_most_recent_matching_profile ( ) {
3386+ let mut store = AuthStore :: default ( ) ;
3387+ store. profiles . insert (
3388+ "older" . into ( ) ,
3389+ AuthProfile {
3390+ auth_kind : AuthKind :: Oauth ,
3391+ api_url : Some ( "https://api.acme.example" . into ( ) ) ,
3392+ app_url : Some ( "https://www.acme.example" . into ( ) ) ,
3393+ org_name : Some ( "acme" . into ( ) ) ,
3394+ oauth_access_expires_at : Some ( 100 ) ,
3395+ user_name : Some ( "Alice" . into ( ) ) ,
3396+ email : Some ( "alice@example.com" . into ( ) ) ,
3397+ ..Default :: default ( )
3398+ } ,
3399+ ) ;
3400+ store. profiles . insert (
3401+ "newer" . into ( ) ,
3402+ AuthProfile {
3403+ auth_kind : AuthKind :: Oauth ,
3404+ api_url : Some ( "https://api.acme.example" . into ( ) ) ,
3405+ app_url : Some ( "https://www.acme.example" . into ( ) ) ,
3406+ org_name : Some ( "acme" . into ( ) ) ,
3407+ oauth_access_expires_at : Some ( 200 ) ,
3408+ user_name : Some ( "Alice" . into ( ) ) ,
3409+ email : Some ( "alice@example.com" . into ( ) ) ,
3410+ ..Default :: default ( )
3411+ } ,
3412+ ) ;
3413+
3414+ let jwt_id = JwtIdentity {
3415+ name : Some ( "Alice" . into ( ) ) ,
3416+ email : Some ( "alice@example.com" . into ( ) ) ,
3417+ } ;
3418+ let ( profile_name, should_confirm) = resolve_oauth_login_profile_name (
3419+ None ,
3420+ Some ( "acme" ) ,
3421+ "https://api.acme.example" ,
3422+ "https://www.acme.example" ,
3423+ & jwt_id,
3424+ & store,
3425+ )
3426+ . expect ( "resolve" ) ;
3427+
3428+ assert_eq ! ( profile_name, "newer" ) ;
3429+ assert ! ( !should_confirm) ;
3430+ }
3431+
32003432 #[ test]
32013433 fn obscure_api_key_standard ( ) {
32023434 assert_eq ! ( obscure_api_key( "sk-LumEdp0BbLRzhJwO" ) , "sk-****zhJwO" ) ;
0 commit comments