From 75599129c4c84f3b8a12eccfbe9a94804cae7d6d Mon Sep 17 00:00:00 2001 From: Daniel Gilchrist Date: Tue, 17 Feb 2026 18:22:30 +0000 Subject: [PATCH] Handle authentication explicitly --- spec/commands/auth/login_spec.cr | 160 ++++++++++++++++++ spec/commands/auth/logout_spec.cr | 49 ++++++ spec/commands/auth/status_spec.cr | 28 +++ spec/commands/refetch_token_spec.cr | 78 --------- .../configuration/unauthenticated.json | 9 + spec/support/configuration/fixture_file.cr | 1 + src/tanda_cli.cr | 35 +--- src/tanda_cli/api/auth.cr | 113 ------------- src/tanda_cli/api/client.cr | 12 +- src/tanda_cli/commands/auth.cr | 22 +++ src/tanda_cli/commands/auth/login.cr | 143 ++++++++++++++++ src/tanda_cli/commands/auth/logout.cr | 47 +++++ src/tanda_cli/commands/auth/status.cr | 30 ++++ src/tanda_cli/commands/balance.cr | 2 + src/tanda_cli/commands/base.cr | 19 ++- .../commands/clock_in/break/finish.cr | 1 + .../commands/clock_in/break/start.cr | 1 + src/tanda_cli/commands/clock_in/display.cr | 2 + src/tanda_cli/commands/clock_in/finish.cr | 1 + src/tanda_cli/commands/clock_in/start.cr | 1 + src/tanda_cli/commands/clock_in/status.cr | 2 + src/tanda_cli/commands/main.cr | 3 +- src/tanda_cli/commands/me.cr | 2 + src/tanda_cli/commands/personal_details.cr | 2 + src/tanda_cli/commands/refetch_token.cr | 17 -- src/tanda_cli/commands/refetch_users.cr | 14 -- .../commands/regular_hours/determine.cr | 2 + src/tanda_cli/commands/time_worked/today.cr | 2 + src/tanda_cli/commands/time_worked/week.cr | 2 + src/tanda_cli/configuration.cr | 25 ++- src/tanda_cli/context.cr | 20 ++- src/tanda_cli/request.cr | 62 ------- 32 files changed, 584 insertions(+), 323 deletions(-) create mode 100644 spec/commands/auth/login_spec.cr create mode 100644 spec/commands/auth/logout_spec.cr create mode 100644 spec/commands/auth/status_spec.cr delete mode 100644 spec/commands/refetch_token_spec.cr create mode 100644 spec/fixtures/configuration/unauthenticated.json delete mode 100644 src/tanda_cli/api/auth.cr create mode 100644 src/tanda_cli/commands/auth.cr create mode 100644 src/tanda_cli/commands/auth/login.cr create mode 100644 src/tanda_cli/commands/auth/logout.cr create mode 100644 src/tanda_cli/commands/auth/status.cr delete mode 100644 src/tanda_cli/commands/refetch_token.cr delete mode 100644 src/tanda_cli/commands/refetch_users.cr delete mode 100644 src/tanda_cli/request.cr diff --git a/spec/commands/auth/login_spec.cr b/spec/commands/auth/login_spec.cr new file mode 100644 index 00000000..c1d89df0 --- /dev/null +++ b/spec/commands/auth/login_spec.cr @@ -0,0 +1,160 @@ +require "../../spec_helper" + +describe TandaCLI::Commands::Auth::Login do + it "logs in and prompts for organisation selection when multiple organisations" do + scope = "me" + + 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 + ) + + WebMock + .stub(:get, endpoint("/users/me")) + .to_return( + status: 200, + body: { + name: "Test", + email: "test@example.com", + country: "United Kingdom", + time_zone: "Europe/London", + user_ids: [1, 2], + permissions: ["test"], + organisations: [ + { + id: 1, + name: "Test Organisation 1", + locale: "en-GB", + country: "United Kingdom", + user_id: 1, + }, + { + id: 2, + name: "Test Organisation 2", + locale: "en-GB", + country: "United Kingdom", + user_id: 2, + }, + ], + }.to_json + ) + + stdin = build_stdin( + "eu", + "test@example.com", + "dummypassword", + "2" + ) + + 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) + 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 + ) + + WebMock + .stub(:get, endpoint("/users/me")) + .to_return( + status: 200, + body: { + name: "Test", + email: "test@example.com", + country: "United Kingdom", + time_zone: "Europe/London", + user_ids: [1], + permissions: ["test"], + organisations: [ + { + id: 1, + name: "Test Organisation", + locale: "en-GB", + country: "United Kingdom", + user_id: 1, + }, + ], + }.to_json + ) + + 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? + + What's your password? + + Success: Retrieved token! + Success: Selected organisation "Test Organisation" + Success: Organisations saved to config + + OUTPUT + + context.stdout.to_s.should eq(expected) + end + + it "errors with invalid credentials" do + WebMock + .stub(:post, "https://eu.tanda.co/api/oauth/token") + .to_return( + status: 401, + body: { + error: "invalid_grant", + error_description: "The provided authorization grant is invalid", + }.to_json + ) + + stdin = build_stdin( + "eu", + "bad@example.com", + "wrongpassword", + ) + + context = run(["auth", "login"], stdin: stdin) + + context.stderr.to_s.should contain("Unable to authenticate") + end +end diff --git a/spec/commands/auth/logout_spec.cr b/spec/commands/auth/logout_spec.cr new file mode 100644 index 00000000..cb43a2db --- /dev/null +++ b/spec/commands/auth/logout_spec.cr @@ -0,0 +1,49 @@ +require "../../spec_helper" + +describe TandaCLI::Commands::Auth::Logout do + it "revokes token and clears authentication for production environment" do + WebMock + .stub(:post, "https://eu.tanda.co/api/oauth/revoke") + .with(body: {token: "testtoken"}.to_json, headers: {"Content-Type" => "application/json"}) + .to_return(status: 200, body: "{}") + + context = run(["auth", "logout"]) + + context.stdout.to_s.should contain("Revoking access token...") + context.stdout.to_s.should contain("Revoked access token") + context.stdout.to_s.should contain("Logged out of production environment") + context.config.access_token.token.should be_nil + context.config.access_token.email.should be_nil + context.config.organisations.should be_empty + end + + it "revokes token and clears authentication for staging environment" do + WebMock + .stub(:post, "https://staging.eu.tanda.co/api/oauth/revoke") + .with(body: {token: "testtoken"}.to_json, headers: {"Content-Type" => "application/json"}) + .to_return(status: 200, body: "{}") + + context = run(["auth", "logout"], config_fixture: :default_staging) + + context.stdout.to_s.should contain("Revoking access token...") + context.stdout.to_s.should contain("Revoked access token") + context.stdout.to_s.should contain("Logged out of staging environment") + context.config.access_token.token.should be_nil + context.config.access_token.email.should be_nil + context.config.organisations.should be_empty + end + + it "warns but still clears authentication when revocation fails" do + WebMock + .stub(:post, "https://eu.tanda.co/api/oauth/revoke") + .to_return(status: 503, body: "") + + context = run(["auth", "logout"]) + + context.stdout.to_s.should contain("Revoking access token...") + context.stdout.to_s.should_not contain("Revoked access token") + context.stdout.to_s.should contain("Failed to revoke token (status: 503)") + context.stdout.to_s.should contain("Logged out of production environment") + context.config.access_token.token.should be_nil + end +end diff --git a/spec/commands/auth/status_spec.cr b/spec/commands/auth/status_spec.cr new file mode 100644 index 00000000..e9f70fb0 --- /dev/null +++ b/spec/commands/auth/status_spec.cr @@ -0,0 +1,28 @@ +require "../../spec_helper" + +describe TandaCLI::Commands::Auth::Status do + it "displays authenticated status with details" do + context = run(["auth", "status"]) + + output = context.stdout.to_s + output.should contain("Authenticated (production)") + output.should contain("test@testmailfakenotrealthisisntarealdomainaaaa.com") + output.should contain("Test Organisation (user 1)") + output.should contain("eu") + end + + it "displays authenticated status in staging" do + context = run(["auth", "status"], config_fixture: :default_staging) + + output = context.stdout.to_s + output.should contain("Authenticated (staging)") + end + + it "displays not authenticated when no token" do + context = run(["auth", "status"], config_fixture: :unauthenticated) + + output = context.stdout.to_s + output.should contain("Not authenticated (production)") + output.should contain("Run `tanda_cli auth login` to authenticate") + end +end diff --git a/spec/commands/refetch_token_spec.cr b/spec/commands/refetch_token_spec.cr deleted file mode 100644 index e6f44da7..00000000 --- a/spec/commands/refetch_token_spec.cr +++ /dev/null @@ -1,78 +0,0 @@ -require "../spec_helper" - -describe TandaCLI::Commands::RefetchToken do - it "outputs correctly on success" do - scope = "me" - - 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 - ) - - WebMock - .stub(:get, endpoint("/users/me")) - .to_return( - status: 200, - body: { - name: "Test", - email: "test@example.com", - country: "United Kingdom", - time_zone: "Europe/London", - user_ids: [1, 2], - permissions: ["test"], - organisations: [ - { - id: 1, - name: "Test Organisation 1", - locale: "en-GB", - country: "United Kingdom", - user_id: 1, - }, - { - id: 2, - name: "Test Organisation 2", - locale: "en-GB", - country: "United Kingdom", - user_id: 2, - }, - ], - }.to_json - ) - - stdin = build_stdin( - "eu", - "test@example.com", - "dummypassword", - "2" - ) - - context = run(["refetch_token"], 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) - end -end diff --git a/spec/fixtures/configuration/unauthenticated.json b/spec/fixtures/configuration/unauthenticated.json new file mode 100644 index 00000000..64adc4c1 --- /dev/null +++ b/spec/fixtures/configuration/unauthenticated.json @@ -0,0 +1,9 @@ +{ + "mode": "production", + "production": { + "site_prefix": "eu", + "access_token": {}, + "organisations": [] + }, + "start_of_week": "saturday" +} diff --git a/spec/support/configuration/fixture_file.cr b/spec/support/configuration/fixture_file.cr index c8ae9274..a082257c 100644 --- a/spec/support/configuration/fixture_file.cr +++ b/spec/support/configuration/fixture_file.cr @@ -5,6 +5,7 @@ class Configuration::FixtureFile < TandaCLI::Configuration::AbstractFile Default DefaultStaging NoRegularHours + Unauthenticated def read : Bytes file_name = to_s.underscore diff --git a/src/tanda_cli.cr b/src/tanda_cli.cr index c247e6ba..672eef44 100644 --- a/src/tanda_cli.cr +++ b/src/tanda_cli.cr @@ -20,31 +20,21 @@ module TandaCLI display = Display.new(stdout, stderr) input = Input.new(stdin, display) config = Configuration.init(config_file, display) - current_user = user_from_config(config) || user_from_api(config, display, input) - client = build_client(config, display, input, current_user) - current = Current.new(current_user) + current_user = user_from_config(config) + client = build_client(config, current_user) + current = Current.new(current_user) if current_user - Context.new( - config, - client, - current, - display, - input - ) + Context.new(config, client, current, display, input) end - private def build_client(config : Configuration, display : Display, input : Input, current_user : Current::User? = nil) : API::Client + private def build_client(config : Configuration, current_user : Current::User? = nil) : API::Client? token = config.access_token.token - - # if a token can't be parsed from the config, get username and password from user and request a token - if token.nil? - API::Auth.fetch_new_token!(config, display, input) - return build_client(config, display, input) - end + return unless token url = config.api_url - display.error!(url) unless url.is_a?(String) - API::Client.new(url, token, display, current_user) + return unless url.is_a?(String) + + API::Client.new(url, token, current_user) end private def user_from_config(config : Configuration) : Current::User? @@ -53,13 +43,6 @@ module TandaCLI Current::User.new(organisation.user_id, organisation.name) end - - private def user_from_api(config : Configuration, display : Display, input : Input) : Current::User - client = build_client(config, display, input) - organisation = Request.ask_which_organisation_and_save!(client, config, display, input) - - Current::User.new(organisation.user_id, organisation.name) - end end {% unless flag?(:test) %} diff --git a/src/tanda_cli/api/auth.cr b/src/tanda_cli/api/auth.cr deleted file mode 100644 index 770359a1..00000000 --- a/src/tanda_cli/api/auth.cr +++ /dev/null @@ -1,113 +0,0 @@ -# shards -require "http" -require "json" - -# internal -require "../types/access_token" -require "../types/error" - -module TandaCLI - module API - module Auth - extend self - - VALID_SITE_PREFIXES = {"my", "eu", "us"} - SCOPES = "device leave personal roster timesheet me" - - def fetch_new_token!(config : Configuration, display : Display, input : Input) : Configuration - site_prefix, email, password = request_user_information!(display, input) - - auth_site_prefix = begin - if config.staging? - case site_prefix - when "my" - "staging" - when "eu" - "staging.eu" - when "us" - "staging.us" - end - end - end || site_prefix - - access_token = fetch_access_token!(display, auth_site_prefix, email, password).or do |error| - display.error!("Unable to authenticate (likely incorrect login details)") do |sub_errors| - sub_errors << "Error Type: #{error.error}\n" - - description = error.error_description - sub_errors << "Message: #{description}" if description - end - end - - display.success("Retrieved token!#{config.staging? ? " (staging)" : ""}\n") - config.overwrite!(site_prefix, email, access_token) - - config - end - - private def fetch_access_token!(display : Display, site_prefix : String, email : String, password : String) : API::Result(Types::AccessToken) - response = begin - HTTP::Client.post( - build_endpoint(site_prefix), - headers: build_headers, - 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)) - - API::Result(Types::AccessToken).from(response) - end - - private def build_endpoint(site_prefix : String) : String - "https://#{site_prefix}.tanda.co/api/oauth/token" - end - - private def build_headers : HTTP::Headers - HTTP::Headers{ - "Cache-Control" => "no-cache", - "Content-Type" => "application/json", - } - end - - private def request_user_information!(display : Display, input : Input) : Tuple(String, String, String) - valid_site_prefixes = VALID_SITE_PREFIXES.join(", ") - site_prefix = request_site_prefix(display, input, message: "Site prefix (#{valid_site_prefixes} - Default is \"my\"):") - - 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 - display.error!("Email cannot be blank") - end - display.puts - - password = input.request_or(message: "What's your password?", sensitive: true) do - display.error!("Password cannot be blank") - end - display.puts - - {site_prefix, email, password} - end - - private def request_site_prefix(display : Display, input : Input, message : String) : String - input.request_or(message) do - "my".tap do |default| - display.warning("Defaulting to \"#{default}\"") - end - end - end - end - end -end diff --git a/src/tanda_cli/api/client.cr b/src/tanda_cli/api/client.cr index babce782..686e0e36 100644 --- a/src/tanda_cli/api/client.cr +++ b/src/tanda_cli/api/client.cr @@ -14,10 +14,14 @@ module TandaCLI INTERNAL_SERVER_ERROR_STRING = "Internal Server Error" + class NetworkError < Exception; end + + class FatalAPIError < Exception; end + alias TQuery = Hash(String, String) alias TBody = Hash(String, String) - def initialize(@base_uri : String, @token : String, @display : Display, @current_user : Current::User? = nil); end + def initialize(@base_uri : String, @token : String, @current_user : Current::User? = nil); end def get(endpoint : String, query : TQuery? = nil) : HTTP::Client::Response exec(GET, endpoint, query: query) @@ -77,16 +81,16 @@ module TandaCLI private def with_no_internet_handler!(&) yield rescue Socket::Addrinfo::Error - @display.fatal!("There appears to be a problem with your internet connection") + raise NetworkError.new("There appears to be a problem with your internet connection") end private def handle_fatal_error!(response : HTTP::Client::Response) case response.status when .service_unavailable? - @display.fatal!("API is offline") + raise FatalAPIError.new("API is offline") when .internal_server_error? if response.body.includes?(INTERNAL_SERVER_ERROR_STRING) - @display.fatal!("An internal server error occured") + raise FatalAPIError.new("An internal server error occured") end end end diff --git a/src/tanda_cli/commands/auth.cr b/src/tanda_cli/commands/auth.cr new file mode 100644 index 00000000..60a89269 --- /dev/null +++ b/src/tanda_cli/commands/auth.cr @@ -0,0 +1,22 @@ +require "./base" + +module TandaCLI + module Commands + class Auth < Base + def setup_ + @name = "auth" + @summary = @description = "Manage authentication" + + add_commands( + Auth::Login, + Auth::Logout, + Auth::Status + ) + end + + def run_(arguments : Cling::Arguments, options : Cling::Options) : Nil + display.puts help_template + end + end + end +end diff --git a/src/tanda_cli/commands/auth/login.cr b/src/tanda_cli/commands/auth/login.cr new file mode 100644 index 00000000..c4e2bc0e --- /dev/null +++ b/src/tanda_cli/commands/auth/login.cr @@ -0,0 +1,143 @@ +module TandaCLI + module Commands + class Auth + class Login < Base + VALID_SITE_PREFIXES = {"my", "eu", "us"} + SCOPES = "device leave personal roster timesheet me" + + def setup_ + @name = "login" + @summary = @description = "Authenticate with Tanda" + end + + def run_(arguments : Cling::Arguments, options : Cling::Options) : Nil + config.reset_environment! + + site_prefix, email, password = prompt_for_credentials + config.site_prefix = site_prefix + + access_token = fetch_access_token(email, password) + + config.overwrite!(site_prefix, email, access_token) + display.success("Retrieved token!#{config.staging? ? " (staging)" : ""}\n") + + url = config.api_url + display.error!(url) unless url.is_a?(String) + + client = API::Client.new(url, access_token.token) + 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 + display.error!("Email cannot be blank") + end + display.puts + + password = input.request_or(message: "What's your password?", sensitive: true) do + display.error!("Password cannot be blank") + end + display.puts + + {site_prefix, 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) + + 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)) + + 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" + + description = error.error_description + sub_errors << "Message: #{description}" if description + end + end + end + + private def select_and_save_organisation(client : API::Client) + me = client.me.unwrap! + organisations = Configuration::Serialisable::Organisation.from(me) + + 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) + end + + organisation.current = true + config.organisations = organisations + config.save! + + display.success("Selected organisation \"#{organisation.name}\"") + display.success("Organisations saved to config") + end + + 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}" + end + + input.request_and(message: "\nEnter a number:") do |user_input| + number = user_input.try(&.to_i32?) + + if number + index = number - 1 + organisations[index]? || handle_invalid_selection(organisations.size, user_input) + else + handle_invalid_selection + end + end + end + + private def handle_invalid_selection(length : Int32? = nil, user_input : String? = nil) : Nil + display.puts "\n" + if user_input + display.error("Invalid selection", user_input) do |sub_errors| + sub_errors << "Please select a number between 1 and #{length}" if length + end + else + display.error("You must enter a number") + end + end + end + end + end +end diff --git a/src/tanda_cli/commands/auth/logout.cr b/src/tanda_cli/commands/auth/logout.cr new file mode 100644 index 00000000..e923bb2b --- /dev/null +++ b/src/tanda_cli/commands/auth/logout.cr @@ -0,0 +1,47 @@ +module TandaCLI + module Commands + class Auth + class Logout < Base + def setup_ + @name = "logout" + @summary = @description = "Clear authentication for the current environment" + end + + def run_(arguments : Cling::Arguments, options : Cling::Options) : Nil + revoke_access_token + + config.reset_environment! + config.save! + + environment = config.staging? ? "staging" : "production" + display.success("Logged out of #{environment} environment") + end + + private def revoke_access_token + token = config.access_token.token + return unless token + + url = config.oauth_url(:revoke) + return unless url.is_a?(String) + + display.info("Revoking access token...") + response = HTTP::Client.post( + url, + headers: HTTP::Headers{ + "Content-Type" => "application/json", + }, + body: {token: token}.to_json, + ) + + if response.success? + display.success("Revoked access token") + else + display.warning("Failed to revoke token (status: #{response.status_code})") + end + rescue Socket::Addrinfo::Error + display.warning("Failed to revoke token (network error)") + end + end + end + end +end diff --git a/src/tanda_cli/commands/auth/status.cr b/src/tanda_cli/commands/auth/status.cr new file mode 100644 index 00000000..6ddf652b --- /dev/null +++ b/src/tanda_cli/commands/auth/status.cr @@ -0,0 +1,30 @@ +module TandaCLI + module Commands + class Auth + class Status < Base + def setup_ + @name = "status" + @summary = @description = "Show current authentication status" + end + + def run_(arguments : Cling::Arguments, options : Cling::Options) : Nil + environment = config.staging? ? "staging" : "production" + + unless context.authenticated? + display.puts "🔒 #{"Not authenticated (#{environment})".colorize.yellow}" + display.puts "Run `tanda_cli auth login` to authenticate" + return + end + + email = config.access_token.email + organisation = config.current_organisation? + + 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}" + end + end + end + end +end diff --git a/src/tanda_cli/commands/balance.cr b/src/tanda_cli/commands/balance.cr index d9438751..b1164051 100644 --- a/src/tanda_cli/commands/balance.cr +++ b/src/tanda_cli/commands/balance.cr @@ -3,6 +3,8 @@ require "./base" module TandaCLI module Commands class Balance < Base + requires_auth! + DEFAULT_LEAVE_TYPE = "Holiday Leave" def setup_ diff --git a/src/tanda_cli/commands/base.cr b/src/tanda_cli/commands/base.cr index a6b1756d..bf6e745d 100644 --- a/src/tanda_cli/commands/base.cr +++ b/src/tanda_cli/commands/base.cr @@ -12,7 +12,17 @@ module TandaCLI end getter context : Context - delegate client, config, current, display, input, to: context + delegate client, current, config, display, input, to: context + + macro requires_auth! + def requires_auth? : Bool + true + end + end + + def requires_auth? : Bool + false + end abstract def setup_ abstract def run_(arguments : Cling::Arguments, options : Cling::Options) @@ -43,6 +53,7 @@ module TandaCLI return if help?(arguments, options) + check_auth! if requires_auth? maybe_display_staging_warning before_run(arguments, options) end @@ -103,6 +114,12 @@ module TandaCLI TandaCLI.exit! end + private def check_auth! + return if context.authenticated? + + display.error!("Not authenticated. Run `tanda_cli auth login` to authenticate.") + end + private def maybe_display_staging_warning return if @disable_staging_warning return unless config.staging? diff --git a/src/tanda_cli/commands/clock_in/break/finish.cr b/src/tanda_cli/commands/clock_in/break/finish.cr index e3e97f84..39bdeff8 100644 --- a/src/tanda_cli/commands/clock_in/break/finish.cr +++ b/src/tanda_cli/commands/clock_in/break/finish.cr @@ -6,6 +6,7 @@ module TandaCLI class Break class Finish < Commands::Base include Helpers::ClockIn + requires_auth! def setup_ @name = "finish" diff --git a/src/tanda_cli/commands/clock_in/break/start.cr b/src/tanda_cli/commands/clock_in/break/start.cr index 7ff6326c..60df2766 100644 --- a/src/tanda_cli/commands/clock_in/break/start.cr +++ b/src/tanda_cli/commands/clock_in/break/start.cr @@ -6,6 +6,7 @@ module TandaCLI class Break class Start < Commands::Base include Helpers::ClockIn + requires_auth! def setup_ @name = "start" diff --git a/src/tanda_cli/commands/clock_in/display.cr b/src/tanda_cli/commands/clock_in/display.cr index 92ec66e9..dd50dc6c 100644 --- a/src/tanda_cli/commands/clock_in/display.cr +++ b/src/tanda_cli/commands/clock_in/display.cr @@ -2,6 +2,8 @@ module TandaCLI module Commands class ClockIn class Display < Commands::Base + requires_auth! + def setup_ @name = "display" @summary = @description = "Display current clockins" diff --git a/src/tanda_cli/commands/clock_in/finish.cr b/src/tanda_cli/commands/clock_in/finish.cr index 1d289180..754a8c78 100644 --- a/src/tanda_cli/commands/clock_in/finish.cr +++ b/src/tanda_cli/commands/clock_in/finish.cr @@ -5,6 +5,7 @@ module TandaCLI class ClockIn class Finish < Commands::Base include Helpers::ClockIn + requires_auth! def setup_ @name = "finish" diff --git a/src/tanda_cli/commands/clock_in/start.cr b/src/tanda_cli/commands/clock_in/start.cr index 482cc136..264d0601 100644 --- a/src/tanda_cli/commands/clock_in/start.cr +++ b/src/tanda_cli/commands/clock_in/start.cr @@ -5,6 +5,7 @@ module TandaCLI class ClockIn class Start < Commands::Base include Helpers::ClockIn + requires_auth! def setup_ @name = "start" diff --git a/src/tanda_cli/commands/clock_in/status.cr b/src/tanda_cli/commands/clock_in/status.cr index 533deb41..bcc361d6 100644 --- a/src/tanda_cli/commands/clock_in/status.cr +++ b/src/tanda_cli/commands/clock_in/status.cr @@ -2,6 +2,8 @@ module TandaCLI module Commands class ClockIn class Status < Commands::Base + requires_auth! + def setup_ @name = "status" @summary = @description = "Check current clockin status" diff --git a/src/tanda_cli/commands/main.cr b/src/tanda_cli/commands/main.cr index 1782e876..c62012af 100644 --- a/src/tanda_cli/commands/main.cr +++ b/src/tanda_cli/commands/main.cr @@ -6,6 +6,7 @@ module TandaCLI @description = "A CLI application for people using Tanda/Workforce.com" add_commands( + Auth, Me, PersonalDetails, ClockIn, @@ -13,8 +14,6 @@ module TandaCLI Balance, RegularHours, CurrentUser, - RefetchToken, - RefetchUsers, Mode, StartOfWeek ) diff --git a/src/tanda_cli/commands/me.cr b/src/tanda_cli/commands/me.cr index de73ad5b..b967a4af 100644 --- a/src/tanda_cli/commands/me.cr +++ b/src/tanda_cli/commands/me.cr @@ -1,6 +1,8 @@ module TandaCLI module Commands class Me < Base + requires_auth! + def setup_ @name = "me" @summary = @description = "Get your own information" diff --git a/src/tanda_cli/commands/personal_details.cr b/src/tanda_cli/commands/personal_details.cr index f540ac37..6f1967e8 100644 --- a/src/tanda_cli/commands/personal_details.cr +++ b/src/tanda_cli/commands/personal_details.cr @@ -1,6 +1,8 @@ module TandaCLI module Commands class PersonalDetails < Base + requires_auth! + def setup_ @name = "personal_details" @summary = @description = "Get your personal details" diff --git a/src/tanda_cli/commands/refetch_token.cr b/src/tanda_cli/commands/refetch_token.cr deleted file mode 100644 index e4adf478..00000000 --- a/src/tanda_cli/commands/refetch_token.cr +++ /dev/null @@ -1,17 +0,0 @@ -module TandaCLI - module Commands - class RefetchToken < Base - def setup_ - @name = "refetch_token" - @summary = @description = "Refetch token for the current environment" - end - - def run_(arguments : Cling::Arguments, options : Cling::Options) : Nil - config.reset_environment! - API::Auth.fetch_new_token!(config, display, input) - - Request.ask_which_organisation_and_save!(client, config, display, input) - end - end - end -end diff --git a/src/tanda_cli/commands/refetch_users.cr b/src/tanda_cli/commands/refetch_users.cr deleted file mode 100644 index e49f86d7..00000000 --- a/src/tanda_cli/commands/refetch_users.cr +++ /dev/null @@ -1,14 +0,0 @@ -module TandaCLI - module Commands - class RefetchUsers < Base - def setup_ - @name = "refetch_users" - @summary = @description = "Refetch users from the API and save to config" - end - - def run_(arguments : Cling::Arguments, options : Cling::Options) : Nil - Request.ask_which_organisation_and_save!(client, config, display, input) - end - end - end -end diff --git a/src/tanda_cli/commands/regular_hours/determine.cr b/src/tanda_cli/commands/regular_hours/determine.cr index 77c97ece..f33c21ef 100644 --- a/src/tanda_cli/commands/regular_hours/determine.cr +++ b/src/tanda_cli/commands/regular_hours/determine.cr @@ -4,6 +4,8 @@ module TandaCLI module Commands class RegularHours class Determine < Base + requires_auth! + def setup_ @name = "determine" @summary = @description = "Determine the regular hours for the current user" diff --git a/src/tanda_cli/commands/time_worked/today.cr b/src/tanda_cli/commands/time_worked/today.cr index 23e2c188..eb08cfa7 100644 --- a/src/tanda_cli/commands/time_worked/today.cr +++ b/src/tanda_cli/commands/time_worked/today.cr @@ -2,6 +2,8 @@ module TandaCLI module Commands class TimeWorked class Today < Commands::Base + requires_auth! + def setup_ @name = "today" @summary = @description = "Show time worked for today" diff --git a/src/tanda_cli/commands/time_worked/week.cr b/src/tanda_cli/commands/time_worked/week.cr index 3c4dbbc6..ea6d1369 100644 --- a/src/tanda_cli/commands/time_worked/week.cr +++ b/src/tanda_cli/commands/time_worked/week.cr @@ -2,6 +2,8 @@ module TandaCLI module Commands class TimeWorked class Week < Commands::Base + requires_auth! + def setup_ @name = "week" @summary = @description = "Show time worked for a week" diff --git a/src/tanda_cli/configuration.cr b/src/tanda_cli/configuration.cr index eba966cf..cb6a0f59 100644 --- a/src/tanda_cli/configuration.cr +++ b/src/tanda_cli/configuration.cr @@ -11,6 +11,11 @@ module TandaCLI PRODUCTION = "production" STAGING = "staging" + enum OAuthEndpoint + Token + Revoke + end + def self.init(file : Configuration::AbstractFile, display : Display) : Configuration config_contents = file.read.presence return new(file) unless config_contents @@ -66,17 +71,31 @@ module TandaCLI end def api_url : String | Error::InvalidURL + base = base_url + return base if base.is_a?(Error::InvalidURL) + + "#{base}/api/v2" + end + + def oauth_url(endpoint : OAuthEndpoint) : String | Error::InvalidURL + base = base_url + return base if base.is_a?(Error::InvalidURL) + + "#{base}/api/oauth/#{endpoint.to_s.downcase}" + end + + private def base_url : String | Error::InvalidURL case mode when PRODUCTION - "https://#{site_prefix}.tanda.co/api/v2" + "https://#{site_prefix}.tanda.co" when STAGING prefix = "#{site_prefix}." if site_prefix != "my" - "https://staging.#{prefix}tanda.co/api/v2" + "https://staging.#{prefix}tanda.co" else validated_url = Utils::URL.validate(mode) return validated_url if validated_url.is_a?(Error::InvalidURL) - "#{validated_url}/api/v2" + validated_url.to_s end end end diff --git a/src/tanda_cli/context.cr b/src/tanda_cli/context.cr index eb781d41..af1626dc 100644 --- a/src/tanda_cli/context.cr +++ b/src/tanda_cli/context.cr @@ -1,13 +1,27 @@ module TandaCLI class Context - def initialize(@config : Configuration, @client : API::Client, @current : Current, @display : Display, @input : Input); end + def initialize(@config : Configuration, @client : API::Client?, @current : Current?, @display : Display, @input : Input); end getter config : Configuration - getter client : API::Client - getter current : Current getter display : Display getter input : Input + def client : API::Client + @client || not_authenticated! + end + + def current : Current + @current || not_authenticated! + end + + def authenticated? : Bool + !@client.nil? && !@current.nil? + end + + private def not_authenticated! : NoReturn + @display.error!("Not authenticated. Run `tanda_cli auth login` to authenticate.") + end + {% if flag?(:test) %} def stdout : IO @display.@stdout diff --git a/src/tanda_cli/request.cr b/src/tanda_cli/request.cr deleted file mode 100644 index c08d5783..00000000 --- a/src/tanda_cli/request.cr +++ /dev/null @@ -1,62 +0,0 @@ -module TandaCLI - module Request - extend self - - def ask_which_organisation_and_save!(client : API::Client, config : Configuration, display : Display, input : Input) : Configuration::Serialisable::Organisation - me = client.me.unwrap! - organisations = Configuration::Serialisable::Organisation.from(me) - - if organisations.empty? - display.error!("You don't have access to any organisations") - end - - organisation = organisations.first if organisations.one? - while organisation.nil? - organisation = ask_for_organisation(organisations, display, input) - end - - display.success("Selected organisation \"#{organisation.name}\"") - - organisation.tap do - organisation.current = true - config.organisations = organisations - config.save! - - display.success("Organisations saved to config") - end - end - - private def ask_for_organisation( - organisations : Array(Configuration::Serialisable::Organisation), - display : Display, - input : Input, - ) : 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}" - end - - input.request_and(message: "\nEnter a number:") do |user_input| - number = user_input.try(&.to_i32?) - - if number - index = number - 1 - organisations[index]? || handle_invalid_selection(display, organisations.size, user_input) - else - handle_invalid_selection(display) - end - end - end - - private def handle_invalid_selection(display : Display, length : Int32? = nil, user_input : String? = nil) : Nil - display.puts "\n" - if user_input - display.error("Invalid selection", user_input) do |sub_errors| - sub_errors << "Please select a number between 1 and #{length}" if length - end - else - display.error("You must enter a number") - end - end - end -end