feat(tempo): per-user Tempo API token — same pattern as Jira token
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user