From 7a45c65a0d29ae0de2ec6bc35832a288fa65e84d Mon Sep 17 00:00:00 2001 From: Daniel Gilchrist Date: Tue, 17 Feb 2026 21:43:51 +0000 Subject: [PATCH] Implement fallback authentication rather than requiring a "site prefix" --- spec/commands/auth/login_spec.cr | 152 ++++++++++-------- spec/commands/auth/status_spec.cr | 2 +- spec/fixtures/configuration/default.json | 2 +- .../configuration/default_staging.json | 2 +- .../configuration/no_regular_hours.json | 2 +- .../configuration/unauthenticated.json | 2 +- src/tanda_cli/commands/auth/login.cr | 116 ++++++------- src/tanda_cli/commands/auth/status.cr | 2 +- src/tanda_cli/configuration.cr | 19 +-- src/tanda_cli/configuration/serialisable.cr | 2 +- .../configuration/serialisable/environment.cr | 6 +- src/tanda_cli/display.cr | 5 - src/tanda_cli/region.cr | 39 +++++ 13 files changed, 206 insertions(+), 145 deletions(-) create mode 100644 src/tanda_cli/region.cr diff --git a/spec/commands/auth/login_spec.cr b/spec/commands/auth/login_spec.cr index c1d89df0..ac79ea2a 100644 --- a/spec/commands/auth/login_spec.cr +++ b/spec/commands/auth/login_spec.cr @@ -1,20 +1,44 @@ require "../../spec_helper" -describe TandaCLI::Commands::Auth::Login do - it "logs in and prompts for organisation selection when multiple organisations" do - scope = "me" +AUTH_TOKEN_BODY = { + access_token: "faketoken", + token_type: "test", + scope: "me", + created_at: TandaCLI::Utils::Time.now.to_unix, +}.to_json + +AUTH_FAILED_BODY = { + error: "invalid_grant", + error_description: "The provided authorization grant is invalid", +}.to_json + +private def stub_failed_auth(host : String) + WebMock + .stub(:post, "https://#{host}/api/oauth/token") + .to_return(status: 401, body: AUTH_FAILED_BODY) +end - WebMock - .stub(:post, "https://eu.tanda.co/api/oauth/token") - .to_return( - status: 200, - body: { - access_token: "faketoken", - token_type: "test", - scope: scope, - created_at: TandaCLI::Utils::Time.now.to_unix, - }.to_json - ) +private def stub_successful_auth(host : String) + WebMock + .stub(:post, "https://#{host}/api/oauth/token") + .to_return(status: 200, body: AUTH_TOKEN_BODY) +end + +private def stub_all_regions_failed + stub_failed_auth("my.workforce.com") + stub_failed_auth("my.tanda.co") + stub_failed_auth("eu.tanda.co") +end + +private def stub_eu_auth_success + stub_failed_auth("my.workforce.com") + stub_failed_auth("my.tanda.co") + stub_successful_auth("eu.tanda.co") +end + +describe TandaCLI::Commands::Auth::Login do + it "auto-detects region and prompts for organisation selection when multiple organisations" do + stub_eu_auth_success WebMock .stub(:get, endpoint("/users/me")) @@ -47,7 +71,6 @@ describe TandaCLI::Commands::Auth::Login do ) stdin = build_stdin( - "eu", "test@example.com", "dummypassword", "2" @@ -55,39 +78,21 @@ describe TandaCLI::Commands::Auth::Login do context = run(["auth", "login"], stdin: stdin) - expected = <<-OUTPUT - Site prefix (my, eu, us - Default is "my"): - - What's your email? - - What's your password? - - Success: Retrieved token! - Which organisation would you like to use? - 1: Test Organisation 1 - 2: Test Organisation 2 - - Enter a number: - Success: Selected organisation "Test Organisation 2" - Success: Organisations saved to config - - OUTPUT - - context.stdout.to_s.should eq(expected) + output = context.stdout.to_s + output.should contain("Tanda CLI Login") + output.should contain("Email:") + output.should contain("Password:") + output.should contain("Authenticating...") + output.should contain("Authenticated!") + output.should contain("Select an organisation:") + output.should contain("Test Organisation 1") + output.should contain("Test Organisation 2") + output.should contain("Selected organisation \"Test Organisation 2\"") + output.should contain("Organisations saved to config") end it "auto-selects organisation when only one is available" do - WebMock - .stub(:post, "https://eu.tanda.co/api/oauth/token") - .to_return( - status: 200, - body: { - access_token: "faketoken", - token_type: "test", - scope: "me", - created_at: TandaCLI::Utils::Time.now.to_unix, - }.to_json - ) + stub_eu_auth_success WebMock .stub(:get, endpoint("/users/me")) @@ -113,48 +118,65 @@ describe TandaCLI::Commands::Auth::Login do ) stdin = build_stdin( - "eu", "test@example.com", "dummypassword", ) context = run(["auth", "login"], stdin: stdin) - expected = <<-OUTPUT - Site prefix (my, eu, us - Default is "my"): - - What's your email? + output = context.stdout.to_s + output.should contain("Authenticated!") + output.should contain("Selected organisation \"Test Organisation\"") + output.should contain("Organisations saved to config") + end - What's your password? + it "errors when all regions fail authentication" do + stub_all_regions_failed - Success: Retrieved token! - Success: Selected organisation "Test Organisation" - Success: Organisations saved to config + stdin = build_stdin( + "bad@example.com", + "wrongpassword", + ) - OUTPUT + context = run(["auth", "login"], stdin: stdin) - context.stdout.to_s.should eq(expected) + context.stderr.to_s.should contain("Unable to authenticate") end - it "errors with invalid credentials" do + it "stops at the first successful region" do + stub_successful_auth("my.workforce.com") + WebMock - .stub(:post, "https://eu.tanda.co/api/oauth/token") + .stub(:get, "https://my.workforce.com/api/v2/users/me") .to_return( - status: 401, + status: 200, body: { - error: "invalid_grant", - error_description: "The provided authorization grant is invalid", + name: "Test", + email: "test@example.com", + country: "Australia", + time_zone: "Australia/Sydney", + user_ids: [1], + permissions: ["test"], + organisations: [ + { + id: 1, + name: "Test Organisation", + locale: "en-AU", + country: "Australia", + user_id: 1, + }, + ], }.to_json ) stdin = build_stdin( - "eu", - "bad@example.com", - "wrongpassword", + "test@example.com", + "dummypassword", ) context = run(["auth", "login"], stdin: stdin) - context.stderr.to_s.should contain("Unable to authenticate") + output = context.stdout.to_s + output.should contain("Authenticated!") end end diff --git a/spec/commands/auth/status_spec.cr b/spec/commands/auth/status_spec.cr index e9f70fb0..d185d097 100644 --- a/spec/commands/auth/status_spec.cr +++ b/spec/commands/auth/status_spec.cr @@ -8,7 +8,7 @@ describe TandaCLI::Commands::Auth::Status do output.should contain("Authenticated (production)") output.should contain("test@testmailfakenotrealthisisntarealdomainaaaa.com") output.should contain("Test Organisation (user 1)") - output.should contain("eu") + output.should contain("EU (eu.tanda.co)") end it "displays authenticated status in staging" do diff --git a/spec/fixtures/configuration/default.json b/spec/fixtures/configuration/default.json index 1d0d39ab..3803f25c 100644 --- a/spec/fixtures/configuration/default.json +++ b/spec/fixtures/configuration/default.json @@ -2,7 +2,7 @@ "mode": "production", "treat_paid_breaks_as_unpaid": true, "production": { - "site_prefix": "eu", + "region": "eu", "access_token": { "email": "test@testmailfakenotrealthisisntarealdomainaaaa.com", "token": "testtoken", diff --git a/spec/fixtures/configuration/default_staging.json b/spec/fixtures/configuration/default_staging.json index 7df61e06..1b950aeb 100644 --- a/spec/fixtures/configuration/default_staging.json +++ b/spec/fixtures/configuration/default_staging.json @@ -2,7 +2,7 @@ "mode": "staging", "treat_paid_breaks_as_unpaid": true, "staging": { - "site_prefix": "eu", + "region": "eu", "access_token": { "email": "test@testmailfakenotrealthisisntarealdomainaaaa.com", "token": "testtoken", diff --git a/spec/fixtures/configuration/no_regular_hours.json b/spec/fixtures/configuration/no_regular_hours.json index 9ecf955a..37d191b5 100644 --- a/spec/fixtures/configuration/no_regular_hours.json +++ b/spec/fixtures/configuration/no_regular_hours.json @@ -2,7 +2,7 @@ "mode": "production", "treat_paid_breaks_as_unpaid": true, "production": { - "site_prefix": "eu", + "region": "eu", "access_token": { "email": "test@testmailfakenotrealthisisntarealdomainaaaa.com", "token": "testtoken", diff --git a/spec/fixtures/configuration/unauthenticated.json b/spec/fixtures/configuration/unauthenticated.json index 64adc4c1..d4640be6 100644 --- a/spec/fixtures/configuration/unauthenticated.json +++ b/spec/fixtures/configuration/unauthenticated.json @@ -1,7 +1,7 @@ { "mode": "production", "production": { - "site_prefix": "eu", + "region": "eu", "access_token": {}, "organisations": [] }, diff --git a/src/tanda_cli/commands/auth/login.cr b/src/tanda_cli/commands/auth/login.cr index c4e2bc0e..b276babf 100644 --- a/src/tanda_cli/commands/auth/login.cr +++ b/src/tanda_cli/commands/auth/login.cr @@ -2,8 +2,7 @@ module TandaCLI module Commands class Auth class Login < Base - VALID_SITE_PREFIXES = {"my", "eu", "us"} - SCOPES = "device leave personal roster timesheet me" + SCOPES = "device leave personal roster timesheet me" def setup_ @name = "login" @@ -13,13 +12,13 @@ module TandaCLI def run_(arguments : Cling::Arguments, options : Cling::Options) : Nil config.reset_environment! - site_prefix, email, password = prompt_for_credentials - config.site_prefix = site_prefix + display.puts "🔐 #{"Tanda CLI Login".colorize.white.bold}" + display.puts - access_token = fetch_access_token(email, password) + email, password = prompt_for_credentials + region, access_token = detect_region_and_authenticate(email, password) - config.overwrite!(site_prefix, email, access_token) - display.success("Retrieved token!#{config.staging? ? " (staging)" : ""}\n") + config.overwrite!(region, email, access_token) url = config.api_url display.error!(url) unless url.is_a?(String) @@ -28,64 +27,67 @@ module TandaCLI select_and_save_organisation(client) end - private def prompt_for_credentials : Tuple(String, String, String) - valid_site_prefixes = VALID_SITE_PREFIXES.join(", ") - site_prefix = input.request_or(message: "Site prefix (#{valid_site_prefixes} - Default is \"my\"):") do - "my".tap { |default| display.warning("Defaulting to \"#{default}\"") } - end - - unless VALID_SITE_PREFIXES.includes?(site_prefix) - display.error!("Invalid site prefix") do |sub_errors| - sub_errors << "Site prefix must be one of #{valid_site_prefixes}" - end - end - display.puts - - email = input.request_or(message: "What's your email?") do + private def prompt_for_credentials : Tuple(String, String) + email = input.request_or(message: "📧 #{"Email:".colorize.cyan}") do display.error!("Email cannot be blank") end display.puts - password = input.request_or(message: "What's your password?", sensitive: true) do + password = input.request_or(message: "🔑 #{"Password:".colorize.cyan}", sensitive: true) do display.error!("Password cannot be blank") end display.puts - {site_prefix, email, password} + {email, password} end - private def fetch_access_token(email : String, password : String) : Types::AccessToken - url = config.oauth_url(:token) - display.error!(url) unless url.is_a?(String) + private def detect_region_and_authenticate(email : String, password : String) : Tuple(Region, Types::AccessToken) + staging = config.staging? - response = begin - HTTP::Client.post( - url, - headers: HTTP::Headers{ - "Cache-Control" => "no-cache", - "Content-Type" => "application/json", - }, - body: { - username: email, - password: password, - scope: SCOPES, - grant_type: "password", - }.to_json - ) - rescue Socket::Addrinfo::Error - display.fatal!("There appears to be a problem with your internet connection") - end - - Log.debug(&.emit("Response", body: response.body)) + display.puts "🔍 #{"Authenticating...".colorize.cyan}" - API::Result(Types::AccessToken).from(response).or do |error| - display.error!("Unable to authenticate (likely incorrect login details)") do |sub_errors| - sub_errors << "Error Type: #{error.error}\n" + Region.values.each do |region| + Log.debug(&.emit("Trying #{region.display_name} (#{region.host(staging)})")) - description = error.error_description - sub_errors << "Message: #{description}" if description + access_token = try_authenticate(region, email, password, staging) + if access_token + Log.debug(&.emit("Authenticated via #{region.display_name}")) + display.success("Authenticated!") + return {region, access_token} end end + + display.error!("Unable to authenticate (incorrect email or password)") + end + + private def try_authenticate(region : Region, email : String, password : String, staging : Bool) : Types::AccessToken? + url = region.oauth_url(:token, staging) + + response = HTTP::Client.post( + url, + headers: HTTP::Headers{ + "Cache-Control" => "no-cache", + "Content-Type" => "application/json", + }, + body: { + username: email, + password: password, + scope: SCOPES, + grant_type: "password", + }.to_json + ) + + Log.debug(&.emit("Response from #{region.display_name}", body: response.body)) + + return nil unless response.success? + + Types::AccessToken.from_json(response.body) + rescue Socket::Addrinfo::Error + Log.debug(&.emit("Network error for #{region.display_name}")) + nil + rescue JSON::SerializableError | JSON::ParseException + Log.debug(&.emit("Failed to parse response from #{region.display_name}")) + nil end private def select_and_save_organisation(client : API::Client) @@ -95,14 +97,19 @@ module TandaCLI display.error!("You don't have access to any organisations") if organisations.empty? organisation = organisations.first if organisations.one? - while organisation.nil? - organisation = prompt_for_organisation(organisations) + unless organisation + display.puts + display.puts "🏢 #{"Select an organisation:".colorize.white.bold}" + while organisation.nil? + organisation = prompt_for_organisation(organisations) + end end organisation.current = true config.organisations = organisations config.save! + display.puts display.success("Selected organisation \"#{organisation.name}\"") display.success("Organisations saved to config") end @@ -110,12 +117,11 @@ module TandaCLI private def prompt_for_organisation( organisations : Array(Configuration::Serialisable::Organisation), ) : Configuration::Serialisable::Organisation? - display.puts "Which organisation would you like to use?" organisations.each_with_index(1) do |org, index| - display.puts "#{index}: #{org.name}" + display.puts " #{index.to_s.colorize.cyan}: #{org.name}" end - input.request_and(message: "\nEnter a number:") do |user_input| + input.request_and(message: "\n#{"Enter a number:".colorize.cyan}") do |user_input| number = user_input.try(&.to_i32?) if number diff --git a/src/tanda_cli/commands/auth/status.cr b/src/tanda_cli/commands/auth/status.cr index 6ddf652b..5c073b7e 100644 --- a/src/tanda_cli/commands/auth/status.cr +++ b/src/tanda_cli/commands/auth/status.cr @@ -22,7 +22,7 @@ module TandaCLI display.puts "🔓 #{"Authenticated (#{environment})".colorize.green}" display.puts "📧 #{email}" if email display.puts "🏢 #{organisation.name} (user #{organisation.user_id})" if organisation - display.puts "🌐 #{config.site_prefix}" + display.puts "🌐 #{config.region.display_name} (#{config.host})" end end end diff --git a/src/tanda_cli/configuration.cr b/src/tanda_cli/configuration.cr index cb6a0f59..1b651805 100644 --- a/src/tanda_cli/configuration.cr +++ b/src/tanda_cli/configuration.cr @@ -51,16 +51,16 @@ module TandaCLI :treat_paid_breaks_as_unpaid?, :organisations, :organisations=, - :site_prefix, - :site_prefix=, + :region, + :region=, :access_token, :current_environment, :reset_environment!, :staging?, to: @serialisable - def overwrite!(site_prefix : String, email : String, access_token : Types::AccessToken) - self.site_prefix = site_prefix + def overwrite!(region : Region, email : String, access_token : Types::AccessToken) + self.region = region self.access_token.overwrite!(email, access_token) save! @@ -84,13 +84,14 @@ module TandaCLI "#{base}/api/oauth/#{endpoint.to_s.downcase}" end + def host : String + region.host(staging: staging?) + end + private def base_url : String | Error::InvalidURL case mode - when PRODUCTION - "https://#{site_prefix}.tanda.co" - when STAGING - prefix = "#{site_prefix}." if site_prefix != "my" - "https://staging.#{prefix}tanda.co" + when PRODUCTION, STAGING + "https://#{host}" else validated_url = Utils::URL.validate(mode) return validated_url if validated_url.is_a?(Error::InvalidURL) diff --git a/src/tanda_cli/configuration/serialisable.cr b/src/tanda_cli/configuration/serialisable.cr index 93ec1c52..6f4b8e5f 100644 --- a/src/tanda_cli/configuration/serialisable.cr +++ b/src/tanda_cli/configuration/serialisable.cr @@ -23,7 +23,7 @@ module TandaCLI @[JSON::Field(emit_null: true)] property? treat_paid_breaks_as_unpaid : Bool? - delegate :organisations, :organisations=, :site_prefix, :site_prefix=, :access_token, to: current_environment + delegate :organisations, :organisations=, :region, :region=, :access_token, to: current_environment def start_of_week=(value : String) : Time::DayOfWeek | Error::InvalidStartOfWeek start_of_week = Time::DayOfWeek.parse?(value) diff --git a/src/tanda_cli/configuration/serialisable/environment.cr b/src/tanda_cli/configuration/serialisable/environment.cr index a8ac37cb..f13e4e74 100644 --- a/src/tanda_cli/configuration/serialisable/environment.cr +++ b/src/tanda_cli/configuration/serialisable/environment.cr @@ -4,15 +4,13 @@ module TandaCLI class Environment include JSON::Serializable - DEFAULT_SITE_PREFIX = "eu" - def initialize( - @site_prefix : String = DEFAULT_SITE_PREFIX, + @region : Region = Region::APAC, @access_token : AccessToken = AccessToken.new, @organisations : Array(Organisation) = Array(Organisation).new, ); end - property site_prefix : String + property region : Region = Region::APAC property access_token : AccessToken property organisations : Array(Organisation) diff --git a/src/tanda_cli/display.cr b/src/tanda_cli/display.cr index 7ea340e8..29c702dc 100644 --- a/src/tanda_cli/display.cr +++ b/src/tanda_cli/display.cr @@ -62,11 +62,6 @@ module TandaCLI TandaCLI.exit! end - def error!(message : String, value : String? = nil, &block : String::Builder ->) : NoReturn - error(message, value, &block) - TandaCLI.exit! - end - def error!(error_object : Error::Base) : NoReturn {% if flag?(:debug) && !flag?(:test) %} raise error_object diff --git a/src/tanda_cli/region.cr b/src/tanda_cli/region.cr new file mode 100644 index 00000000..3cfe55ba --- /dev/null +++ b/src/tanda_cli/region.cr @@ -0,0 +1,39 @@ +module TandaCLI + enum Region + Global + APAC + EU + + def production_host : String + case self + in .global? then "my.workforce.com" + in .apac? then "my.tanda.co" + in .eu? then "eu.tanda.co" + end + end + + def staging_host : String + case self + in .global? then "staging.workforce.com" + in .apac? then "staging.tanda.co" + in .eu? then "staging.eu.tanda.co" + end + end + + def display_name : String + case self + in .global? then "Global" + in .apac? then "APAC" + in .eu? then "EU" + end + end + + def host(staging : Bool = false) : String + staging ? staging_host : production_host + end + + def oauth_url(endpoint : Configuration::OAuthEndpoint, staging : Bool = false) : String + "https://#{host(staging)}/api/oauth/#{endpoint.to_s.downcase}" + end + end +end