crowdtime-cli 0.17.1__tar.gz → 0.18.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/PKG-INFO +12 -3
  2. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/README.md +11 -2
  3. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/pyproject.toml +1 -1
  4. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/__init__.py +1 -1
  5. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/auth.py +19 -2
  6. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/client.py +47 -4
  7. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/auth_cmd.py +8 -1
  8. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/config_cmd.py +4 -2
  9. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/insights_cmd.py +4 -2
  10. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/log_cmd.py +7 -0
  11. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/org_cmd.py +5 -2
  12. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/report_cmd.py +1 -1
  13. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/timesheet_cmd.py +42 -13
  14. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/version_cmd.py +2 -2
  15. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/config.py +16 -0
  16. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/oauth.py +25 -3
  17. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/skills/crowdtime/SKILL.md +45 -27
  18. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/skills/crowdtime/references/commands.md +49 -34
  19. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/skills/crowdtime/references/pto.md +8 -6
  20. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/skills/crowdtime/references/workflows.md +45 -23
  21. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/version_check.py +17 -1
  22. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/.gitignore +0 -0
  23. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/LICENSE +0 -0
  24. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/__init__.py +0 -0
  25. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/ai_cmd.py +0 -0
  26. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/billing_cmd.py +0 -0
  27. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/calendar_cmd.py +0 -0
  28. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/clients_cmd.py +0 -0
  29. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/expense_cmd.py +0 -0
  30. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/favorites_cmd.py +0 -0
  31. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/invoice_cmd.py +0 -0
  32. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/payroll_cmd.py +0 -0
  33. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/projects_cmd.py +0 -0
  34. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/pto_cmd.py +0 -0
  35. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/skill_cmd.py +0 -0
  36. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/tasks_cmd.py +0 -0
  37. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/team_cmd.py +0 -0
  38. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/timer_cmd.py +0 -0
  39. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/formatters.py +0 -0
  40. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/main.py +0 -0
  41. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/models.py +0 -0
  42. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/resolvers.py +0 -0
  43. {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crowdtime-cli
3
- Version: 0.17.1
3
+ Version: 0.18.0
4
4
  Summary: AI-powered time tracking CLI — a modern, developer-friendly alternative to Harvest
5
5
  Project-URL: Homepage, https://crowdtime.lat
6
6
  Project-URL: Documentation, https://crowdtime.lat/docs
@@ -56,10 +56,19 @@ Track time, manage projects, log expenses, generate reports, submit timesheets,
56
56
  ## Installation
57
57
 
58
58
  ```bash
59
- pip install crowdtime-cli
59
+ pipx install crowdtime-cli
60
60
  ```
61
61
 
62
- Requires Python 3.11+
62
+ Requires Python 3.11+. [pipx](https://pipx.pypa.io/) installs the CLI in its own isolated environment and puts `crowdtime` and `ct` on your PATH. On Debian, Ubuntu and other systems where the system Python is "externally managed", plain `pip install` into the system Python is blocked, so use pipx (or `uv tool install crowdtime-cli`).
63
+
64
+ Prefer pip? Install into a virtualenv:
65
+
66
+ ```bash
67
+ python3 -m venv ~/.venvs/crowdtime
68
+ ~/.venvs/crowdtime/bin/pip install crowdtime-cli
69
+ ```
70
+
71
+ To upgrade later: `pipx upgrade crowdtime-cli` (or `pip install -U crowdtime-cli` in the virtualenv).
63
72
 
64
73
  ## Quick Start
65
74
 
@@ -21,10 +21,19 @@ Track time, manage projects, log expenses, generate reports, submit timesheets,
21
21
  ## Installation
22
22
 
23
23
  ```bash
24
- pip install crowdtime-cli
24
+ pipx install crowdtime-cli
25
25
  ```
26
26
 
27
- Requires Python 3.11+
27
+ Requires Python 3.11+. [pipx](https://pipx.pypa.io/) installs the CLI in its own isolated environment and puts `crowdtime` and `ct` on your PATH. On Debian, Ubuntu and other systems where the system Python is "externally managed", plain `pip install` into the system Python is blocked, so use pipx (or `uv tool install crowdtime-cli`).
28
+
29
+ Prefer pip? Install into a virtualenv:
30
+
31
+ ```bash
32
+ python3 -m venv ~/.venvs/crowdtime
33
+ ~/.venvs/crowdtime/bin/pip install crowdtime-cli
34
+ ```
35
+
36
+ To upgrade later: `pipx upgrade crowdtime-cli` (or `pip install -U crowdtime-cli` in the virtualenv).
28
37
 
29
38
  ## Quick Start
30
39
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "crowdtime-cli"
3
- version = "0.17.1"
3
+ version = "0.18.0"
4
4
  description = "AI-powered time tracking CLI — a modern, developer-friendly alternative to Harvest"
5
5
  readme = "README.md"
6
6
  license = {text = "Proprietary"}
@@ -1,3 +1,3 @@
1
1
  """CrowdTime CLI - AI-powered time tracking from the command line."""
2
2
 
3
- __version__ = "0.17.1"
3
+ __version__ = "0.18.0"
@@ -5,6 +5,8 @@ Stores API tokens in the system keyring, with file-based fallback.
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
+ import os
9
+ import sys
8
10
  from pathlib import Path
9
11
 
10
12
  from .config import CONFIG_DIR
@@ -51,8 +53,23 @@ def clear_token() -> None:
51
53
  def _save_token_file(token: str) -> None:
52
54
  """File-based fallback for token storage."""
53
55
  CONFIG_DIR.mkdir(parents=True, exist_ok=True)
54
- CREDENTIALS_FILE.write_text(token)
55
- CREDENTIALS_FILE.chmod(0o600)
56
+ # Created owner-only from the start — writing first and tightening the
57
+ # mode afterwards leaves the token world-readable in between.
58
+ fd = os.open(CREDENTIALS_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
59
+ try:
60
+ # The mode above only applies to a new file; an existing one keeps
61
+ # whatever it had, so tighten it before the token goes in.
62
+ if hasattr(os, "fchmod"):
63
+ os.fchmod(fd, 0o600)
64
+ except OSError:
65
+ pass
66
+ with os.fdopen(fd, "w") as f:
67
+ f.write(token)
68
+ print(
69
+ "Warning: no system keyring available — the API token was stored in "
70
+ f"plain text at {CREDENTIALS_FILE} (readable only by you).",
71
+ file=sys.stderr,
72
+ )
56
73
 
57
74
 
58
75
  def _get_token_file() -> str | None:
@@ -7,16 +7,39 @@ and friendly error handling.
7
7
  from __future__ import annotations
8
8
 
9
9
  import os
10
+ import sys
10
11
  from typing import Any
11
12
 
12
13
  import httpx
13
14
  from rich.console import Console
15
+ from rich.markup import escape
14
16
 
15
17
  from .auth import get_token
16
- from .config import get_config
18
+ from .config import INSECURE_SERVER_URL_WARNING, get_config, is_insecure_server_url
17
19
 
18
20
  console = Console(stderr=True)
19
21
  _DEBUG = os.environ.get("CROWDTIME_DEBUG", "").lower() in ("1", "true", "yes")
22
+ # One warning per invocation, however many clients a command creates.
23
+ _warned_insecure_url = False
24
+
25
+
26
+ def _error_message(detail: Any) -> str:
27
+ """Turn an API error body into one readable line.
28
+
29
+ Validation errors arrive as ``{"field": ["message", ...]}``; printing the
30
+ dict verbatim buries the reason the server gave.
31
+ """
32
+ if isinstance(detail, dict):
33
+ if isinstance(detail.get("detail"), str):
34
+ return detail["detail"]
35
+ parts = []
36
+ for field, value in detail.items():
37
+ text = "; ".join(str(v) for v in value) if isinstance(value, (list, tuple)) else str(value)
38
+ parts.append(text if field in ("non_field_errors", "detail") else f"{field}: {text}")
39
+ return " | ".join(parts) if parts else str(detail)
40
+ if isinstance(detail, list):
41
+ return "; ".join(str(item) for item in detail)
42
+ return str(detail)
20
43
 
21
44
 
22
45
  class APIError(Exception):
@@ -37,6 +60,14 @@ class CrowdTimeClient:
37
60
  self.base_url = self.config.server_url
38
61
  self.token = get_token()
39
62
  self.org_slug = self.config.organization
63
+ # Extra line a command can set before a call, shown if the server
64
+ # answers 403 — for pointing at the command that would work instead.
65
+ self.forbidden_hint: str | None = None
66
+
67
+ global _warned_insecure_url
68
+ if not _warned_insecure_url and is_insecure_server_url(self.base_url):
69
+ _warned_insecure_url = True
70
+ print(INSECURE_SERVER_URL_WARNING.format(url=self.base_url), file=sys.stderr)
40
71
 
41
72
  if require_auth and not self.token:
42
73
  console.print(
@@ -138,7 +169,20 @@ class CrowdTimeClient:
138
169
  raise SystemExit(1)
139
170
 
140
171
  if response.status_code == 403:
141
- console.print("[red]Permission denied.[/red] You don't have access to this resource.")
172
+ # The server says why (not your entry, already invoiced, no
173
+ # approval authority, read-only token…) — pass that on instead of
174
+ # a generic refusal.
175
+ reason = "You don't have access to this resource."
176
+ try:
177
+ body = response.json()
178
+ if isinstance(body, dict) and isinstance(body.get("detail"), str) and body["detail"]:
179
+ reason = body["detail"]
180
+ except Exception:
181
+ pass
182
+ console.print(f"[red]Permission denied.[/red] {escape(reason)}")
183
+ hint = getattr(self, "forbidden_hint", None)
184
+ if hint:
185
+ console.print(f"[dim]{escape(hint)}[/dim]")
142
186
  raise SystemExit(1)
143
187
 
144
188
  if response.status_code == 402:
@@ -179,8 +223,7 @@ class CrowdTimeClient:
179
223
  detail = response.json()
180
224
  except Exception:
181
225
  detail = response.text
182
- msg = detail.get("detail", str(detail)) if isinstance(detail, dict) else str(detail)
183
- raise APIError(msg, status_code=response.status_code, detail=detail)
226
+ raise APIError(_error_message(detail), status_code=response.status_code, detail=detail)
184
227
 
185
228
  if not response.content:
186
229
  return None
@@ -81,7 +81,14 @@ def _login_with_browser(output_json: bool) -> None:
81
81
  console.print(f"[dim]Server: {server_url}[/dim]")
82
82
  console.print(f"[dim]Waiting for authentication (up to 2 minutes)...[/dim]\n")
83
83
 
84
- result = run_oauth_flow(server_url)
84
+ def _show_code(code: str) -> None:
85
+ console.print(f"Verification code: [bold]{code}[/bold]")
86
+ console.print(
87
+ "[dim]The browser will ask you to authorize this login. "
88
+ "Only approve it if it shows the same code.[/dim]\n"
89
+ )
90
+
91
+ result = run_oauth_flow(server_url, on_verification_code=_show_code)
85
92
 
86
93
  if result is None:
87
94
  console.print()
@@ -8,7 +8,7 @@ import subprocess
8
8
  import typer
9
9
  from rich.console import Console
10
10
 
11
- from ..config import get_config
11
+ from ..config import INSECURE_SERVER_URL_WARNING, get_config, is_insecure_server_url
12
12
  from ..formatters import format_error, format_success, print_json
13
13
 
14
14
  app = typer.Typer(name="config", help="Manage CLI configuration.")
@@ -23,11 +23,13 @@ def config_set(
23
23
  """Set a configuration value.
24
24
 
25
25
  Examples:
26
- ct config set server.url https://api.crowdtime.io
26
+ ct config set server.url https://api.crowdtime.lat
27
27
  ct config set defaults.project myproject
28
28
  ct config set defaults.daily_target 7h
29
29
  """
30
30
  config = get_config()
31
+ if key == "server.url" and is_insecure_server_url(value):
32
+ typer.echo(INSECURE_SERVER_URL_WARNING.format(url=value), err=True)
31
33
  # Convert boolean strings
32
34
  if value.lower() in ("true", "yes", "1"):
33
35
  config.set(key, True)
@@ -571,7 +571,8 @@ def profitability_insights(
571
571
 
572
572
  Revenue is computed from billable time entries. Cost is derived from team
573
573
  compensation configs (hourly rate or prorated salary). Margin = Revenue - Cost.
574
- Requires manager+ role. Cost data requires payroll compensation configs.
574
+ Requires manager+ role. Cost and margin are shown to admins and owners only
575
+ (they derive from payroll); account managers get revenue and hours.
575
576
 
576
577
  Examples:
577
578
  ct insights profitability
@@ -671,7 +672,8 @@ def profitability_insights(
671
672
 
672
673
  if not has_cost:
673
674
  console.print(
674
- "[dim]ℹ Cost data unavailable — configure compensation in Payroll to see margins.[/dim]"
675
+ "[dim]ℹ Cost and margin are not shown. They derive from payroll, so they are "
676
+ "visible to admins and owners only, and need compensation configured in Payroll.[/dim]"
675
677
  )
676
678
 
677
679
  console.print()
@@ -277,8 +277,15 @@ def bulk_delete(
277
277
  if skipped:
278
278
  for s in skipped:
279
279
  console.print(f" [yellow]Skipped[/yellow] {s['id']}: {s['reason']}")
280
+ # Entries that aren't yours are not reported at all — the server
281
+ # treats them as not found.
282
+ unmatched = len(set(ids)) - deleted_count - len(skipped)
280
283
  if not deleted_count and not skipped:
281
284
  console.print("[dim]No matching entries found.[/dim]")
285
+ elif unmatched > 0:
286
+ console.print(
287
+ f" [dim]{unmatched} ID(s) not found (wrong ID, or not your entry).[/dim]"
288
+ )
282
289
  except APIError as e:
283
290
  format_error(e.message)
284
291
  raise typer.Exit(1)
@@ -231,7 +231,10 @@ def update_member(
231
231
  capacity: Optional[float] = typer.Option(None, "--capacity", help="Weekly capacity hours."),
232
232
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
233
233
  ) -> None:
234
- """Update an organization member's role, rate, or capacity.
234
+ """Update an organization member's role, rate, or capacity (admin+).
235
+
236
+ You cannot grant a role above your own, and only the owner can change
237
+ the owner's membership. Ownership is not assignable here.
235
238
 
236
239
  Examples:
237
240
  ct org update-member <user-id> --role project_manager
@@ -363,7 +366,7 @@ def revoke_invite(
363
366
  def invitations(
364
367
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
365
368
  ) -> None:
366
- """List pending invitations for the current organization."""
369
+ """List pending invitations for the current organization (admin+)."""
367
370
  client = CrowdTimeClient(require_auth=True, require_org=True)
368
371
 
369
372
  try:
@@ -649,7 +649,7 @@ def saved_delete(
649
649
  client = CrowdTimeClient(require_auth=True, require_org=True)
650
650
 
651
651
  if not force:
652
- if not typer.confirm(f"Delete saved report {report_id[:8]}…?"):
652
+ if not typer.confirm(f"Delete saved report {report_id}?"):
653
653
  console.print("[dim]Cancelled.[/dim]")
654
654
  raise typer.Exit(0)
655
655
 
@@ -83,6 +83,14 @@ def _print_timesheets_table(timesheets: list[dict], show_user: bool = False) ->
83
83
  console.print(table)
84
84
 
85
85
 
86
+ _REVIEW_BY_PROJECT_HINT = (
87
+ "A whole timesheet can be decided by an admin, by the person's direct manager, or by "
88
+ "someone who manages every project in it. Otherwise review your project's portion: "
89
+ "ct timesheet approvals <timesheet-id>, then ct timesheet approve|reject <timesheet-id> "
90
+ "--project <project-id>."
91
+ )
92
+
93
+
86
94
  def _print_team_overview(data: dict) -> None:
87
95
  """Print team overview in a Rich table."""
88
96
  from rich.table import Table
@@ -113,6 +121,7 @@ def _print_team_overview(data: dict) -> None:
113
121
  table.add_column("Entries", justify="right", width=8)
114
122
  table.add_column("Status", width=14)
115
123
 
124
+ any_by_project = False
116
125
  for m in members:
117
126
  # When project filter is active, surface the per-project approval
118
127
  # status rather than the whole-timesheet status.
@@ -123,8 +132,12 @@ def _print_team_overview(data: dict) -> None:
123
132
  status = m.get("timesheet_status")
124
133
  style = _status_style(status)
125
134
 
126
- capacity_pct = m.get("capacity_percent", 0)
127
- if capacity_pct > 100:
135
+ # Null for people who only share a project with the caller: their
136
+ # hours here are that project's, not their whole week.
137
+ capacity_pct = m.get("capacity_percent")
138
+ if capacity_pct is None:
139
+ pct_display = "[dim]—[/dim]"
140
+ elif capacity_pct > 100:
128
141
  pct_display = f"[red]{capacity_pct}%[/red]"
129
142
  elif capacity_pct >= 80:
130
143
  pct_display = f"[green]{capacity_pct}%[/green]"
@@ -133,17 +146,27 @@ def _print_team_overview(data: dict) -> None:
133
146
 
134
147
  period_cap = m.get("period_capacity_hours", "40.0")
135
148
 
149
+ review_by_project = m.get("can_review_timesheet") is False
150
+ if review_by_project:
151
+ any_by_project = True
152
+
136
153
  table.add_row(
137
- m.get("user_name", ""),
138
- format_duration(Decimal(str(m.get("total_hours", 0)))),
154
+ m.get("user_name", "") + (" *" if review_by_project else ""),
155
+ format_duration(Decimal(str(m.get("total_hours") or 0))),
139
156
  f"{period_cap}h",
140
157
  pct_display,
141
- format_duration(Decimal(str(m.get("billable_hours", 0)))),
158
+ format_duration(Decimal(str(m.get("billable_hours") or 0))),
142
159
  str(m.get("entry_count", 0)),
143
160
  f"[{style}]{_status_label(status)}[/{style}]",
144
161
  )
145
162
 
146
163
  console.print(table)
164
+ if any_by_project:
165
+ console.print(
166
+ "[dim]* Shares a project with you but is not your direct report: hours shown are "
167
+ "for your projects only, and you review their timesheet per project "
168
+ "(ct timesheet approve <id> --project <project-id>).[/dim]"
169
+ )
147
170
 
148
171
 
149
172
  # ─── Commands ─────────────────────────────────────────────────────
@@ -282,7 +305,8 @@ def approve_timesheet(
282
305
  ) -> None:
283
306
  """Approve a submitted timesheet (project_manager+).
284
307
 
285
- Without --project: approves all project portions at once (manager+).
308
+ Without --project: approves the whole timesheet. Allowed for admins, the
309
+ person's direct manager, or someone who manages every project in it.
286
310
  With --project: approves only that project's portion (PM for that project).
287
311
 
288
312
  Examples:
@@ -294,7 +318,7 @@ def approve_timesheet(
294
318
 
295
319
  if not force and not output_json:
296
320
  scope = f"project portion for {project}" if project else "all projects"
297
- if not typer.confirm(f"Approve timesheet {timesheet_id[:8]}… ({scope})?"):
321
+ if not typer.confirm(f"Approve timesheet {timesheet_id} ({scope})?"):
298
322
  console.print("[dim]Cancelled.[/dim]")
299
323
  raise typer.Exit(0)
300
324
 
@@ -309,13 +333,14 @@ def approve_timesheet(
309
333
  data=data or None,
310
334
  )
311
335
  else:
336
+ client.forbidden_hint = _REVIEW_BY_PROJECT_HINT
312
337
  result = client.post(f"/timesheets/{timesheet_id}/approve/", data=data or None)
313
338
 
314
339
  if output_json:
315
340
  print_json(result)
316
341
  else:
317
342
  if project:
318
- project_name = result.get("project_name") or project[:8]
343
+ project_name = result.get("project_name") or project
319
344
  ts_status = result.get("timesheet_status", "")
320
345
  format_success(f"Project '{project_name}' approved.")
321
346
  if ts_status == "approved":
@@ -344,7 +369,9 @@ def reject_timesheet(
344
369
  """Reject a submitted timesheet with notes (project_manager+).
345
370
 
346
371
  Rejecting any project portion rejects the entire timesheet.
347
- The user must fix entries and resubmit.
372
+ The user must fix entries and resubmit. Without --project you need
373
+ authority over the whole timesheet (admin, direct manager, or managing
374
+ every project in it); with --project, managing that project is enough.
348
375
 
349
376
  Examples:
350
377
  ct timesheet reject <timesheet-id> --notes "Missing entries for Wednesday"
@@ -354,7 +381,7 @@ def reject_timesheet(
354
381
 
355
382
  if not force and not output_json:
356
383
  console.print("[yellow]Rejecting will return the entire timesheet for revision.[/yellow]")
357
- if not typer.confirm(f"Reject timesheet {timesheet_id[:8]}…?"):
384
+ if not typer.confirm(f"Reject timesheet {timesheet_id}?"):
358
385
  console.print("[dim]Cancelled.[/dim]")
359
386
  raise typer.Exit(0)
360
387
 
@@ -365,6 +392,7 @@ def reject_timesheet(
365
392
  data={"notes": notes},
366
393
  )
367
394
  else:
395
+ client.forbidden_hint = _REVIEW_BY_PROJECT_HINT
368
396
  result = client.post(f"/timesheets/{timesheet_id}/reject/", data={
369
397
  "notes": notes,
370
398
  })
@@ -373,7 +401,7 @@ def reject_timesheet(
373
401
  print_json(result)
374
402
  else:
375
403
  if project:
376
- project_name = result.get("project_name") or project[:8]
404
+ project_name = result.get("project_name") or project
377
405
  format_success(f"Project '{project_name}' rejected. Timesheet returned for revision.")
378
406
  else:
379
407
  user_name = result.get("user_name", "user")
@@ -402,7 +430,7 @@ def recall_timesheet(
402
430
  client = CrowdTimeClient(require_auth=True, require_org=True)
403
431
 
404
432
  if not force and not output_json:
405
- if not typer.confirm(f"Recall timesheet {timesheet_id[:8]}… back to draft?"):
433
+ if not typer.confirm(f"Recall timesheet {timesheet_id} back to draft?"):
406
434
  console.print("[dim]Cancelled.[/dim]")
407
435
  raise typer.Exit(0)
408
436
 
@@ -439,7 +467,8 @@ def reopen_timesheet(
439
467
  The reviewer-side undo for an approval granted in error: clears the
440
468
  approval, unlocks the period's time entries and returns the timesheet to
441
469
  draft so the user can fix it and resubmit. Blocked if any of that time is
442
- already on a finalized invoice.
470
+ already on a finalized invoice. Needs authority over the whole timesheet:
471
+ admin, the person's direct manager, or managing every project in it.
443
472
 
444
473
  Examples:
445
474
  ct timesheet reopen <timesheet-id> --notes "Approved before Friday's entries were added"
@@ -7,7 +7,7 @@ from rich.console import Console
7
7
 
8
8
  from .. import __version__
9
9
  from ..formatters import print_json
10
- from ..version_check import _parse_simple, get_latest, is_outdated
10
+ from ..version_check import _parse_simple, get_latest, is_outdated, upgrade_command
11
11
 
12
12
  app = typer.Typer(name="version", help="Show installed and latest CLI versions.")
13
13
  console = Console()
@@ -51,7 +51,7 @@ def version(
51
51
  elif outdated:
52
52
  console.print(
53
53
  f"[yellow]\u26a0 A newer version is available. "
54
- f"Run [bold]pip install -U crowdtime-cli[/bold] to upgrade.[/yellow]"
54
+ f"Run [bold]{upgrade_command()}[/bold] to upgrade.[/yellow]"
55
55
  )
56
56
  else:
57
57
  console.print("[green]\u2713 You are up to date.[/green]")
@@ -34,6 +34,22 @@ DEFAULT_CONFIG = {
34
34
  }
35
35
 
36
36
 
37
+ INSECURE_SERVER_URL_WARNING = (
38
+ "Warning: server.url is {url} — plain http sends your API token "
39
+ "unencrypted. Use https for anything but a local server."
40
+ )
41
+
42
+ _LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
43
+
44
+
45
+ def is_insecure_server_url(url: str) -> bool:
46
+ """True for a plain-http server URL that isn't on this machine."""
47
+ from urllib.parse import urlsplit
48
+
49
+ parts = urlsplit(str(url).strip())
50
+ return parts.scheme == "http" and (parts.hostname or "") not in _LOCAL_HOSTS
51
+
52
+
37
53
  class CrowdTimeConfig:
38
54
  """Manages CrowdTime CLI configuration."""
39
55
 
@@ -10,20 +10,39 @@ import hashlib
10
10
  import secrets
11
11
  import time
12
12
  import webbrowser
13
+ from collections.abc import Callable
13
14
 
14
15
  import httpx
15
16
 
16
17
 
17
- def run_oauth_flow(server_url: str, timeout: int = 120) -> tuple[str, str] | None:
18
+ def verification_code(state: str) -> str:
19
+ """Short code the server shows on its consent page for this login.
20
+
21
+ Comparing it with the terminal confirms the browser is authorizing the
22
+ session started here. Must match ``cli_verification_code`` on the server.
23
+ """
24
+ digest = hashlib.sha256(f'cli-verify:{state}'.encode()).hexdigest().upper()
25
+ return f'{digest[:4]}-{digest[4:8]}'
26
+
27
+
28
+ def run_oauth_flow(
29
+ server_url: str,
30
+ timeout: int = 120,
31
+ on_verification_code: Callable[[str], None] | None = None,
32
+ ) -> tuple[str, str] | None:
18
33
  """Run the browser-based OAuth login flow with PKCE protection.
19
34
 
20
35
  1. Opens browser for Google OAuth on the server
21
- 2. Polls the server until the auth code is available
22
- 3. Exchanges the code + PKCE verifier for an API token
36
+ 2. The user confirms on the server's consent page that the code shown
37
+ there matches the one printed here
38
+ 3. Polls the server until the auth code is available
39
+ 4. Exchanges the code + PKCE verifier for an API token
23
40
 
24
41
  Args:
25
42
  server_url: The CrowdTime server URL (e.g. https://api.crowdtime.lat).
26
43
  timeout: Max seconds to wait for the user to complete OAuth.
44
+ on_verification_code: Called with the code to display before the
45
+ browser opens.
27
46
 
28
47
  Returns:
29
48
  Tuple of (api_token, org_slug) on success, or None on failure/timeout.
@@ -40,6 +59,9 @@ def run_oauth_flow(server_url: str, timeout: int = 120) -> tuple[str, str] | Non
40
59
  f'?state={state}&code_challenge={code_challenge}'
41
60
  )
42
61
 
62
+ if on_verification_code is not None:
63
+ on_verification_code(verification_code(state))
64
+
43
65
  if not webbrowser.open(login_url):
44
66
  return None
45
67