Changeset 505f39a for lan-frontend/src/components/TopBar.jsx
- Timestamp:
- 01/21/26 13:35:44 (6 months ago)
- Branches:
- master
- Children:
- d42aac3
- Parents:
- 2058e5c
- File:
-
- 1 edited
-
lan-frontend/src/components/TopBar.jsx (modified) (12 diffs)
Legend:
- Unmodified
- Added
- Removed
-
lan-frontend/src/components/TopBar.jsx
r2058e5c r505f39a 1 1 import React, { useEffect, useMemo, useState } from "react"; 2 3 export default function TopBar({ onOpenAI, onRefresh, lastUpdated, onEnvChange }) { 2 import logo from "../assets/ChatGPT_Image_Jan_21__2026__01_20_04_PM-removebg-preview (1).png"; 3 4 export default function TopBar({ 5 onOpenAI, 6 onRefresh, 7 lastUpdated, 8 onEnvChange, 9 selectedEnv: selectedEnvProp, // optional (ако сакаш да го контролира App) 10 me, 11 onLoggedOut, // optional callback (App loadMe) 12 }) { 4 13 const [open, setOpen] = useState(false); 5 6 // admin auth7 const [adminKey, setAdminKey] = useState("");8 const [adminSession, setAdminSession] = useState("");9 14 10 15 // env + tokens 11 16 const [envs, setEnvs] = useState(["default"]); 12 const [selectedEnv, setSelectedEnv] = useState( "default");17 const [selectedEnv, setSelectedEnv] = useState(selectedEnvProp || "default"); 13 18 const [newEnvName, setNewEnvName] = useState(""); 14 19 const [token, setToken] = useState(""); … … 17 22 const [error, setError] = useState(""); 18 23 19 // helper: fetch env list after login 20 async function loadEnvironments(session) { 24 25 // keep in sync ако App праќа selectedEnv prop 26 useEffect(() => { 27 if (selectedEnvProp && selectedEnvProp !== selectedEnv) { 28 setSelectedEnv(selectedEnvProp); 29 } 30 // eslint-disable-next-line react-hooks/exhaustive-deps 31 }, [selectedEnvProp]); 32 33 async function loadEnvironments() { 21 34 const r = await fetch("/api/admin/environments", { 22 headers: { "X-Admin-Session": session },35 credentials: "include", 23 36 }); 24 37 const data = await r.json(); … … 28 41 setEnvs(list); 29 42 30 // ако тековниот не постои, стави првиот од листата31 43 if (!list.includes(selectedEnv)) { 32 44 setSelectedEnv(list[0]); … … 35 47 } 36 48 37 async function login() {38 setError("");39 setToken("");40 41 if (!adminKey.trim()) {42 setError("Внеси admin key.");43 return;44 }45 46 setBusy(true);47 try {48 const r = await fetch("/api/admin/login", {49 method: "POST",50 headers: { "Content-Type": "application/json" },51 body: JSON.stringify({ admin_key: adminKey.trim() }),52 });53 const data = await r.json();54 if (!r.ok) throw new Error(data?.error || `HTTP ${r.status}`);55 56 const session = data.session;57 setAdminSession(session);58 await loadEnvironments(session);59 } catch (e) {60 setError(String(e.message || e));61 } finally {62 setBusy(false);63 }64 }65 66 function logout() {67 setAdminSession("");68 setAdminKey("");69 setToken("");70 setError("");71 setEnvs(["default"]);72 setSelectedEnv("default");73 onEnvChange?.("default");74 }75 76 49 async function createEnvironment() { 77 50 setError(""); 78 51 setToken(""); 52 79 53 if (!newEnvName.trim()) { 80 54 setError("Внеси име за environment."); … … 86 60 const r = await fetch("/api/admin/environments", { 87 61 method: "POST", 88 headers: { 89 "Content-Type": "application/json", 90 "X-Admin-Session": adminSession, 91 }, 62 headers: { "Content-Type": "application/json" }, 63 credentials: "include", 92 64 body: JSON.stringify({ name: newEnvName.trim() }), 93 65 }); … … 96 68 if (!r.ok) throw new Error(data?.error || `HTTP ${r.status}`); 97 69 98 // reload list, select new env 99 await loadEnvironments(adminSession); 70 await loadEnvironments(); 100 71 setSelectedEnv(newEnvName.trim()); 101 72 onEnvChange?.(newEnvName.trim()); … … 120 91 const r = await fetch("/api/admin/tokens", { 121 92 method: "POST", 122 headers: { 123 "Content-Type": "application/json", 124 "X-Admin-Session": adminSession, 125 }, 93 headers: { "Content-Type": "application/json" }, 94 credentials: "include", 126 95 body: JSON.stringify({ env: selectedEnv }), 127 96 }); … … 147 116 setToken(""); 148 117 onEnvChange?.(val); 118 } 119 120 async function logout() { 121 try { 122 await fetch("/api/auth/logout", { method: "POST", credentials: "include" }); 123 } catch {} 124 onLoggedOut?.(); 125 // reset local admin modal state 126 setOpen(false); 127 setToken(""); 128 setError(""); 149 129 } 150 130 … … 161 141 <> 162 142 <style>{` 163 /* ---- TopBar scoped theme ---- */ 164 .tb-wrap{ 165 position: sticky; 166 top: 0; 167 z-index: 20; 168 backdrop-filter: blur(10px); 143 .tb-wrap{ position: sticky; top: 0; z-index: 20; backdrop-filter: blur(10px); 169 144 background: linear-gradient(180deg, rgba(10,16,32,0.92), rgba(10,16,32,0.75)); 170 border-bottom: 1px solid rgba(96,165,250,0.18); 171 } 172 .tb-inner{ 173 max-width: 1200px; 174 margin: 0 auto; 175 padding: 14px 18px; 176 display: flex; 177 align-items: center; 178 justify-content: space-between; 179 gap: 14px; 180 } 181 182 .tb-brand{ 183 display:flex; 184 align-items:center; 185 gap: 12px; 186 min-width: 260px; 187 } 188 .tb-badge{ 189 width: 44px; 190 height: 44px; 191 border-radius: 14px; 192 display:flex; 193 align-items:center; 194 justify-content:center; 195 background: 196 radial-gradient(120px 80px at 30% 20%, rgba(96,165,250,0.55), transparent 60%), 197 linear-gradient(135deg, rgba(30,64,175,0.75), rgba(15,23,42,0.85)); 198 border: 1px solid rgba(96,165,250,0.25); 199 box-shadow: 0 10px 25px rgba(0,0,0,0.25); 200 } 201 .tb-title{ 202 margin:0; 203 font-size: 16px; 204 font-weight: 900; 205 letter-spacing: 0.4px; 206 color: rgba(255,255,255,0.95); 207 line-height: 1.2; 208 } 209 .tb-sub{ 210 margin: 2px 0 0 0; 211 font-size: 12px; 212 color: rgba(191,219,254,0.85); 213 } 214 .tb-sub span{ 215 color: rgba(96,165,250,0.95); 216 font-weight: 800; 217 } 218 219 .tb-actions{ 220 display:flex; 221 align-items:center; 222 gap: 10px; 223 flex-wrap: wrap; 224 justify-content: flex-end; 225 } 226 227 .tb-pill{ 228 display:inline-flex; 229 align-items:center; 230 gap: 8px; 231 padding: 8px 12px; 232 border-radius: 999px; 233 background: rgba(30,41,59,0.65); 234 border: 1px solid rgba(96,165,250,0.22); 235 color: rgba(255,255,255,0.9); 236 font-weight: 800; 237 font-size: 12px; 238 box-shadow: inset 0 1px 0 rgba(255,255,255,0.06); 239 } 240 .tb-pill .dot{ 241 width: 8px; 242 height: 8px; 243 border-radius: 999px; 244 background: rgba(96,165,250,0.95); 245 box-shadow: 0 0 0 3px rgba(96,165,250,0.15); 246 } 247 248 .tb-btn{ 249 appearance: none; 250 border: 1px solid rgba(96,165,250,0.22); 251 background: rgba(15,23,42,0.55); 252 color: rgba(255,255,255,0.9); 253 padding: 9px 12px; 254 border-radius: 12px; 255 font-weight: 800; 256 font-size: 13px; 257 cursor: pointer; 258 transition: transform .12s ease, background .12s ease, border-color .12s ease, opacity .12s ease; 259 box-shadow: 0 8px 20px rgba(0,0,0,0.18); 260 } 261 .tb-btn:hover{ 262 transform: translateY(-1px); 263 background: rgba(30,41,59,0.65); 264 border-color: rgba(96,165,250,0.38); 265 } 266 .tb-btn:disabled{ 267 opacity: .6; 268 cursor: not-allowed; 269 transform: none; 270 } 271 .tb-btn.primary{ 272 background: linear-gradient(135deg, rgba(37,99,235,0.95), rgba(56,189,248,0.7)); 273 border-color: rgba(147,197,253,0.35); 274 color: rgba(5,12,24,0.95); 275 } 276 .tb-btn.primary:hover{ 277 background: linear-gradient(135deg, rgba(59,130,246,0.95), rgba(34,211,238,0.75)); 278 } 279 280 /* ---- Modal ---- */ 281 .tb-backdrop{ 282 position: fixed; 283 inset: 0; 284 background: rgba(2,6,23,0.72); 285 backdrop-filter: blur(6px); 286 display:flex; 287 align-items: center; 288 justify-content: center; 289 padding: 20px; 290 z-index: 50; 291 } 292 .tb-modal{ 293 width: min(860px, 96vw); 294 border-radius: 18px; 295 background: 296 radial-gradient(900px 420px at 20% 0%, rgba(96,165,250,0.18), transparent 60%), 297 radial-gradient(700px 350px at 80% 10%, rgba(34,211,238,0.10), transparent 55%), 298 rgba(10,16,32,0.92); 299 border: 1px solid rgba(96,165,250,0.22); 300 box-shadow: 0 30px 80px rgba(0,0,0,0.45); 301 overflow: hidden; 302 } 303 .tb-modal-head{ 304 padding: 16px 18px; 305 display:flex; 306 align-items: center; 307 justify-content: space-between; 308 gap: 10px; 309 border-bottom: 1px solid rgba(96,165,250,0.18); 310 } 311 .tb-modal-title{ 312 color: rgba(255,255,255,0.96); 313 font-weight: 900; 314 letter-spacing: .3px; 315 } 316 .tb-modal-sub{ 317 color: rgba(191,219,254,0.8); 318 font-size: 12px; 319 margin-top: 2px; 320 } 321 .tb-grid{ 322 padding: 16px; 323 display:grid; 324 gap: 12px; 325 } 326 .tb-card{ 327 border-radius: 16px; 328 background: rgba(15,23,42,0.55); 329 border: 1px solid rgba(96,165,250,0.18); 330 padding: 14px; 331 } 332 .tb-card-head{ 333 display:flex; 334 align-items:center; 335 justify-content: space-between; 336 margin-bottom: 10px; 337 } 338 .tb-card-title{ 339 color: rgba(255,255,255,0.92); 340 font-weight: 900; 341 } 342 .tb-help{ 343 color: rgba(148,163,184,0.95); 344 font-size: 12px; 345 margin-top: 8px; 346 line-height: 1.35; 347 } 348 .tb-input, .tb-select{ 349 width: 100%; 350 padding: 10px 12px; 351 border-radius: 12px; 352 border: 1px solid rgba(96,165,250,0.18); 353 background: rgba(2,6,23,0.35); 354 color: rgba(255,255,255,0.92); 355 outline: none; 356 font-weight: 700; 357 } 145 border-bottom: 1px solid rgba(96,165,250,0.18); } 146 .tb-inner{ max-width: 1200px; margin: 0 auto; padding: 14px 18px; 147 display: flex; align-items: center; justify-content: space-between; gap: 14px; } 148 .tb-brand{ display:flex; align-items:center; gap: 12px; min-width: 260px; } 149 .tb-badge{ width: 44px; height: 44px; border-radius: 14px; display:flex; align-items:center; justify-content:center; 150 background: radial-gradient(120px 80px at 30% 20%, rgba(96,165,250,0.55), transparent 60%), 151 linear-gradient(135deg, rgba(30,64,175,0.75), rgba(15,23,42,0.85)); 152 border: 1px solid rgba(96,165,250,0.25); box-shadow: 0 10px 25px rgba(0,0,0,0.25); } 153 .tb-title{ margin:0; font-size: 16px; font-weight: 900; letter-spacing: 0.4px; color: rgba(255,255,255,0.95); line-height: 1.2; } 154 .tb-sub{ margin: 2px 0 0 0; font-size: 12px; color: rgba(191,219,254,0.85); } 155 .tb-sub span{ color: rgba(96,165,250,0.95); font-weight: 800; } 156 .tb-actions{ display:flex; align-items:center; gap: 10px; flex-wrap: wrap; justify-content: flex-end; } 157 .tb-pill{ display:inline-flex; align-items:center; gap: 8px; padding: 8px 12px; border-radius: 999px; 158 background: rgba(30,41,59,0.65); border: 1px solid rgba(96,165,250,0.22); 159 color: rgba(255,255,255,0.9); font-weight: 800; font-size: 12px; box-shadow: inset 0 1px 0 rgba(255,255,255,0.06); } 160 .tb-pill .dot{ width: 8px; height: 8px; border-radius: 999px; background: rgba(96,165,250,0.95); 161 box-shadow: 0 0 0 3px rgba(96,165,250,0.15); } 162 .tb-btn{ appearance: none; border: 1px solid rgba(96,165,250,0.22); background: rgba(15,23,42,0.55); 163 color: rgba(255,255,255,0.9); padding: 9px 12px; border-radius: 12px; font-weight: 800; 164 font-size: 13px; cursor: pointer; transition: transform .12s ease, background .12s ease, border-color .12s ease, opacity .12s ease; 165 box-shadow: 0 8px 20px rgba(0,0,0,0.18); } 166 .tb-btn:hover{ transform: translateY(-1px); background: rgba(30,41,59,0.65); border-color: rgba(96,165,250,0.38); } 167 .tb-btn:disabled{ opacity: .6; cursor: not-allowed; transform: none; } 168 .tb-btn.primary{ background: linear-gradient(135deg, rgba(37,99,235,0.95), rgba(56,189,248,0.7)); 169 border-color: rgba(147,197,253,0.35); color: rgba(5,12,24,0.95); } 170 .tb-btn.primary:hover{ background: linear-gradient(135deg, rgba(59,130,246,0.95), rgba(34,211,238,0.75)); } 171 172 .tb-backdrop{ position: fixed; inset: 0; background: rgba(2,6,23,0.72); backdrop-filter: blur(6px); 173 display:flex; align-items: center; justify-content: center; padding: 20px; z-index: 50; } 174 .tb-modal{ width: min(860px, 96vw); border-radius: 18px; 175 background: radial-gradient(900px 420px at 20% 0%, rgba(96,165,250,0.18), transparent 60%), 176 radial-gradient(700px 350px at 80% 10%, rgba(34,211,238,0.10), transparent 55%), 177 rgba(10,16,32,0.92); 178 border: 1px solid rgba(96,165,250,0.22); box-shadow: 0 30px 80px rgba(0,0,0,0.45); overflow: hidden; } 179 .tb-modal-head{ padding: 16px 18px; display:flex; align-items: center; justify-content: space-between; gap: 10px; 180 border-bottom: 1px solid rgba(96,165,250,0.18); } 181 .tb-modal-title{ color: rgba(255,255,255,0.96); font-weight: 900; letter-spacing: .3px; } 182 .tb-modal-sub{ color: rgba(191,219,254,0.8); font-size: 12px; margin-top: 2px; } 183 .tb-grid{ padding: 16px; display:grid; gap: 12px; } 184 .tb-card{ border-radius: 16px; background: rgba(15,23,42,0.55); border: 1px solid rgba(96,165,250,0.18); padding: 14px; } 185 .tb-card-head{ display:flex; align-items:center; justify-content: space-between; margin-bottom: 10px; gap: 10px; } 186 .tb-card-title{ color: rgba(255,255,255,0.92); font-weight: 900; } 187 .tb-help{ color: rgba(148,163,184,0.95); font-size: 12px; margin-top: 8px; line-height: 1.35; } 188 .tb-input, .tb-select{ width: 100%; padding: 10px 12px; border-radius: 12px; 189 border: 1px solid rgba(96,165,250,0.18); background: rgba(2,6,23,0.35); color: rgba(255,255,255,0.92); 190 outline: none; font-weight: 700; } 358 191 .tb-input::placeholder{ color: rgba(148,163,184,0.75); } 359 .tb-row{ 360 display:grid; 361 gap: 10px; 362 } 363 .tb-token{ 364 display:flex; 365 align-items:center; 366 justify-content: space-between; 367 gap: 10px; 368 padding: 10px 12px; 369 border-radius: 14px; 370 background: rgba(2,6,23,0.38); 371 border: 1px dashed rgba(147,197,253,0.32); 372 color: rgba(255,255,255,0.92); 373 } 374 .tb-mono{ 375 font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 376 font-weight: 800; 377 color: rgba(191,219,254,0.95); 378 } 379 .tb-error{ 380 border-color: rgba(239,68,68,0.45) !important; 381 background: rgba(127,29,29,0.15) !important; 382 color: rgba(254,202,202,0.95); 383 } 192 .tb-row{ display:grid; gap: 10px; } 193 .tb-token{ display:flex; align-items:center; justify-content: space-between; gap: 10px; padding: 10px 12px; 194 border-radius: 14px; background: rgba(2,6,23,0.38); border: 1px dashed rgba(147,197,253,0.32); 195 color: rgba(255,255,255,0.92); } 196 .tb-mono{ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 197 font-weight: 800; color: rgba(191,219,254,0.95); } 198 .tb-error{ border-color: rgba(239,68,68,0.45) !important; background: rgba(127,29,29,0.15) !important; 199 color: rgba(254,202,202,0.95); } 384 200 `}</style> 385 201 386 {/* TOPBAR */}387 202 <div className="tb-wrap"> 388 203 <div className="tb-inner"> … … 394 209 Sysmon Edition • <span>{prettyTime}</span> 395 210 </p> 211 {me?.email && ( 212 <p className="tb-sub" style={{ marginTop: 4, opacity: 0.9 }}> 213 Signed in as <span>{me.email}</span> 214 {me.role ? ` • ${me.role}` : ""} 215 </p> 216 )} 396 217 </div> 397 218 </div> 219 <div> 220 <img 221 src={logo} 222 alt="NETIntel logo" 223 className="netintel-logo" 224 width={150} 225 style={{marginTop: 10 }} 226 /> 227 </div> 228 229 398 230 399 231 <div className="tb-actions"> … … 402 234 Env: {selectedEnv} 403 235 </span> 404 <button className="tb-btn" onClick={onRefresh} disabled={busy}>Refresh</button> 405 <button className="tb-btn" onClick={() => setOpen(true)}>Admin</button> 406 <button className="tb-btn primary" onClick={onOpenAI}>AI Assistant</button> 236 <button className="tb-btn" onClick={onRefresh} disabled={busy}> 237 Refresh 238 </button> 239 <button 240 className="tb-btn" 241 onClick={async () => { 242 setOpen(true); 243 setError(""); 244 setToken(""); 245 try { 246 await loadEnvironments(); 247 } catch (e) { 248 setError(String(e.message || e)); 249 } 250 }} 251 > 252 Admin 253 </button> 254 <button className="tb-btn primary" onClick={onOpenAI}> 255 AI Assistant 256 </button> 407 257 </div> 408 258 </div> 409 259 </div> 410 260 411 {/* MODAL */}412 261 {open && ( 413 262 <div className="tb-backdrop" onMouseDown={() => setOpen(false)}> … … 415 264 <div className="tb-modal-head"> 416 265 <div> 417 <div className="tb-modal-title">Admin Panel</div> 418 <div className="tb-modal-sub">Login ➜ Environments ➜ Generate token</div> 419 </div> 420 <button className="tb-btn" onClick={() => setOpen(false)}>Close</button> 266 <div className="tb-modal-title">Tenant Admin</div> 267 <div className="tb-modal-sub"> 268 Environments ➜ Generate token (clients send X-Env-Token to /receive) 269 </div> 270 </div> 271 <div style={{ display: "flex", gap: 10 }}> 272 <button className="tb-btn" onClick={logout}> 273 Logout 274 </button> 275 <button className="tb-btn" onClick={() => setOpen(false)}> 276 Close 277 </button> 278 </div> 421 279 </div> 422 280 423 281 <div className="tb-grid"> 424 {/* 1) LOGIN */} 425 {!adminSession ? ( 426 <div className="tb-card"> 427 <div className="tb-card-head"> 428 <div className="tb-card-title">Step 1: Admin login</div> 282 <div className="tb-card"> 283 <div className="tb-card-head"> 284 <div className="tb-card-title">Choose environment</div> 285 </div> 286 <div className="tb-row"> 287 <select 288 className="tb-select" 289 value={selectedEnv} 290 onChange={(e) => changeEnv(e.target.value)} 291 > 292 {envs.map((e) => ( 293 <option key={e} value={e}> 294 {e} 295 </option> 296 ))} 297 </select> 298 <div className="tb-help"> 299 Овој env ќе се користи за dashboard филтрирање + token generation. 429 300 </div> 430 431 <div className="tb-row"> 432 <input 433 className="tb-input" 434 type="password" 435 value={adminKey} 436 onChange={(e) => setAdminKey(e.target.value)} 437 placeholder="Enter admin key" 438 /> 439 <button className="tb-btn primary" onClick={login} disabled={busy}> 440 {busy ? "Logging in…" : "Login"} 441 </button> 442 <div className="tb-help"> 443 Admin key е само за логирање. После тоа користиме <b>session token</b>. 444 </div> 445 </div> 446 </div> 447 ) : ( 448 <> 449 {/* 2) ENV SELECT */} 450 <div className="tb-card"> 451 <div className="tb-card-head"> 452 <div className="tb-card-title">Step 2: Choose environment</div> 453 <button className="tb-btn" onClick={logout}>Logout</button> 454 </div> 455 456 <div className="tb-row"> 457 <select className="tb-select" value={selectedEnv} onChange={(e) => changeEnv(e.target.value)}> 458 {envs.map((e) => ( 459 <option key={e} value={e}>{e}</option> 460 ))} 461 </select> 301 </div> 302 </div> 303 304 <div className="tb-card"> 305 <div className="tb-card-head"> 306 <div className="tb-card-title">Create new environment</div> 307 </div> 308 <div className="tb-row"> 309 <input 310 className="tb-input" 311 value={newEnvName} 312 onChange={(e) => setNewEnvName(e.target.value)} 313 placeholder="e.g. school-lab / staging" 314 /> 315 <button className="tb-btn" onClick={createEnvironment} disabled={busy}> 316 {busy ? "Creating…" : "Create"} 317 </button> 318 </div> 319 </div> 320 321 <div className="tb-card"> 322 <div className="tb-card-head"> 323 <div className="tb-card-title">Generate token (for clients)</div> 324 </div> 325 326 <div className="tb-row"> 327 <button className="tb-btn primary" onClick={generateToken} disabled={busy}> 328 {busy ? "Generating…" : `Generate token for "${selectedEnv}"`} 329 </button> 330 331 {token && ( 332 <> 333 <div className="tb-token"> 334 <div> 335 <div style={{ fontWeight: 900, color: "rgba(255,255,255,0.92)" }}> 336 Token 337 </div> 338 <div className="tb-mono"> 339 {token.slice(0, 12)}…{token.slice(-10)} 340 </div> 341 </div> 342 <button className="tb-btn" onClick={copyToken}> 343 Copy 344 </button> 345 </div> 346 462 347 <div className="tb-help"> 463 Овој env ќе се користи за dashboard филтрирање + token generation.348 Клиентот мора да праќа header: <b>X-Env-Token</b> со овој token на <b>/receive</b>. 464 349 </div> 465 </div> 466 </div> 467 468 {/* 3) CREATE ENV */} 469 <div className="tb-card"> 470 <div className="tb-card-head"> 471 <div className="tb-card-title">Create new environment</div> 472 </div> 473 474 <div className="tb-row"> 475 <input 476 className="tb-input" 477 value={newEnvName} 478 onChange={(e) => setNewEnvName(e.target.value)} 479 placeholder="e.g. school-lab / staging" 480 /> 481 <button className="tb-btn" onClick={createEnvironment} disabled={busy}> 482 {busy ? "Creating…" : "Create"} 483 </button> 484 </div> 485 </div> 486 487 {/* 4) TOKEN */} 488 <div className="tb-card"> 489 <div className="tb-card-head"> 490 <div className="tb-card-title">Step 3: Generate token (for clients)</div> 491 </div> 492 493 <div className="tb-row"> 494 <button className="tb-btn primary" onClick={generateToken} disabled={busy}> 495 {busy ? "Generating…" : `Generate token for "${selectedEnv}"`} 496 </button> 497 498 {token && ( 499 <> 500 <div className="tb-token"> 501 <div> 502 <div style={{ fontWeight: 900, color: "rgba(255,255,255,0.92)" }}>Token</div> 503 <div className="tb-mono"> 504 {token.slice(0, 12)}…{token.slice(-10)} 505 </div> 506 </div> 507 <button className="tb-btn" onClick={copyToken}>Copy</button> 508 </div> 509 510 <div className="tb-help"> 511 Клиентот мора да праќа header: <b>X-Env-Token</b> со овој token на <b>/receive</b>. 512 </div> 513 </> 514 )} 515 </div> 516 </div> 517 </> 518 )} 350 </> 351 )} 352 </div> 353 </div> 519 354 520 355 {error && (
Note:
See TracChangeset
for help on using the changeset viewer.
