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
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ Configure the connection to your Timetracker instance.
| `--show` | | no | Display current config (token masked) |
| `--reset` | | no | Delete all local config and activity cache |

*Required when setting up or updating credentials.
*Required only on first-time setup.

Configuration updates are **non-destructive**: once configured, each option can be changed on its own and everything else is preserved. Only `--url`/`--token` trigger a re-authentication (and refresh of the activity cache).

```bash
# Initial setup
Expand All @@ -81,7 +83,10 @@ timetracker config -u https://acme.timehub.7pace.com -t eyJ...
# View current config
timetracker config --show

# Change the list table border (keeps credentials)
# Rotate just the token (URL and other settings are kept)
timetracker config -t eyJnew...

# Change the list table border (no network call, credentials untouched)
timetracker config --border square

# Remove all local config
Expand Down
30 changes: 17 additions & 13 deletions Timetracker.Console/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,26 +119,30 @@ async Task<int> ConfigAction(ConfigOptions opts, CancellationToken cancellationT
return 1;
}

// Border-only update: preserve credentials, just change the border.
if (!string.IsNullOrEmpty(opts.Border)
&& string.IsNullOrEmpty(opts.TimetrackerUrl)
&& string.IsNullOrEmpty(opts.TimetrackerBearerToken))
// Only re-authenticate when credentials are actually being set or changed.
var settingCredentials = !string.IsNullOrEmpty(opts.TimetrackerUrl)
|| !string.IsNullOrEmpty(opts.TimetrackerBearerToken);

if (!settingCredentials)
{
if (!ConfigService.ConfigExists())
{
ConsoleHelper.WriteError(ConsoleHelper.ConfigNotFound);
return 1;
}
// Partial update (e.g. only --border): merge into the existing config, no network call.
ConfigService.SaveConfig(opts);

ConsoleHelper.WriteSuccess(!string.IsNullOrEmpty(opts.Border)
? $"Table border set to '{opts.Border.ToLowerInvariant()}'."
: "Configuration updated.");

var border = opts.Border.ToLowerInvariant();
ConfigService.SetTableBorder(border);
ConsoleHelper.WriteSuccess($"Table border set to '{border}'.");
return 0;
}

// Authenticate with the effective credentials (newly provided ones fall back to what is stored).
var current = ConfigService.ConfigExists() ? ConfigService.LoadConfig() : null;
var url = opts.TimetrackerUrl ?? current?.TimetrackerUrl;
var token = opts.TimetrackerBearerToken ?? current?.TimetrackerBearerToken;

Console.WriteLine("Authenticating with Timetracker...");

var user = await HttpService.GetTimetrackerUser(opts.TimetrackerUrl, opts.TimetrackerBearerToken, cancellationToken);
var user = await HttpService.GetTimetrackerUser(url, token, cancellationToken);

ConfigService.SaveConfig(
opts,
Expand Down
60 changes: 26 additions & 34 deletions Timetracker.Console/Services/ConfigService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,34 +58,39 @@ public static Config LoadConfig()
return config;
}

public static void SaveConfig(ConfigOptions opts, string userId, string displayName, string email, string accountName)
/// <summary>
/// Merges the provided options into the existing configuration: only values actually
/// supplied are overwritten, everything else is preserved. Safe for partial updates.
/// </summary>
public static void SaveConfig(ConfigOptions opts, string userId = null, string displayName = null, string email = null, string accountName = null)
{
// LoadConfig returns the token already decrypted; WriteConfig re-encrypts it.
var existing = ConfigExists() ? LoadConfig() : new Config();

var config = new Config
{
TimetrackerUrl = opts.TimetrackerUrl ?? existing.TimetrackerUrl,
TimetrackerBearerToken = opts.TimetrackerBearerToken ?? existing.TimetrackerBearerToken,
TimetrackerUserId = userId ?? existing.TimetrackerUserId,
DisplayName = displayName ?? existing.DisplayName,
Email = email ?? existing.Email,
AccountName = accountName ?? existing.AccountName,
TableBorder = opts.Border?.ToLowerInvariant() ?? existing.TableBorder
};

WriteConfig(config);
}

private static void WriteConfig(Config config)
{
var configPath = GetConfigPath();
var folderPath = Path.GetDirectoryName(configPath);

if (!Directory.Exists(folderPath))
Directory.CreateDirectory(folderPath);

string token = opts.TimetrackerBearerToken;
bool tokenEncrypted = false;

if (OperatingSystem.IsWindows())
{
token = EncryptToken(token);
tokenEncrypted = true;
}

var config = new Config
{
TimetrackerUrl = opts.TimetrackerUrl,
TimetrackerBearerToken = token,
TimetrackerUserId = userId,
DisplayName = displayName,
Email = email,
AccountName = accountName,
TokenEncrypted = tokenEncrypted,
TableBorder = opts.Border ?? GetTableBorder()
};
if (OperatingSystem.IsWindows() && !string.IsNullOrEmpty(config.TimetrackerBearerToken))
config = config with { TimetrackerBearerToken = EncryptToken(config.TimetrackerBearerToken), TokenEncrypted = true };

File.WriteAllText(configPath, JsonConvert.SerializeObject(config, Formatting.Indented));

Expand All @@ -110,19 +115,6 @@ public static string GetTableBorder()
}
}

/// <summary>Updates only the table border, preserving the (encrypted) token and everything else.</summary>
public static void SetTableBorder(string border)
{
var configPath = GetConfigPath();
if (!File.Exists(configPath))
throw new FileNotFoundException($"{JSON_FILE_NAME} does not exist. Make sure you already executed the config method.");

var config = JsonConvert.DeserializeObject<Config>(File.ReadAllText(configPath)) with { TableBorder = border };
File.WriteAllText(configPath, JsonConvert.SerializeObject(config, Formatting.Indented));

if (!OperatingSystem.IsWindows())
File.SetUnixFileMode(configPath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
}

[SupportedOSPlatform("windows")]
private static string EncryptToken(string token)
Expand Down
28 changes: 19 additions & 9 deletions Timetracker.Console/Validators/ConfigValidator.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using FluentValidation;
using FluentValidation;
using Timetracker.Options;
using Timetracker.Services;
using Timetracker.Utils;

namespace Timetracker.Validators;
Expand All @@ -8,20 +9,29 @@ public class ConfigValidator : AbstractValidator<ConfigOptions>
{
public ConfigValidator()
{
// A border-only invocation (config --border ...) does not set credentials.
bool BorderOnly(ConfigOptions x) =>
!string.IsNullOrEmpty(x.Border) &&
string.IsNullOrEmpty(x.TimetrackerUrl) &&
string.IsNullOrEmpty(x.TimetrackerBearerToken);
RuleFor(x => x)
.Must(x => x.Show || x.Reset
|| !string.IsNullOrEmpty(x.TimetrackerUrl)
|| !string.IsNullOrEmpty(x.TimetrackerBearerToken)
|| !string.IsNullOrEmpty(x.Border))
.WithMessage("Provide at least one option: --url, --token, --border, --show, or --reset.");

When(x => !x.Show && !x.Reset && !BorderOnly(x), () =>
// Credentials are only mandatory on first-time setup. Once configured, any
// option can be updated on its own without re-entering the others.
When(x => !x.Show && !x.Reset && !ConfigService.ConfigExists(), () =>
{
RuleFor(x => x.TimetrackerBearerToken)
.NotEmpty().WithMessage("A Bearer token is required for authentication.");

RuleFor(x => x.TimetrackerUrl)
.NotEmpty().WithMessage("The Timetracker URL is required. Please provide the base URL.")
.Must(ValidationUtils.ValidUrl).WithMessage("The provided URL is invalid or does not use HTTPS. Ensure it is in the format 'https://<company>.timehub.7pace.com'.");
.NotEmpty().WithMessage("The Timetracker URL is required. Please provide the base URL.");
});

When(x => !string.IsNullOrEmpty(x.TimetrackerUrl), () =>
{
RuleFor(x => x.TimetrackerUrl)
.Must(ValidationUtils.ValidUrl)
.WithMessage("The provided URL is invalid or does not use HTTPS. Ensure it is in the format 'https://<company>.timehub.7pace.com'.");
});

When(x => !string.IsNullOrEmpty(x.Border), () =>
Expand Down
Loading