feat(jira): admin account replaces per-user tokens for all Jira/Tempo ops
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
- Single admin Jira account stored in system_configs (jira.admin_email, jira.admin_api_token) - Admin Tempo token in system_configs (tempo.admin_token) - get_admin_jira_client() + has_admin_jira_configured() replace per-user auth in all lifecycle hooks - lookup_user_jira_account_id() auto-discovers each user's Atlassian accountId by email on login - auto_log_test_worklog() now uses admin Tempo token and logs both red+blue team time - Settings > Jira: admin credentials fields + test buttons moved to admin Jira tab - Profile section simplified: jira_account_id shown read-only (auto-detected)
This commit is contained in:
@@ -163,6 +163,8 @@ export interface JiraConfigOut {
|
||||
project_key: string;
|
||||
parent_ticket: string;
|
||||
parent_ticket_standalone: string;
|
||||
admin_email_set: boolean;
|
||||
tempo_admin_token_set: boolean;
|
||||
}
|
||||
|
||||
export interface JiraConfigUpdate {
|
||||
@@ -171,6 +173,9 @@ export interface JiraConfigUpdate {
|
||||
project_key?: string;
|
||||
parent_ticket?: string;
|
||||
parent_ticket_standalone?: string;
|
||||
admin_email?: string;
|
||||
admin_api_token?: string;
|
||||
tempo_admin_token?: string;
|
||||
}
|
||||
|
||||
export async function getJiraConfig(): Promise<JiraConfigOut> {
|
||||
|
||||
+243
-354
@@ -826,51 +826,102 @@ function NotificationSection() {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ProfileSection() {
|
||||
const qc = useQueryClient();
|
||||
const [toast, setToast] = useState<{ msg: string; type: "success" | "error" } | null>(null);
|
||||
const { user } = useAuth();
|
||||
// Blue team roles have no Jira/Tempo access — hide those settings for them
|
||||
const showJiraTempoSettings = !["blue_lead", "blue_tech"].includes(user?.role ?? "");
|
||||
|
||||
const { data: me, isLoading } = useQuery({
|
||||
queryKey: ["me-prefs"],
|
||||
queryFn: getMe,
|
||||
});
|
||||
|
||||
const [jiraAccountId, setJiraAccountId] = useState<string>("");
|
||||
const [jiraEmail, setJiraEmail] = useState<string>("");
|
||||
const [jiraApiToken, setJiraApiToken] = useState<string>("");
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
const [tempoApiToken, setTempoApiToken] = useState<string>("");
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Username</p>
|
||||
<p className="text-gray-300 font-medium">{me?.username}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Role</p>
|
||||
<p className="text-gray-300 font-medium capitalize">{me?.role?.replace("_", " ")}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Email</p>
|
||||
<p className="text-gray-300">{me?.email ?? "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Last Login</p>
|
||||
<p className="text-gray-300">
|
||||
{me?.last_login
|
||||
? new Date(me.last_login).toLocaleString("en-US", {
|
||||
timeZone: "UTC",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}) + " UTC"
|
||||
: "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-800 pt-4">
|
||||
<p className="text-sm font-semibold text-gray-300 mb-1">Jira / Tempo</p>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
Your Atlassian account ID is detected automatically on login using the admin Jira account. No personal tokens required.
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-500">Account ID:</span>
|
||||
{me?.jira_account_id ? (
|
||||
<code className="rounded bg-gray-800 border border-gray-700 px-2 py-1 text-xs text-cyan-300 font-mono">
|
||||
{me.jira_account_id}
|
||||
</code>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-gray-800 border border-gray-700 px-2 py-0.5 text-[11px] text-gray-500">
|
||||
Not detected yet — log out and back in after admin configures Jira
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Jira Admin Config Section (admin only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function JiraConfigSection() {
|
||||
const qc = useQueryClient();
|
||||
const [toast, setToast] = useState<{ msg: string; type: "success" | "error" } | null>(null);
|
||||
const [showAdminToken, setShowAdminToken] = useState(false);
|
||||
const [showTempoToken, setShowTempoToken] = useState(false);
|
||||
const [tempoTestResult, setTempoTestResult] = useState<string | null>(null);
|
||||
const [tempoTestError, setTempoTestError] = useState<string | null>(null);
|
||||
const [jiraTestResult, setJiraTestResult] = useState<{ connectedAs: string; url: string } | null>(null);
|
||||
const [jiraTestError, setJiraTestError] = useState<string | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [tempoTestResult, setTempoTestResult] = useState<string | null>(null);
|
||||
const [tempoTestError, setTempoTestError] = useState<string | null>(null);
|
||||
|
||||
// Initialise editable fields from server on first successful load
|
||||
useEffect(() => {
|
||||
if (me) {
|
||||
setJiraAccountId(me.jira_account_id ?? "");
|
||||
setJiraEmail(me.jira_email ?? "");
|
||||
// Never pre-fill the token — we only know whether it is set, not its value
|
||||
}
|
||||
// Only run when `me` transitions from undefined → data (i.e., first load)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [!!me]);
|
||||
const { data: cfg, isLoading } = useQuery({
|
||||
queryKey: ["jira-config"],
|
||||
queryFn: getJiraConfig,
|
||||
});
|
||||
|
||||
const [form, setForm] = useState<JiraConfigUpdate>({});
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: updateMyPreferences,
|
||||
onSuccess: (updatedMe) => {
|
||||
// Update cache immediately with the response — no extra round-trip needed
|
||||
qc.setQueryData(["me-prefs"], updatedMe);
|
||||
setDirty(false);
|
||||
setJiraApiToken(""); // clear token fields after save — they're persisted
|
||||
setTempoApiToken("");
|
||||
setToast({ msg: "Profile settings saved", type: "success" });
|
||||
mutationFn: updateJiraConfig,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["jira-config"] });
|
||||
setForm({});
|
||||
setToast({ msg: "Jira configuration saved", type: "success" });
|
||||
},
|
||||
onError: () => setToast({ msg: "Failed to save", type: "error" }),
|
||||
onError: () => setToast({ msg: "Failed to save Jira configuration", type: "error" }),
|
||||
});
|
||||
|
||||
const jiraTestMut = useMutation({
|
||||
@@ -907,322 +958,6 @@ function ProfileSection() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
const payload: Parameters<typeof updateMyPreferences>[0] = {
|
||||
jira_account_id: jiraAccountId || null,
|
||||
jira_email: jiraEmail || null,
|
||||
};
|
||||
// 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);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{toast && (
|
||||
<Toast message={toast.msg} type={toast.type} onClose={() => setToast(null)} />
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Username</p>
|
||||
<p className="text-gray-300 font-medium">{me?.username}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Role</p>
|
||||
<p className="text-gray-300 font-medium capitalize">{me?.role?.replace("_", " ")}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Email</p>
|
||||
<p className="text-gray-300">{me?.email ?? "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Last Login</p>
|
||||
<p className="text-gray-300">
|
||||
{me?.last_login
|
||||
? new Date(me.last_login).toLocaleString("en-US", {
|
||||
timeZone: "UTC",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}) + " UTC"
|
||||
: "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showJiraTempoSettings && <div className="border-t border-gray-800 pt-4">
|
||||
<p className="text-sm font-semibold text-gray-300 mb-1">Jira Integration (personal settings)</p>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Configure your personal Atlassian credentials for Jira integration.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Jira email */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-cyan-400">
|
||||
Atlassian / Jira Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={jiraEmail}
|
||||
onChange={(e) => { setJiraEmail(e.target.value); setDirty(true); }}
|
||||
placeholder={me?.email ?? "your@company.com"}
|
||||
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:border-cyan-500 focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-600">
|
||||
Email used to authenticate with Atlassian.
|
||||
{me?.email && !me?.jira_email && (
|
||||
<span className="text-gray-500"> Currently using Aegis email: <span className="text-gray-400">{me.email}</span></span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* API Token */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-cyan-400">
|
||||
Jira API Token (Atlassian personal token)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showToken ? "text" : "password"}
|
||||
value={jiraApiToken}
|
||||
onChange={(e) => {
|
||||
setJiraApiToken(e.target.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
placeholder={me?.jira_token_set ? "Leave blank to keep current token" : "Paste your Atlassian 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-cyan-500 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
|
||||
>
|
||||
{showToken ? <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?.jira_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>
|
||||
)}
|
||||
<a
|
||||
href="https://id.atlassian.com/manage-profile/security/api-tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[11px] text-cyan-500 hover:text-cyan-400 underline"
|
||||
>
|
||||
Create token at id.atlassian.com →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Account ID */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-cyan-400">
|
||||
Jira Account ID (required for Tempo time tracking)
|
||||
</label>
|
||||
<input
|
||||
value={jiraAccountId}
|
||||
onChange={(e) => {
|
||||
setJiraAccountId(e.target.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
placeholder="e.g. 5abcdef1234567890abcdef1"
|
||||
className="w-full max-w-sm rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:border-cyan-500 focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-600">
|
||||
Your Atlassian account ID. Found in your Jira profile URL.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test Jira connection */}
|
||||
<div className="mt-4 rounded-lg border border-gray-700 bg-gray-800/50 p-3 space-y-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setJiraTestResult(null);
|
||||
setJiraTestError(null);
|
||||
jiraTestMut.mutate();
|
||||
}}
|
||||
disabled={jiraTestMut.isPending || !me?.jira_token_set}
|
||||
className="flex items-center gap-2 rounded-lg border border-cyan-700 px-3 py-1.5 text-sm font-medium text-cyan-400 hover:bg-cyan-900/30 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{jiraTestMut.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<TestTube className="h-4 w-4" />
|
||||
)}
|
||||
Test Jira Connection
|
||||
</button>
|
||||
{jiraTestResult && (
|
||||
<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" />
|
||||
Connected as: {jiraTestResult.connectedAs}
|
||||
</div>
|
||||
)}
|
||||
{jiraTestError && (
|
||||
<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" />
|
||||
{jiraTestError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* ── Tempo Integration ─────────────────────────────────── */}
|
||||
{showJiraTempoSettings && <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
|
||||
onClick={handleSave}
|
||||
disabled={saveMut.isPending || !dirty}
|
||||
className="flex items-center gap-2 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saveMut.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Jira Admin Config Section (admin only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function JiraConfigSection() {
|
||||
const qc = useQueryClient();
|
||||
const [toast, setToast] = useState<{ msg: string; type: "success" | "error" } | null>(null);
|
||||
|
||||
const { data: cfg, isLoading } = useQuery({
|
||||
queryKey: ["jira-config"],
|
||||
queryFn: getJiraConfig,
|
||||
});
|
||||
|
||||
const [form, setForm] = useState<JiraConfigUpdate>({});
|
||||
const effective = { ...cfg, ...form };
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: updateJiraConfig,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["jira-config"] });
|
||||
setForm({});
|
||||
setToast({ msg: "Jira configuration saved", type: "success" });
|
||||
},
|
||||
onError: () => setToast({ msg: "Failed to save Jira configuration", type: "error" }),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-8">
|
||||
@@ -1278,9 +1013,7 @@ function JiraConfigSection() {
|
||||
placeholder="OFS-20795"
|
||||
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:border-cyan-500 focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-600">
|
||||
Campaigns are created directly under this issue
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-600">Campaigns are created directly under this issue</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -1293,12 +1026,110 @@ function JiraConfigSection() {
|
||||
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:border-cyan-500 focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-600">
|
||||
Standalone tests (not in a campaign) are nested under this issue. Falls back to Campaign Parent if not set.
|
||||
Standalone tests are nested here. Falls back to Campaign Parent if not set.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center pt-2">
|
||||
{/* ── Admin account (replaces per-user tokens) ───────────── */}
|
||||
<div className="mt-2 border-t border-gray-800 pt-4">
|
||||
<p className="text-sm font-semibold text-gray-300 mb-1">Admin Jira Account</p>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
One admin account handles all Jira and Tempo operations. Regular users need zero configuration — their Atlassian account ID is auto-detected on login.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-cyan-400">Admin Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={String(form.admin_email !== undefined ? form.admin_email : "")}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, admin_email: e.target.value }))}
|
||||
placeholder="jira-admin@company.com"
|
||||
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:border-cyan-500 focus:outline-none"
|
||||
/>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
{cfg?.admin_email_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" /> 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-600">Leave blank to keep current</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-cyan-400">Admin Jira API Token</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showAdminToken ? "text" : "password"}
|
||||
value={String(form.admin_api_token !== undefined ? form.admin_api_token : "")}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, admin_api_token: e.target.value }))}
|
||||
placeholder={cfg?.admin_email_set ? "Leave blank to keep current token" : "Paste Atlassian 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-cyan-500 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdminToken(!showAdminToken)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
|
||||
>
|
||||
{showAdminToken ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-gray-600">
|
||||
Create at{" "}
|
||||
<a
|
||||
href="https://id.atlassian.com/manage-profile/security/api-tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-cyan-500 hover:text-cyan-400 underline"
|
||||
>
|
||||
id.atlassian.com →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-purple-400">Admin Tempo API Token</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showTempoToken ? "text" : "password"}
|
||||
value={String(form.tempo_admin_token !== undefined ? form.tempo_admin_token : "")}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, tempo_admin_token: e.target.value }))}
|
||||
placeholder={cfg?.tempo_admin_token_set ? "Leave blank to keep current token" : "Paste 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 flex items-center gap-2">
|
||||
{cfg?.tempo_admin_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" /> 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-600">
|
||||
Jira → Apps → Tempo → Settings → API Integration
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (Object.keys(form).length > 0) saveMut.mutate(form);
|
||||
@@ -1315,6 +1146,64 @@ function JiraConfigSection() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Test connections ───────────────────────────────────── */}
|
||||
<div className="grid grid-cols-2 gap-3 pt-1">
|
||||
<div className="rounded-lg border border-gray-700 bg-gray-800/50 p-3 space-y-2">
|
||||
<p className="text-xs font-medium text-gray-400">Test Jira connection</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setJiraTestResult(null);
|
||||
setJiraTestError(null);
|
||||
jiraTestMut.mutate();
|
||||
}}
|
||||
disabled={jiraTestMut.isPending || !cfg?.admin_email_set}
|
||||
className="flex items-center gap-2 rounded-lg border border-cyan-700 px-3 py-1.5 text-sm font-medium text-cyan-400 hover:bg-cyan-900/30 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{jiraTestMut.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <TestTube className="h-4 w-4" />}
|
||||
Test Jira
|
||||
</button>
|
||||
{jiraTestResult && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-emerald-900/30 border border-emerald-800/50 px-3 py-2 text-xs text-emerald-300">
|
||||
<CheckCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
Connected as: {jiraTestResult.connectedAs}
|
||||
</div>
|
||||
)}
|
||||
{jiraTestError && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-red-900/30 border border-red-800/50 px-3 py-2 text-xs text-red-300">
|
||||
<XCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
{jiraTestError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-700 bg-gray-800/50 p-3 space-y-2">
|
||||
<p className="text-xs font-medium text-gray-400">Test Tempo connection</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setTempoTestResult(null);
|
||||
setTempoTestError(null);
|
||||
tempoTestMut.mutate();
|
||||
}}
|
||||
disabled={tempoTestMut.isPending || !cfg?.tempo_admin_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
|
||||
</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-xs text-emerald-300">
|
||||
<CheckCircle className="h-3.5 w-3.5 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-xs text-red-300">
|
||||
<XCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
{tempoTestError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user