feat(tempo): per-user Tempo API token — same pattern as Jira token
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled

Each user can now store their own personal Tempo API token in their
profile settings. Time is logged using each user's own credentials.

Backend:
- Migration b044: adds tempo_api_token column to users table
- User model: adds tempo_api_token column
- UserPreferencesUpdate: adds tempo_api_token field (write-only)
- UserOut: adds tempo_api_token (excluded) + tempo_token_set bool;
  @model_validator derives both jira_token_set and tempo_token_set
- users router: handles tempo_api_token same as jira_api_token
  (empty string clears it, never returned in responses)
- tempo_service: refactored to per-user token; has_tempo_configured(),
  get_user_tempo_client(user) use user.tempo_api_token; global
  TEMPO_ENABLED still acts as kill-switch
- system router: /system/tempo-test now uses current user's personal
  token (any role); removed global TEMPO_API_TOKEN dependency

Frontend:
- settings.ts: UserPreferencesUpdate.tempo_api_token, UserMeOut.tempo_token_set
- SettingsPage ProfileSection: Tempo Integration section with password
  field, show/hide toggle, configured badge, and Test Tempo button —
  mirrors the Jira token UX exactly
- JiraConfigSection: removed stale global Tempo test block

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kitos
2026-05-27 10:46:38 +02:00
parent 2337abe55e
commit 69d92f500a
8 changed files with 235 additions and 100 deletions

View File

