diff --git a/arma/client/addons/org/README.md b/arma/client/addons/org/README.md index 1c007ba..53329f1 100644 --- a/arma/client/addons/org/README.md +++ b/arma/client/addons/org/README.md @@ -1,4 +1,86 @@ forge_client_org =================== -Description for this addon +Player organization UI and client integration. + +UI Login Contract +----------------- + +The web UI sends the following request through `A3API.SendAlert`: + +```json +{ + "event": "org::login::request", + "data": { + "email": "admin@spearnet.mil", + "password": "secret" + } +} +``` + +On success, SQF should call the browser bridge with: + +```sqf +private _payload = createHashMapFromArray [ + ["session", createHashMapFromArray [ + ["actorName", name player], + ["role", "Leader"] + ]], + ["portalData", createHashMapFromArray [ + ["org", createHashMapFromArray [ + ["name", "Black Rifle Company"], + ["tag", "BRC-0160566824"], + ["type", "Private Military Company"], + ["status", "Operational"], + ["headquarters", "Georgetown Command Annex"], + ["owner", "Jacob Schmidt"] + ]], + ["funds", 482750], + ["reputation", 72], + ["members", [ + createHashMapFromArray [["name", "Jacob Schmidt"]], + createHashMapFromArray [["name", "Mara Velez"]] + ]], + ["fleet", [ + createHashMapFromArray [ + ["name", "UH-80 Ghost Hawk"], + ["type", "helicopter"], + ["status", "Ready"], + ["damage", "16%"] + ] + ]], + ["assets", [ + createHashMapFromArray [ + ["name", "First Aid Kits"], + ["type", "items"], + ["quantity", "36"] + ] + ]], + ["activity", []], + ["roadmap", []] + ]] +]; + +_control ctrlWebBrowserAction [ + "ExecJS", + format ["OrgUIBridge.receiveLoginSuccess(%1)", toJSON _payload] +]; +``` + +On failure: + +```sqf +private _payload = createHashMapFromArray [ + ["message", "Invalid credentials."] +]; + +_control ctrlWebBrowserAction [ + "ExecJS", + format ["OrgUIBridge.receiveLoginFailure(%1)", toJSON _payload] +]; +``` + +Current implementation: +- `fnc_handleUIEvents.sqf` now handles `org::login::request` +- success hydrates the portal with `session` + `portalData` +- failure returns a single `message` string for inline UI feedback diff --git a/arma/client/addons/org/functions/fnc_handleUIEvents.sqf b/arma/client/addons/org/functions/fnc_handleUIEvents.sqf index 53944c6..10a12ea 100644 --- a/arma/client/addons/org/functions/fnc_handleUIEvents.sqf +++ b/arma/client/addons/org/functions/fnc_handleUIEvents.sqf @@ -23,81 +23,174 @@ private _event = _alert get "event"; private _data = _alert get "data"; // private _display = displayChild findDisplay 46; +private _fnc_execBridge = { + params ["_control", "_fnName", "_payload"]; + + private _json = toJSON _payload; + _control ctrlWebBrowserAction ["ExecJS", format ["OrgUIBridge.%1(%2)", _fnName, _json]]; +}; + +private _fnc_buildPortalPayload = { + private _orgData = GVAR(OrgClass) get "org"; + + private _name = _orgData getOrDefault ["name", "Unknown Organization"]; + private _id = _orgData getOrDefault ["id", ""]; + private _ownerUid = _orgData getOrDefault ["owner", ""]; + private _funds = _orgData getOrDefault ["funds", 0]; + private _reputation = _orgData getOrDefault ["reputation", 0]; + private _assetsRaw = _orgData getOrDefault ["assets", createHashMap]; + private _membersRaw = _orgData getOrDefault ["members", createHashMap]; + private _fleetRaw = _orgData getOrDefault ["fleet", createHashMap]; + private _headquarters = _orgData getOrDefault ["headquarters", "ArmA Verse"]; + private _type = _orgData getOrDefault ["type", "Organization"]; + private _status = _orgData getOrDefault ["status", "Operational"]; + private _isDefaultOrg = (_orgData getOrDefault ["default", false]) + || {toLower _id isEqualTo "default"} + || {toLower _ownerUid isEqualTo "server"}; + + private _playerName = name player; + private _playerUid = getPlayerUID player; + private _playerVar = vehicleVarName player; + private _sessionRole = "Member"; + private _sessionIsCeo = _isDefaultOrg && {_playerVar isEqualTo "ceo"}; + private _ownerName = ["", "Server"] select (toLower _ownerUid isEqualTo "server"); + + private _membersList = []; + { + private _memberData = _y; + private _memberName = _memberData getOrDefault ["name", "Unknown"]; + private _memberUid = _memberData getOrDefault ["uid", ""]; + + if (_memberUid isEqualTo _ownerUid && {_ownerName isEqualTo ""}) then { + _ownerName = _memberName; + }; + + if (_memberUid isEqualTo _playerUid) then { + _sessionRole = "Member"; + }; + + _membersList pushBack (createHashMapFromArray [ + ["name", _memberName] + ]); + } forEach _membersRaw; + + if (_ownerName isEqualTo "" && {_ownerUid isEqualTo _playerUid}) then { + _ownerName = _playerName; + }; + + if (_ownerName isEqualTo "" && {_ownerUid isNotEqualTo ""}) then { + _ownerName = "Unknown Owner"; + }; + + if (_ownerUid isEqualTo _playerUid) then { + _sessionRole = "Leader"; + }; + + private _assetsList = []; + { + private _assetData = _y; + _assetsList pushBack (createHashMapFromArray [ + ["name", _assetData getOrDefault ["name", "Unknown Asset"]], + ["type", _assetData getOrDefault ["type", "items"]], + ["quantity", str (_assetData getOrDefault ["quantity", 0])] + ]); + } forEach _assetsRaw; + + private _fleetList = []; + { + private _vehicleData = _y; + _fleetList pushBack (createHashMapFromArray [ + ["name", _vehicleData getOrDefault ["name", "Unknown Vehicle"]], + ["type", _vehicleData getOrDefault ["type", "other"]], + ["status", _vehicleData getOrDefault ["status", "Unknown"]], + ["damage", _vehicleData getOrDefault ["damage", "0%"]] + ]); + } forEach _fleetRaw; + + private _roadmap = [ + createHashMapFromArray [ + ["name", "Contracts Board"], + ["status", "Planned"], + ["detail", "Track payouts, assignments, and claim approvals."] + ], + createHashMapFromArray [ + ["name", "Diplomacy"], + ["status", "Future Review"], + ["detail", "Possible future module pending a full design and scope review."] + ], + createHashMapFromArray [ + ["name", "Logistics Queue"], + ["status", "Future Review"], + ["detail", "Possible future module pending a full design and scope review."] + ], + createHashMapFromArray [ + ["name", "Permissions"], + ["status", "Future Review"], + ["detail", "Possible future module pending a full design and scope review."] + ] + ]; + + createHashMapFromArray [ + ["session", createHashMapFromArray [ + ["actorName", _playerName], + ["actorUid", _playerUid], + ["role", _sessionRole], + ["ceo", _sessionIsCeo] + ]], + ["portalData", createHashMapFromArray [ + ["org", createHashMapFromArray [ + ["name", _name], + ["tag", _id], + ["type", _type], + ["status", _status], + ["headquarters", _headquarters], + ["owner", _ownerName], + ["ownerUid", _ownerUid], + ["isDefault", _isDefaultOrg] + ]], + ["funds", _funds], + ["reputation", _reputation], + ["members", _membersList], + ["fleet", _fleetList], + ["assets", _assetsList], + ["activity", []], + ["roadmap", _roadmap] + ]] + ] +}; + diag_log format ["[FORGE:Client:Org] Handling UI event: %1 with data: %2", _event, _data]; switch (_event) do { case "org::close": { closeDialog 1; }; - case "org::ready": { + case "org::login::request": { + private _email = toLower (_data getOrDefault ["email", ""]); + private _password = _data getOrDefault ["password", ""]; private _orgData = GVAR(OrgClass) get "org"; - private _name = _orgData getOrDefault ["name", "Unknown"]; - private _id = _orgData getOrDefault ["id", ""]; - private _funds = _orgData getOrDefault ["funds", 0]; - private _reputation = _orgData getOrDefault ["reputation", 0]; - private _membersRaw = _orgData getOrDefault ["members", []]; - private _membersList = []; + private _orgId = _orgData getOrDefault ["id", ""]; + private _orgName = _orgData getOrDefault ["name", ""]; - // Handle members - private _fnc_processMember = { - params ["_mData"]; - - private _mName = _mData getOrDefault ["name", "Unknown"]; - createHashMapFromArray [ - ["name", _mName], - ["rank", "Member"], - ["online", true] - ] + if (_email isEqualTo "" || {_password isEqualTo ""}) exitWith { + [_control, "receiveLoginFailure", createHashMapFromArray [ + ["message", "Enter both email and password."] + ]] call _fnc_execBridge; }; - { - _membersList pushBack ([_y] call _fnc_processMember); - } forEach _membersRaw; - - private _totalMembers = count _membersList; - - // Handle assets - private _assetsRaw = _orgData getOrDefault ["assets", createHashMap]; - private _assetsList = []; - - private _fnc_processAsset = { - params ["_aData"]; - - private _aName = _aData getOrDefault ["name", "Unknown Asset"]; - private _aLocation = _aData getOrDefault ["location", "Unknown Location"]; - private _aIcon = _aData getOrDefault ["icon", "📦"]; - - createHashMapFromArray [ - ["name", _aName], - ["location", _aLocation], - ["icon", _aIcon] - ] + if (_orgId isEqualTo "" && {_orgName isEqualTo ""}) exitWith { + [_control, "receiveLoginFailure", createHashMapFromArray [ + ["message", "No organization data is available for this player."] + ]] call _fnc_execBridge; }; - { - _assetsList pushBack ([_y] call _fnc_processAsset); - } forEach _assetsRaw; - - // Construct HashMap payload - private _payload = createHashMapFromArray [ - ["org", createHashMapFromArray [ - ["name", _name], - ["tag", _id], - ["status", "Active"] - ]], - ["stats", createHashMapFromArray [ - ["totalMembers", _totalMembers], - ["onlineMembers", _totalMembers], - ["balance", _funds], - ["reputation", _reputation] - ]], - ["membersOnline", _membersList], - ["assets", _assetsList], - ["activities", []], - ["missions", []] - ]; - - private _json = toJSON _payload; - - _control ctrlWebBrowserAction ["ExecJS", format ["updateOrgDashboard(%1)", _json]]; + [_control, "receiveLoginSuccess", call _fnc_buildPortalPayload] call _fnc_execBridge; + }; + case "org::ready": { + [_control, "receive", createHashMapFromArray [ + ["event", "org::ready"], + ["data", createHashMapFromArray [ + ["loaded", true] + ]] + ]] call _fnc_execBridge; }; default { hint format ["Unhandled UI event: %1", _event]; }; }; diff --git a/arma/client/addons/org/ui/_site/bridge.js b/arma/client/addons/org/ui/_site/bridge.js new file mode 100644 index 0000000..34ab232 --- /dev/null +++ b/arma/client/addons/org/ui/_site/bridge.js @@ -0,0 +1,66 @@ +(function () { + const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const store = RegistryApp.store; + + function sendEvent(event, data) { + if ( + typeof A3API !== "undefined" && + typeof A3API.SendAlert === "function" + ) { + A3API.SendAlert( + JSON.stringify({ + event, + data, + }), + ); + return true; + } + + return false; + } + + function requestLogin(credentials) { + store.startLogin(); + + const sent = sendEvent("org::login::request", credentials); + if (sent) { + return; + } + + store.failLogin("Arma login bridge is unavailable."); + } + + function receive(eventOrPayload, data = {}) { + const event = + typeof eventOrPayload === "object" && eventOrPayload !== null + ? eventOrPayload.event + : eventOrPayload; + const payloadData = + typeof eventOrPayload === "object" && eventOrPayload !== null + ? eventOrPayload.data || {} + : data; + + if (event === "org::login::success") { + store.completeLogin(payloadData); + return; + } + + if (event === "org::login::failure") { + store.failLogin(payloadData.message || "Authentication failed."); + return; + } + } + + RegistryApp.bridge = { + requestLogin, + receive, + sendEvent, + }; + + window.OrgUIBridge = { + requestLogin, + receive, + receiveLoginSuccess: (data) => receive("org::login::success", data), + receiveLoginFailure: (data) => receive("org::login::failure", data), + }; +})(); diff --git a/arma/client/addons/org/ui/_site/components/createOrgForm.js b/arma/client/addons/org/ui/_site/components/createOrgForm.js index 2541103..7ca2a86 100644 --- a/arma/client/addons/org/ui/_site/components/createOrgForm.js +++ b/arma/client/addons/org/ui/_site/components/createOrgForm.js @@ -42,23 +42,119 @@ }, h( "li", - { style: { marginBottom: "0.5rem" } }, - "✅ Official Organization Designator", + { + style: { + marginBottom: "0.5rem", + display: "flex", + alignItems: "center", + gap: "0.5rem", + }, + }, + h( + "svg", + { + viewBox: "0 0 24 24", + fill: "none", + stroke: "#10b981", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + style: { + width: "1.2rem", + height: "1.2rem", + flexShrink: "0", + }, + }, + h("path", { d: "M20 6L9 17l-5-5" }), + ), + "Official Organization Designator", ), h( "li", - { style: { marginBottom: "0.5rem" } }, - "✅ Secure Comms Channel", + { + style: { + marginBottom: "0.5rem", + display: "flex", + alignItems: "center", + gap: "0.5rem", + }, + }, + h( + "svg", + { + viewBox: "0 0 24 24", + fill: "none", + stroke: "#10b981", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + style: { + width: "1.2rem", + height: "1.2rem", + flexShrink: "0", + }, + }, + h("path", { d: "M20 6L9 17l-5-5" }), + ), + "Secure Comms Channel", ), h( "li", - { style: { marginBottom: "0.5rem" } }, - "✅ Deployment Roster Access", + { + style: { + marginBottom: "0.5rem", + display: "flex", + alignItems: "center", + gap: "0.5rem", + }, + }, + h( + "svg", + { + viewBox: "0 0 24 24", + fill: "none", + stroke: "#10b981", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + style: { + width: "1.2rem", + height: "1.2rem", + flexShrink: "0", + }, + }, + h("path", { d: "M20 6L9 17l-5-5" }), + ), + "Deployment Roster Access", ), h( "li", - { style: { marginBottom: "0.5rem" } }, - "✅ After-Action Report Tools", + { + style: { + marginBottom: "0.5rem", + display: "flex", + alignItems: "center", + gap: "0.5rem", + }, + }, + h( + "svg", + { + viewBox: "0 0 24 24", + fill: "none", + stroke: "#10b981", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + style: { + width: "1.2rem", + height: "1.2rem", + flexShrink: "0", + }, + }, + h("path", { d: "M20 6L9 17l-5-5" }), + ), + "After-Action Report Tools", ), ), h( diff --git a/arma/client/addons/org/ui/_site/components/forms.css b/arma/client/addons/org/ui/_site/components/forms.css index 8364d77..1ddcc5e 100644 --- a/arma/client/addons/org/ui/_site/components/forms.css +++ b/arma/client/addons/org/ui/_site/components/forms.css @@ -65,6 +65,18 @@ } } +.form-feedback { + padding: 0.85rem 1rem; + border-radius: var(--radius); + font-size: 0.92rem; + + &.is-error { + background: #fef2f2; + border: 1px solid #fecaca; + color: #991b1b; + } +} + @media (max-width: 960px) { .split-container { grid-template-columns: 1fr; diff --git a/arma/client/addons/org/ui/_site/components/homeView.js b/arma/client/addons/org/ui/_site/components/homeView.js index 5342229..0a7bde7 100644 --- a/arma/client/addons/org/ui/_site/components/homeView.js +++ b/arma/client/addons/org/ui/_site/components/homeView.js @@ -35,23 +35,6 @@ ), h("button", { onClick: () => store.setView("login") }, "Login"), ), - h( - "div", - { className: "card", style: { gridColumn: "span 2" } }, - h("h2", null, "Organization Portal Preview"), - h( - "p", - null, - "Review the refactor direction for a player organization portal with fleet, assets, treasury, reputation, roster management, and reserved space for future modules.", - ), - h( - "button", - { - onClick: () => store.setView("portal"), - }, - "Open Portal Preview", - ), - ), ); }; })(); diff --git a/arma/client/addons/org/ui/_site/components/loginForm.js b/arma/client/addons/org/ui/_site/components/loginForm.js index 82e87a3..f27c588 100644 --- a/arma/client/addons/org/ui/_site/components/loginForm.js +++ b/arma/client/addons/org/ui/_site/components/loginForm.js @@ -6,6 +6,10 @@ RegistryApp.componentFns = RegistryApp.componentFns || {}; RegistryApp.componentFns.LoginForm = function LoginForm() { + const bridge = RegistryApp.bridge; + const isAuthenticating = store.getIsAuthenticating(); + const loginError = store.getLoginError(); + const handleLogin = () => { const data = { email: String( @@ -15,8 +19,13 @@ document.getElementById("org-login-password")?.value || "", ), }; - console.log("Login Attempt:", data); - store.setView("portal"); + + if (!bridge) { + store.failLogin("Login bridge is not available."); + return; + } + + bridge.requestLogin(data); }; return h( @@ -47,8 +56,16 @@ id: "org-login-password", type: "password", placeholder: "********", + disabled: isAuthenticating, }), ), + loginError + ? h( + "div", + { className: "form-feedback is-error" }, + loginError, + ) + : null, h( "div", { className: "form-actions" }, @@ -58,8 +75,11 @@ type: "button", style: { width: "100%" }, onClick: handleLogin, + disabled: isAuthenticating, }, - "Access Authenticator", + isAuthenticating + ? "Authenticating..." + : "Access Authenticator", ), h( "span", diff --git a/arma/client/addons/org/ui/_site/index.html b/arma/client/addons/org/ui/_site/index.html index 94cdd9d..b3e5698 100644 --- a/arma/client/addons/org/ui/_site/index.html +++ b/arma/client/addons/org/ui/_site/index.html @@ -27,6 +27,7 @@ const scriptFiles = [ "runtime.js", "state.js", + "bridge.js", "portal\\runtime.js", "portal\\data.js", "portal\\store.js", diff --git a/arma/client/addons/org/ui/_site/portal/actions.js b/arma/client/addons/org/ui/_site/portal/actions.js index ac813cd..85c3b1c 100644 --- a/arma/client/addons/org/ui/_site/portal/actions.js +++ b/arma/client/addons/org/ui/_site/portal/actions.js @@ -30,7 +30,32 @@ return type.charAt(0).toUpperCase() + type.slice(1); } + formatDisplayName(value) { + if (!value) { + return ""; + } + + return String(value) + .trim() + .split(/\s+/) + .map((part) => { + if (!part) { + return ""; + } + + return ( + part.charAt(0).toUpperCase() + + part.slice(1).toLowerCase() + ); + }) + .join(" "); + } + getAssetReadiness() { + if (portalData.fleet.length === 0) { + return null; + } + const total = portalData.fleet.reduce( (sum, unit) => sum + (100 - parseInt(unit.damage, 10)), 0, diff --git a/arma/client/addons/org/ui/_site/portal/components/overviewCard.js b/arma/client/addons/org/ui/_site/portal/components/overviewCard.js index f8aa4b3..658f895 100644 --- a/arma/client/addons/org/ui/_site/portal/components/overviewCard.js +++ b/arma/client/addons/org/ui/_site/portal/components/overviewCard.js @@ -9,6 +9,8 @@ OrgPortal.componentFns.OverviewCard = function OverviewCard() { const MetricCard = OrgPortal.componentFns.MetricCard; + const readiness = actions.getAssetReadiness(); + const headquarters = portalData.org.headquarters || "ArmA Verse"; return h( "section", @@ -38,7 +40,7 @@ { className: "org-summary" }, portalData.org.type, " operating from ", - portalData.org.headquarters, + headquarters, ". Treasury, fleet status, inventory, and roster management are surfaced here first.", ), h( @@ -55,7 +57,7 @@ h( "span", { className: "org-meta-value" }, - portalData.org.owner, + actions.formatDisplayName(portalData.org.owner), ), ), h( @@ -83,7 +85,7 @@ h( "span", { className: "org-meta-value" }, - `${actions.getAssetReadiness()}%`, + readiness === null ? "N/A" : `${readiness}%`, ), ), ), diff --git a/arma/client/addons/org/ui/_site/portal/components/treasuryCard.js b/arma/client/addons/org/ui/_site/portal/components/treasuryCard.js index a280783..b665f93 100644 --- a/arma/client/addons/org/ui/_site/portal/components/treasuryCard.js +++ b/arma/client/addons/org/ui/_site/portal/components/treasuryCard.js @@ -11,7 +11,6 @@ OrgPortal.componentFns.TreasuryCard = function TreasuryCard() { const notice = store.getTreasuryNotice(); const creditLines = store.getCreditLines(); - const noMembers = store.getMembers().length === 0; const allowTreasuryActions = permissions.canManageTreasury(); return h( @@ -56,7 +55,6 @@ { type: "button", onClick: () => actions.openModal("payroll"), - disabled: noMembers, }, "Run Payroll", ), @@ -66,7 +64,6 @@ type: "button", className: "org-secondary-btn", onClick: () => actions.openModal("transfer"), - disabled: noMembers, }, "Send Funds", ), @@ -76,7 +73,6 @@ type: "button", className: "org-secondary-btn", onClick: () => actions.openModal("credit"), - disabled: noMembers, }, "Credit Line", ), diff --git a/arma/client/addons/org/ui/_site/portal/data.js b/arma/client/addons/org/ui/_site/portal/data.js index 484aaa0..10d4ad1 100644 --- a/arma/client/addons/org/ui/_site/portal/data.js +++ b/arma/client/addons/org/ui/_site/portal/data.js @@ -1,92 +1,37 @@ (function () { const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + function cloneValue(value) { + return JSON.parse(JSON.stringify(value)); + } + + function replaceObject(target, source) { + Object.keys(target).forEach((key) => delete target[key]); + Object.assign(target, cloneValue(source)); + } + + function replaceArray(target, source) { + target.splice(0, target.length, ...cloneValue(source)); + } + OrgPortal.data = { portalData: { org: { - name: "Black Rifle Company", - tag: "BRC-0160566824", - type: "Private Military Company", + name: "", + tag: "", + type: "Organization", status: "Operational", - headquarters: "Georgetown Command Annex", - owner: "Jacob Schmidt", + headquarters: "ArmA Verse", + owner: "", + ownerUid: "", + isDefault: false, }, - funds: 482750, - reputation: 72, - members: [ - { name: "Jacob Schmidt" }, - { name: "Mara Velez" }, - { name: "Rylan Cross" }, - { name: "Noah Briggs" }, - { name: "Elena Price" }, - { name: "Isaac Rowe" }, - { name: "Talia Boone" }, - { name: "Cade Mercer" }, - ], - fleet: [ - { - name: "UH-80 Ghost Hawk", - type: "helicopter", - status: "Ready", - damage: "16%", - }, - { - name: "MH-9 Hummingbird", - type: "helicopter", - status: "Ready", - damage: "8%", - }, - { - name: "M-ATV Patrol 1", - type: "car", - status: "Fielded", - damage: "24%", - }, - { - name: "M2A1 Slammer", - type: "armor", - status: "Ready", - damage: "11%", - }, - { - name: "RHIB Patrol Boat", - type: "naval", - status: "Repairing", - damage: "32%", - }, - ], - assets: [ - { name: "First Aid Kits", type: "items", quantity: "36" }, - { name: "MX 6.5 mm Rifles", type: "weapons", quantity: "18" }, - { - name: "6.5 mm Magazines", - type: "magazines", - quantity: "120", - }, - { - name: "Carryall Backpacks", - type: "backpacks", - quantity: "24", - }, - ], - activity: [ - { - time: "08:20", - text: "Treasury cleared contractor payment for northern route escort.", - }, - { - time: "07:45", - text: "Viper Flight completed readiness checks on all rotary assets.", - }, - { - time: "07:10", - text: "New recruit Cade Mercer accepted into ground training roster.", - }, - { - time: "06:30", - text: "North Depot inventory count pushed reserve ratio above target.", - }, - ], + funds: 0, + reputation: 0, + members: [], + fleet: [], + assets: [], + activity: [], roadmap: [ { name: "Contracts Board", @@ -111,8 +56,35 @@ ], }, session: { - actorName: "Jacob Schmidt", - role: "Leader", + actorName: "", + actorUid: "", + role: "", + ceo: false, + }, + applyLoginPayload(payload) { + replaceObject(this.portalData.org, payload.portalData.org || {}); + this.portalData.funds = payload.portalData.funds || 0; + this.portalData.reputation = payload.portalData.reputation || 0; + + replaceArray( + this.portalData.members, + payload.portalData.members || [], + ); + replaceArray(this.portalData.fleet, payload.portalData.fleet || []); + replaceArray( + this.portalData.assets, + payload.portalData.assets || [], + ); + replaceArray( + this.portalData.activity, + payload.portalData.activity || [], + ); + replaceArray( + this.portalData.roadmap, + payload.portalData.roadmap || [], + ); + + replaceObject(this.session, payload.session || {}); }, }; })(); diff --git a/arma/client/addons/org/ui/_site/portal/permissions.js b/arma/client/addons/org/ui/_site/portal/permissions.js index 7de691e..ee67d4f 100644 --- a/arma/client/addons/org/ui/_site/portal/permissions.js +++ b/arma/client/addons/org/ui/_site/portal/permissions.js @@ -3,11 +3,54 @@ const { portalData, session } = OrgPortal.data; class OrgPortalPermissions { + getNormalizedRole() { + return String(session.role || "") + .trim() + .toUpperCase(); + } + + isDefaultOrg() { + return ( + portalData.org.isDefault === true || + String(portalData.org.tag || "") + .trim() + .toUpperCase() === "DEFAULT" + ); + } + + isOrgOwner() { + const ownerUid = String( + portalData.org.ownerUid || portalData.org.owner || "", + ) + .trim() + .toLowerCase(); + const actorUid = String(session.actorUid || "") + .trim() + .toLowerCase(); + + if (ownerUid && actorUid) { + return actorUid === ownerUid; + } + + return ( + String(session.actorName || "") + .trim() + .toLowerCase() === + String(portalData.org.owner || "") + .trim() + .toLowerCase() + ); + } + + isSessionCeo() { + return session.ceo === true; + } + isOrgLeaderOrCeo() { return ( - session.actorName === portalData.org.owner || - session.role === "Leader" || - session.role === "CEO" + this.isOrgOwner() || + this.getNormalizedRole() === "LEADER" || + (this.isDefaultOrg() && this.isSessionCeo()) ); } diff --git a/arma/client/addons/org/ui/_site/portal/store.js b/arma/client/addons/org/ui/_site/portal/store.js index cb882b6..e75e120 100644 --- a/arma/client/addons/org/ui/_site/portal/store.js +++ b/arma/client/addons/org/ui/_site/portal/store.js @@ -17,6 +17,15 @@ [this.getModal, this.setModal] = createSignal(null); [this.getOrgDisbanded, this.setOrgDisbanded] = createSignal(false); } + + hydrateFromPayload(payload) { + this.setFunds(payload.portalData.funds || 0); + this.setMembers([...(payload.portalData.members || [])]); + this.setCreditLines([]); + this.setTreasuryNotice({ type: "", text: "" }); + this.setModal(null); + this.setOrgDisbanded(false); + } } OrgPortal.store = new OrgPortalStore(); diff --git a/arma/client/addons/org/ui/_site/runtime.js b/arma/client/addons/org/ui/_site/runtime.js index 70f267b..522a146 100644 --- a/arma/client/addons/org/ui/_site/runtime.js +++ b/arma/client/addons/org/ui/_site/runtime.js @@ -1,15 +1,40 @@ (function () { const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const SVG_NS = "http://www.w3.org/2000/svg"; + const SVG_TAGS = new Set([ + "svg", + "path", + "circle", + "rect", + "line", + "polyline", + "polygon", + "g", + "defs", + "use", + "text", + "tspan", + "clipPath", + "mask", + ]); + function h(tag, props = {}, ...children) { - const el = document.createElement(tag); + const isSvg = SVG_TAGS.has(tag); + const el = isSvg + ? document.createElementNS(SVG_NS, tag) + : document.createElement(tag); if (props) { Object.entries(props).forEach(([key, value]) => { if (key.startsWith("on") && typeof value === "function") { el.addEventListener(key.substring(2).toLowerCase(), value); } else if (key === "className") { - el.className = value; + if (isSvg) { + el.setAttribute("class", value); + } else { + el.className = value; + } } else if (key === "style" && typeof value === "object") { Object.assign(el.style, value); } else if (typeof value === "boolean") { diff --git a/arma/client/addons/org/ui/_site/state.js b/arma/client/addons/org/ui/_site/state.js index bb52993..d293133 100644 --- a/arma/client/addons/org/ui/_site/state.js +++ b/arma/client/addons/org/ui/_site/state.js @@ -5,6 +5,37 @@ class RegistryStore { constructor() { [this.getView, this.setView] = createSignal("home"); + [this.getIsAuthenticating, this.setIsAuthenticating] = + createSignal(false); + [this.getLoginError, this.setLoginError] = createSignal(""); + } + + startLogin() { + this.setLoginError(""); + this.setIsAuthenticating(true); + } + + failLogin(message) { + this.setIsAuthenticating(false); + this.setLoginError(message || "Authentication failed."); + } + + completeLogin(payload) { + const OrgPortal = window.OrgPortal; + const portalData = payload?.portalData; + const session = payload?.session; + + if (!OrgPortal || !portalData || !session) { + this.failLogin("Login response was missing portal data."); + return; + } + + OrgPortal.data.applyLoginPayload(payload); + OrgPortal.store.hydrateFromPayload(payload); + + this.setLoginError(""); + this.setIsAuthenticating(false); + this.setView("portal"); } } diff --git a/arma/ui/apps/main/bridge.js b/arma/ui/apps/main/bridge.js new file mode 100644 index 0000000..c8ebd3b --- /dev/null +++ b/arma/ui/apps/main/bridge.js @@ -0,0 +1,81 @@ +(function () { + const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const store = RegistryApp.store; + + function sendEvent(event, data) { + if ( + typeof A3API !== "undefined" && + typeof A3API.SendAlert === "function" + ) { + A3API.SendAlert( + JSON.stringify({ + event, + data, + }), + ); + return true; + } + + return false; + } + + function getMockPayload() { + const OrgPortal = window.OrgPortal; + return { + session: JSON.parse(JSON.stringify(OrgPortal.data.session)), + portalData: JSON.parse(JSON.stringify(OrgPortal.data.portalData)), + }; + } + + function requestLogin(credentials) { + store.startLogin(); + + const sent = sendEvent("org::login::request", credentials); + if (sent) { + return; + } + + window.setTimeout(() => { + if (!credentials.email || !credentials.password) { + store.failLogin("Enter both email and password."); + return; + } + + store.completeLogin(getMockPayload()); + }, 350); + } + + function receive(eventOrPayload, data = {}) { + const event = + typeof eventOrPayload === "object" && eventOrPayload !== null + ? eventOrPayload.event + : eventOrPayload; + const payloadData = + typeof eventOrPayload === "object" && eventOrPayload !== null + ? eventOrPayload.data || {} + : data; + + if (event === "org::login::success") { + store.completeLogin(payloadData); + return; + } + + if (event === "org::login::failure") { + store.failLogin(payloadData.message || "Authentication failed."); + return; + } + } + + RegistryApp.bridge = { + requestLogin, + receive, + sendEvent, + }; + + window.OrgUIBridge = { + requestLogin, + receive, + receiveLoginSuccess: (data) => receive("org::login::success", data), + receiveLoginFailure: (data) => receive("org::login::failure", data), + }; +})(); diff --git a/arma/ui/apps/main/components/createOrgForm.js b/arma/ui/apps/main/components/createOrgForm.js index 2541103..7ca2a86 100644 --- a/arma/ui/apps/main/components/createOrgForm.js +++ b/arma/ui/apps/main/components/createOrgForm.js @@ -42,23 +42,119 @@ }, h( "li", - { style: { marginBottom: "0.5rem" } }, - "✅ Official Organization Designator", + { + style: { + marginBottom: "0.5rem", + display: "flex", + alignItems: "center", + gap: "0.5rem", + }, + }, + h( + "svg", + { + viewBox: "0 0 24 24", + fill: "none", + stroke: "#10b981", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + style: { + width: "1.2rem", + height: "1.2rem", + flexShrink: "0", + }, + }, + h("path", { d: "M20 6L9 17l-5-5" }), + ), + "Official Organization Designator", ), h( "li", - { style: { marginBottom: "0.5rem" } }, - "✅ Secure Comms Channel", + { + style: { + marginBottom: "0.5rem", + display: "flex", + alignItems: "center", + gap: "0.5rem", + }, + }, + h( + "svg", + { + viewBox: "0 0 24 24", + fill: "none", + stroke: "#10b981", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + style: { + width: "1.2rem", + height: "1.2rem", + flexShrink: "0", + }, + }, + h("path", { d: "M20 6L9 17l-5-5" }), + ), + "Secure Comms Channel", ), h( "li", - { style: { marginBottom: "0.5rem" } }, - "✅ Deployment Roster Access", + { + style: { + marginBottom: "0.5rem", + display: "flex", + alignItems: "center", + gap: "0.5rem", + }, + }, + h( + "svg", + { + viewBox: "0 0 24 24", + fill: "none", + stroke: "#10b981", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + style: { + width: "1.2rem", + height: "1.2rem", + flexShrink: "0", + }, + }, + h("path", { d: "M20 6L9 17l-5-5" }), + ), + "Deployment Roster Access", ), h( "li", - { style: { marginBottom: "0.5rem" } }, - "✅ After-Action Report Tools", + { + style: { + marginBottom: "0.5rem", + display: "flex", + alignItems: "center", + gap: "0.5rem", + }, + }, + h( + "svg", + { + viewBox: "0 0 24 24", + fill: "none", + stroke: "#10b981", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + style: { + width: "1.2rem", + height: "1.2rem", + flexShrink: "0", + }, + }, + h("path", { d: "M20 6L9 17l-5-5" }), + ), + "After-Action Report Tools", ), ), h( diff --git a/arma/ui/apps/main/components/forms.css b/arma/ui/apps/main/components/forms.css index 8364d77..1ddcc5e 100644 --- a/arma/ui/apps/main/components/forms.css +++ b/arma/ui/apps/main/components/forms.css @@ -65,6 +65,18 @@ } } +.form-feedback { + padding: 0.85rem 1rem; + border-radius: var(--radius); + font-size: 0.92rem; + + &.is-error { + background: #fef2f2; + border: 1px solid #fecaca; + color: #991b1b; + } +} + @media (max-width: 960px) { .split-container { grid-template-columns: 1fr; diff --git a/arma/ui/apps/main/components/loginForm.js b/arma/ui/apps/main/components/loginForm.js index 82e87a3..f27c588 100644 --- a/arma/ui/apps/main/components/loginForm.js +++ b/arma/ui/apps/main/components/loginForm.js @@ -6,6 +6,10 @@ RegistryApp.componentFns = RegistryApp.componentFns || {}; RegistryApp.componentFns.LoginForm = function LoginForm() { + const bridge = RegistryApp.bridge; + const isAuthenticating = store.getIsAuthenticating(); + const loginError = store.getLoginError(); + const handleLogin = () => { const data = { email: String( @@ -15,8 +19,13 @@ document.getElementById("org-login-password")?.value || "", ), }; - console.log("Login Attempt:", data); - store.setView("portal"); + + if (!bridge) { + store.failLogin("Login bridge is not available."); + return; + } + + bridge.requestLogin(data); }; return h( @@ -47,8 +56,16 @@ id: "org-login-password", type: "password", placeholder: "********", + disabled: isAuthenticating, }), ), + loginError + ? h( + "div", + { className: "form-feedback is-error" }, + loginError, + ) + : null, h( "div", { className: "form-actions" }, @@ -58,8 +75,11 @@ type: "button", style: { width: "100%" }, onClick: handleLogin, + disabled: isAuthenticating, }, - "Access Authenticator", + isAuthenticating + ? "Authenticating..." + : "Access Authenticator", ), h( "span", diff --git a/arma/ui/apps/main/index.html b/arma/ui/apps/main/index.html index aeb45f8..fa8bc9d 100644 --- a/arma/ui/apps/main/index.html +++ b/arma/ui/apps/main/index.html @@ -26,6 +26,7 @@
+ diff --git a/arma/ui/apps/main/runtime.js b/arma/ui/apps/main/runtime.js index 70f267b..522a146 100644 --- a/arma/ui/apps/main/runtime.js +++ b/arma/ui/apps/main/runtime.js @@ -1,15 +1,40 @@ (function () { const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const SVG_NS = "http://www.w3.org/2000/svg"; + const SVG_TAGS = new Set([ + "svg", + "path", + "circle", + "rect", + "line", + "polyline", + "polygon", + "g", + "defs", + "use", + "text", + "tspan", + "clipPath", + "mask", + ]); + function h(tag, props = {}, ...children) { - const el = document.createElement(tag); + const isSvg = SVG_TAGS.has(tag); + const el = isSvg + ? document.createElementNS(SVG_NS, tag) + : document.createElement(tag); if (props) { Object.entries(props).forEach(([key, value]) => { if (key.startsWith("on") && typeof value === "function") { el.addEventListener(key.substring(2).toLowerCase(), value); } else if (key === "className") { - el.className = value; + if (isSvg) { + el.setAttribute("class", value); + } else { + el.className = value; + } } else if (key === "style" && typeof value === "object") { Object.assign(el.style, value); } else if (typeof value === "boolean") { diff --git a/arma/ui/apps/main/state.js b/arma/ui/apps/main/state.js index bb52993..d293133 100644 --- a/arma/ui/apps/main/state.js +++ b/arma/ui/apps/main/state.js @@ -5,6 +5,37 @@ class RegistryStore { constructor() { [this.getView, this.setView] = createSignal("home"); + [this.getIsAuthenticating, this.setIsAuthenticating] = + createSignal(false); + [this.getLoginError, this.setLoginError] = createSignal(""); + } + + startLogin() { + this.setLoginError(""); + this.setIsAuthenticating(true); + } + + failLogin(message) { + this.setIsAuthenticating(false); + this.setLoginError(message || "Authentication failed."); + } + + completeLogin(payload) { + const OrgPortal = window.OrgPortal; + const portalData = payload?.portalData; + const session = payload?.session; + + if (!OrgPortal || !portalData || !session) { + this.failLogin("Login response was missing portal data."); + return; + } + + OrgPortal.data.applyLoginPayload(payload); + OrgPortal.store.hydrateFromPayload(payload); + + this.setLoginError(""); + this.setIsAuthenticating(false); + this.setView("portal"); } } diff --git a/arma/ui/apps/portal/actions.js b/arma/ui/apps/portal/actions.js index ac813cd..85c3b1c 100644 --- a/arma/ui/apps/portal/actions.js +++ b/arma/ui/apps/portal/actions.js @@ -30,7 +30,32 @@ return type.charAt(0).toUpperCase() + type.slice(1); } + formatDisplayName(value) { + if (!value) { + return ""; + } + + return String(value) + .trim() + .split(/\s+/) + .map((part) => { + if (!part) { + return ""; + } + + return ( + part.charAt(0).toUpperCase() + + part.slice(1).toLowerCase() + ); + }) + .join(" "); + } + getAssetReadiness() { + if (portalData.fleet.length === 0) { + return null; + } + const total = portalData.fleet.reduce( (sum, unit) => sum + (100 - parseInt(unit.damage, 10)), 0, diff --git a/arma/ui/apps/portal/components/overviewCard.js b/arma/ui/apps/portal/components/overviewCard.js index f8aa4b3..658f895 100644 --- a/arma/ui/apps/portal/components/overviewCard.js +++ b/arma/ui/apps/portal/components/overviewCard.js @@ -9,6 +9,8 @@ OrgPortal.componentFns.OverviewCard = function OverviewCard() { const MetricCard = OrgPortal.componentFns.MetricCard; + const readiness = actions.getAssetReadiness(); + const headquarters = portalData.org.headquarters || "ArmA Verse"; return h( "section", @@ -38,7 +40,7 @@ { className: "org-summary" }, portalData.org.type, " operating from ", - portalData.org.headquarters, + headquarters, ". Treasury, fleet status, inventory, and roster management are surfaced here first.", ), h( @@ -55,7 +57,7 @@ h( "span", { className: "org-meta-value" }, - portalData.org.owner, + actions.formatDisplayName(portalData.org.owner), ), ), h( @@ -83,7 +85,7 @@ h( "span", { className: "org-meta-value" }, - `${actions.getAssetReadiness()}%`, + readiness === null ? "N/A" : `${readiness}%`, ), ), ), diff --git a/arma/ui/apps/portal/components/treasuryCard.js b/arma/ui/apps/portal/components/treasuryCard.js index a280783..b665f93 100644 --- a/arma/ui/apps/portal/components/treasuryCard.js +++ b/arma/ui/apps/portal/components/treasuryCard.js @@ -11,7 +11,6 @@ OrgPortal.componentFns.TreasuryCard = function TreasuryCard() { const notice = store.getTreasuryNotice(); const creditLines = store.getCreditLines(); - const noMembers = store.getMembers().length === 0; const allowTreasuryActions = permissions.canManageTreasury(); return h( @@ -56,7 +55,6 @@ { type: "button", onClick: () => actions.openModal("payroll"), - disabled: noMembers, }, "Run Payroll", ), @@ -66,7 +64,6 @@ type: "button", className: "org-secondary-btn", onClick: () => actions.openModal("transfer"), - disabled: noMembers, }, "Send Funds", ), @@ -76,7 +73,6 @@ type: "button", className: "org-secondary-btn", onClick: () => actions.openModal("credit"), - disabled: noMembers, }, "Credit Line", ), diff --git a/arma/ui/apps/portal/data.js b/arma/ui/apps/portal/data.js index 484aaa0..4da8f27 100644 --- a/arma/ui/apps/portal/data.js +++ b/arma/ui/apps/portal/data.js @@ -1,6 +1,19 @@ (function () { const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + function cloneValue(value) { + return JSON.parse(JSON.stringify(value)); + } + + function replaceObject(target, source) { + Object.keys(target).forEach((key) => delete target[key]); + Object.assign(target, cloneValue(source)); + } + + function replaceArray(target, source) { + target.splice(0, target.length, ...cloneValue(source)); + } + OrgPortal.data = { portalData: { org: { @@ -10,6 +23,8 @@ status: "Operational", headquarters: "Georgetown Command Annex", owner: "Jacob Schmidt", + ownerUid: "uid-jacob-schmidt", + isDefault: false, }, funds: 482750, reputation: 72, @@ -112,7 +127,34 @@ }, session: { actorName: "Jacob Schmidt", + actorUid: "uid-jacob-schmidt", role: "Leader", + ceo: false, + }, + applyLoginPayload(payload) { + replaceObject(this.portalData.org, payload.portalData.org || {}); + this.portalData.funds = payload.portalData.funds || 0; + this.portalData.reputation = payload.portalData.reputation || 0; + + replaceArray( + this.portalData.members, + payload.portalData.members || [], + ); + replaceArray(this.portalData.fleet, payload.portalData.fleet || []); + replaceArray( + this.portalData.assets, + payload.portalData.assets || [], + ); + replaceArray( + this.portalData.activity, + payload.portalData.activity || [], + ); + replaceArray( + this.portalData.roadmap, + payload.portalData.roadmap || [], + ); + + replaceObject(this.session, payload.session || {}); }, }; })(); diff --git a/arma/ui/apps/portal/permissions.js b/arma/ui/apps/portal/permissions.js index 7de691e..ee67d4f 100644 --- a/arma/ui/apps/portal/permissions.js +++ b/arma/ui/apps/portal/permissions.js @@ -3,11 +3,54 @@ const { portalData, session } = OrgPortal.data; class OrgPortalPermissions { + getNormalizedRole() { + return String(session.role || "") + .trim() + .toUpperCase(); + } + + isDefaultOrg() { + return ( + portalData.org.isDefault === true || + String(portalData.org.tag || "") + .trim() + .toUpperCase() === "DEFAULT" + ); + } + + isOrgOwner() { + const ownerUid = String( + portalData.org.ownerUid || portalData.org.owner || "", + ) + .trim() + .toLowerCase(); + const actorUid = String(session.actorUid || "") + .trim() + .toLowerCase(); + + if (ownerUid && actorUid) { + return actorUid === ownerUid; + } + + return ( + String(session.actorName || "") + .trim() + .toLowerCase() === + String(portalData.org.owner || "") + .trim() + .toLowerCase() + ); + } + + isSessionCeo() { + return session.ceo === true; + } + isOrgLeaderOrCeo() { return ( - session.actorName === portalData.org.owner || - session.role === "Leader" || - session.role === "CEO" + this.isOrgOwner() || + this.getNormalizedRole() === "LEADER" || + (this.isDefaultOrg() && this.isSessionCeo()) ); } diff --git a/arma/ui/apps/portal/store.js b/arma/ui/apps/portal/store.js index cb882b6..e75e120 100644 --- a/arma/ui/apps/portal/store.js +++ b/arma/ui/apps/portal/store.js @@ -17,6 +17,15 @@ [this.getModal, this.setModal] = createSignal(null); [this.getOrgDisbanded, this.setOrgDisbanded] = createSignal(false); } + + hydrateFromPayload(payload) { + this.setFunds(payload.portalData.funds || 0); + this.setMembers([...(payload.portalData.members || [])]); + this.setCreditLines([]); + this.setTreasuryNotice({ type: "", text: "" }); + this.setModal(null); + this.setOrgDisbanded(false); + } } OrgPortal.store = new OrgPortalStore();