@@ -820,6 +820,10 @@ function ProfileSection() {
const [jiraEmail, setJiraEmail] = useState<string>("");
const [jiraApiToken, setJiraApiToken] = useState<string>("");
const [showToken, setShowToken] = useState(false);
const [tempoApiToken, setTempoApiToken] = useState<string>("");
const [showTempoToken, setShowTempoToken] = useState(false);
const [tempoTestResult, setTempoTestResult] = useState<string | null>(null);
const [tempoTestError, setTempoTestError] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);
// Initialise editable fields from server on first successful load
@@ -839,22 +843,43 @@ function ProfileSection() {
// Update cache immediately with the response — no extra round-trip needed
qc.setQueryData(["me-prefs"], updatedMe);
setDirty(false);
setJiraApiToken(""); // clear token field after save — it's persisted
setJiraApiToken(""); // clear token fields after save — they're persisted
setTempoApiToken("");
setToast({ msg: "Profile settings saved", type: "success" });
},
onError: () => setToast({ msg: "Failed to save", type: "error" }),
});
const tempoTestMut = useMutation({
mutationFn: testTempoConnection,
onSuccess: (data) => {
if (data.status === "ok") {
setTempoTestResult(data.message ?? "Connected");
setTempoTestError(null);
} else {
setTempoTestError(data.message ?? "Tempo test failed");
setTempoTestResult(null);
}
},
onError: (err: Error) => {
setTempoTestError(err.message || "Tempo test failed");
setTempoTestResult(null);
},
});
const handleSave = () => {
const payload: Parameters<typeof updateMyPreferences>[0] = {
jira_account_id: jiraAccountId || null,
jira_email: jiraEmail || null,
};
// Only send token when the user has typed something new
// Only send tokens when the user has typed something new
// (empty field = "keep current token unchanged")
if (jiraApiToken.trim() !== "") {
payload.jira_api_token = jiraApiToken.trim();
}
if (tempoApiToken.trim() !== "") {
payload.tempo_api_token = tempoApiToken.trim();
}
saveMut.mutate(payload);
};
@@ -979,7 +1004,7 @@ function ProfileSection() {
{/* Account ID */}
<div>
<label className="mb-1 block text-xs font-medium text-cyan-400">
Jira Account ID (for Tempo time tracking, optional)
Jira Account ID (required for Tempo time tracking)
</label>
<input
value={jiraAccountId}
@@ -994,6 +1019,90 @@ function ProfileSection() {
Your Atlassian account ID. Found in your Jira profile URL.
</p>
</div>
</div>
</div>
{/* ── Tempo Integration ─────────────────────────────────── */}
<div className="border-t border-gray-800 pt-4">
<p className="text-sm font-semibold text-gray-300 mb-1">Tempo Integration (personal settings)</p>
<p className="text-xs text-gray-500 mb-4">
Your personal Tempo API token logs work time on Jira tickets automatically.
</p>
<div className="space-y-4">
{/* Tempo API Token */}
<div>
<label className="mb-1 block text-xs font-medium text-purple-400">
Tempo API Token
</label>
<div className="relative">
<input
type={showTempoToken ? "text" : "password"}
value={tempoApiToken}
onChange={(e) => {
setTempoApiToken(e.target.value);
setDirty(true);
}}
placeholder={me?.tempo_token_set ? "Leave blank to keep current token" : "Paste your Tempo API token here"}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 pr-10 text-sm text-gray-200 placeholder-gray-600 focus:border-purple-500 focus:outline-none"
/>
<button
type="button"
onClick={() => setShowTempoToken(!showTempoToken)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
>
{showTempoToken ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<div className="mt-1.5 flex items-center gap-2">
{me?.tempo_token_set ? (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-900/50 px-2 py-0.5 text-[11px] font-medium text-emerald-400">
<CheckCircle className="h-3 w-3" />
Token configured
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-900/50 px-2 py-0.5 text-[11px] font-medium text-amber-400">
<AlertCircle className="h-3 w-3" />
Not configured
</span>
)}
<span className="text-[11px] text-gray-500">
Get it at: Jira Apps Tempo Settings API Integration
</span>
</div>
</div>
{/* Test Tempo connection */}
<div className="rounded-lg border border-gray-700 bg-gray-800/50 p-3 space-y-2">
<button
onClick={() => {
setTempoTestResult(null);
setTempoTestError(null);
tempoTestMut.mutate();
}}
disabled={tempoTestMut.isPending || !me?.tempo_token_set}
className="flex items-center gap-2 rounded-lg border border-purple-700 px-3 py-1.5 text-sm font-medium text-purple-400 hover:bg-purple-900/30 disabled:opacity-50 transition-colors"
>
{tempoTestMut.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<TestTube className="h-4 w-4" />
)}
Test Tempo Connection
</button>
{tempoTestResult && (
<div className="flex items-center gap-2 rounded-md bg-emerald-900/30 border border-emerald-800/50 px-3 py-2 text-sm text-emerald-300">
<CheckCircle className="h-4 w-4 shrink-0" />
{tempoTestResult}
</div>
)}
{tempoTestError && (
<div className="flex items-center gap-2 rounded-md bg-red-900/30 border border-red-800/50 px-3 py-2 text-sm text-red-300">
<XCircle className="h-4 w-4 shrink-0" />
{tempoTestError}
</div>
)}
</div>
<div>
<button
@@ -1025,8 +1134,6 @@ function JiraConfigSection() {
const [toast, setToast] = useState<{ msg: string; type: "success" | "error" } | null>(null);
const [testResult, setTestResult] = useState<{ connectedAs: string; url: string } | null>(null);
const [testError, setTestError] = useState<string | null>(null);
const [tempoResult, setTempoResult] = useState<string | null>(null);
const [tempoError, setTempoError] = useState<string | null>(null);
const { data: cfg, isLoading } = useQuery({
queryKey: ["jira-config"],
@@ -1064,23 +1171,6 @@ function JiraConfigSection() {
},
});
const tempoTestMut = useMutation({
mutationFn: testTempoConnection,
onSuccess: (data) => {
if (data.status === "ok") {
setTempoResult(data.message ?? "Connected");
setTempoError(null);
} else {
setTempoError(data.message ?? "Tempo test failed");
setTempoResult(null);
}
},
onError: (err: Error) => {
setTempoError(err.message || "Tempo test failed");
setTempoResult(null);
},
});
if (isLoading) {
return (
<div className="flex justify-center py-8">
@@ -1196,42 +1286,6 @@ function JiraConfigSection() {
)}
</div>
{/* Test Tempo connection */}
<div className="mt-2 rounded-lg border border-gray-700 bg-gray-800/50 p-4 space-y-3">
<p className="text-sm font-medium text-gray-300">Test Tempo Connection</p>
<div className="rounded-md bg-blue-900/20 border border-blue-800/50 px-3 py-2 text-xs text-blue-300">
Requires <code className="font-mono">TEMPO_ENABLED=true</code> and <code className="font-mono">TEMPO_API_TOKEN</code> set in the server environment, plus your Jira Account ID in the Profile tab.
</div>
<button
onClick={() => {
setTempoResult(null);
setTempoError(null);
tempoTestMut.mutate();
}}
disabled={tempoTestMut.isPending}
className="flex items-center gap-2 rounded-lg border border-purple-700 px-4 py-2 text-sm font-medium text-purple-400 hover:bg-purple-900/30 disabled:opacity-50 transition-colors"
>
{tempoTestMut.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<TestTube className="h-4 w-4" />
)}
Test Tempo Connection
</button>
{tempoResult && (
<div className="flex items-center gap-2 rounded-md bg-emerald-900/30 border border-emerald-800/50 px-3 py-2 text-sm text-emerald-300">
<CheckCircle className="h-4 w-4 shrink-0" />
{tempoResult}
</div>
)}
{tempoError && (
<div className="flex items-center gap-2 rounded-md bg-red-900/30 border border-red-800/50 px-3 py-2 text-sm text-red-300">
<XCircle className="h-4 w-4 shrink-0" />
{tempoError}
</div>
)}
</div>
</div>
</>
);