Align org UI with live org data

This commit is contained in:
Jacob Schmidt 2026-03-07 15:23:01 -06:00
parent 964e839625
commit cdfc8dda80
29 changed files with 1051 additions and 212 deletions

View File

@ -1,4 +1,86 @@
forge_client_org 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

View File

@ -23,81 +23,174 @@ private _event = _alert get "event";
private _data = _alert get "data"; private _data = _alert get "data";
// private _display = displayChild findDisplay 46; // 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]; diag_log format ["[FORGE:Client:Org] Handling UI event: %1 with data: %2", _event, _data];
switch (_event) do { switch (_event) do {
case "org::close": { closeDialog 1; }; 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 _orgData = GVAR(OrgClass) get "org";
private _name = _orgData getOrDefault ["name", "Unknown"]; private _orgId = _orgData getOrDefault ["id", ""];
private _id = _orgData getOrDefault ["id", ""]; private _orgName = _orgData getOrDefault ["name", ""];
private _funds = _orgData getOrDefault ["funds", 0];
private _reputation = _orgData getOrDefault ["reputation", 0];
private _membersRaw = _orgData getOrDefault ["members", []];
private _membersList = [];
// Handle members if (_email isEqualTo "" || {_password isEqualTo ""}) exitWith {
private _fnc_processMember = { [_control, "receiveLoginFailure", createHashMapFromArray [
params ["_mData"]; ["message", "Enter both email and password."]
]] call _fnc_execBridge;
private _mName = _mData getOrDefault ["name", "Unknown"];
createHashMapFromArray [
["name", _mName],
["rank", "Member"],
["online", true]
]
}; };
{ if (_orgId isEqualTo "" && {_orgName isEqualTo ""}) exitWith {
_membersList pushBack ([_y] call _fnc_processMember); [_control, "receiveLoginFailure", createHashMapFromArray [
} forEach _membersRaw; ["message", "No organization data is available for this player."]
]] call _fnc_execBridge;
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]
]
}; };
{ [_control, "receiveLoginSuccess", call _fnc_buildPortalPayload] call _fnc_execBridge;
_assetsList pushBack ([_y] call _fnc_processAsset); };
} forEach _assetsRaw; case "org::ready": {
[_control, "receive", createHashMapFromArray [
// Construct HashMap payload ["event", "org::ready"],
private _payload = createHashMapFromArray [ ["data", createHashMapFromArray [
["org", createHashMapFromArray [ ["loaded", true]
["name", _name], ]]
["tag", _id], ]] call _fnc_execBridge;
["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]];
}; };
default { hint format ["Unhandled UI event: %1", _event]; }; default { hint format ["Unhandled UI event: %1", _event]; };
}; };

View File

@ -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),
};
})();

View File

@ -42,23 +42,119 @@
}, },
h( h(
"li", "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( h(
"li", "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( h(
"li", "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( h(
"li", "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( h(

View File

@ -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) { @media (max-width: 960px) {
.split-container { .split-container {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@ -35,23 +35,6 @@
), ),
h("button", { onClick: () => store.setView("login") }, "Login"), 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",
),
),
); );
}; };
})(); })();

View File

@ -6,6 +6,10 @@
RegistryApp.componentFns = RegistryApp.componentFns || {}; RegistryApp.componentFns = RegistryApp.componentFns || {};
RegistryApp.componentFns.LoginForm = function LoginForm() { RegistryApp.componentFns.LoginForm = function LoginForm() {
const bridge = RegistryApp.bridge;
const isAuthenticating = store.getIsAuthenticating();
const loginError = store.getLoginError();
const handleLogin = () => { const handleLogin = () => {
const data = { const data = {
email: String( email: String(
@ -15,8 +19,13 @@
document.getElementById("org-login-password")?.value || "", 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( return h(
@ -47,8 +56,16 @@
id: "org-login-password", id: "org-login-password",
type: "password", type: "password",
placeholder: "********", placeholder: "********",
disabled: isAuthenticating,
}), }),
), ),
loginError
? h(
"div",
{ className: "form-feedback is-error" },
loginError,
)
: null,
h( h(
"div", "div",
{ className: "form-actions" }, { className: "form-actions" },
@ -58,8 +75,11 @@
type: "button", type: "button",
style: { width: "100%" }, style: { width: "100%" },
onClick: handleLogin, onClick: handleLogin,
disabled: isAuthenticating,
}, },
"Access Authenticator", isAuthenticating
? "Authenticating..."
: "Access Authenticator",
), ),
h( h(
"span", "span",

View File

@ -27,6 +27,7 @@
const scriptFiles = [ const scriptFiles = [
"runtime.js", "runtime.js",
"state.js", "state.js",
"bridge.js",
"portal\\runtime.js", "portal\\runtime.js",
"portal\\data.js", "portal\\data.js",
"portal\\store.js", "portal\\store.js",

View File

@ -30,7 +30,32 @@
return type.charAt(0).toUpperCase() + type.slice(1); 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() { getAssetReadiness() {
if (portalData.fleet.length === 0) {
return null;
}
const total = portalData.fleet.reduce( const total = portalData.fleet.reduce(
(sum, unit) => sum + (100 - parseInt(unit.damage, 10)), (sum, unit) => sum + (100 - parseInt(unit.damage, 10)),
0, 0,

View File

@ -9,6 +9,8 @@
OrgPortal.componentFns.OverviewCard = function OverviewCard() { OrgPortal.componentFns.OverviewCard = function OverviewCard() {
const MetricCard = OrgPortal.componentFns.MetricCard; const MetricCard = OrgPortal.componentFns.MetricCard;
const readiness = actions.getAssetReadiness();
const headquarters = portalData.org.headquarters || "ArmA Verse";
return h( return h(
"section", "section",
@ -38,7 +40,7 @@
{ className: "org-summary" }, { className: "org-summary" },
portalData.org.type, portalData.org.type,
" operating from ", " operating from ",
portalData.org.headquarters, headquarters,
". Treasury, fleet status, inventory, and roster management are surfaced here first.", ". Treasury, fleet status, inventory, and roster management are surfaced here first.",
), ),
h( h(
@ -55,7 +57,7 @@
h( h(
"span", "span",
{ className: "org-meta-value" }, { className: "org-meta-value" },
portalData.org.owner, actions.formatDisplayName(portalData.org.owner),
), ),
), ),
h( h(
@ -83,7 +85,7 @@
h( h(
"span", "span",
{ className: "org-meta-value" }, { className: "org-meta-value" },
`${actions.getAssetReadiness()}%`, readiness === null ? "N/A" : `${readiness}%`,
), ),
), ),
), ),

View File

@ -11,7 +11,6 @@
OrgPortal.componentFns.TreasuryCard = function TreasuryCard() { OrgPortal.componentFns.TreasuryCard = function TreasuryCard() {
const notice = store.getTreasuryNotice(); const notice = store.getTreasuryNotice();
const creditLines = store.getCreditLines(); const creditLines = store.getCreditLines();
const noMembers = store.getMembers().length === 0;
const allowTreasuryActions = permissions.canManageTreasury(); const allowTreasuryActions = permissions.canManageTreasury();
return h( return h(
@ -56,7 +55,6 @@
{ {
type: "button", type: "button",
onClick: () => actions.openModal("payroll"), onClick: () => actions.openModal("payroll"),
disabled: noMembers,
}, },
"Run Payroll", "Run Payroll",
), ),
@ -66,7 +64,6 @@
type: "button", type: "button",
className: "org-secondary-btn", className: "org-secondary-btn",
onClick: () => actions.openModal("transfer"), onClick: () => actions.openModal("transfer"),
disabled: noMembers,
}, },
"Send Funds", "Send Funds",
), ),
@ -76,7 +73,6 @@
type: "button", type: "button",
className: "org-secondary-btn", className: "org-secondary-btn",
onClick: () => actions.openModal("credit"), onClick: () => actions.openModal("credit"),
disabled: noMembers,
}, },
"Credit Line", "Credit Line",
), ),

View File

@ -1,92 +1,37 @@
(function () { (function () {
const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); 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 = { OrgPortal.data = {
portalData: { portalData: {
org: { org: {
name: "Black Rifle Company", name: "",
tag: "BRC-0160566824", tag: "",
type: "Private Military Company", type: "Organization",
status: "Operational", status: "Operational",
headquarters: "Georgetown Command Annex", headquarters: "ArmA Verse",
owner: "Jacob Schmidt", owner: "",
ownerUid: "",
isDefault: false,
}, },
funds: 482750, funds: 0,
reputation: 72, reputation: 0,
members: [ members: [],
{ name: "Jacob Schmidt" }, fleet: [],
{ name: "Mara Velez" }, assets: [],
{ name: "Rylan Cross" }, activity: [],
{ 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.",
},
],
roadmap: [ roadmap: [
{ {
name: "Contracts Board", name: "Contracts Board",
@ -111,8 +56,35 @@
], ],
}, },
session: { session: {
actorName: "Jacob Schmidt", actorName: "",
role: "Leader", 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 || {});
}, },
}; };
})(); })();

View File

@ -3,11 +3,54 @@
const { portalData, session } = OrgPortal.data; const { portalData, session } = OrgPortal.data;
class OrgPortalPermissions { 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() { isOrgLeaderOrCeo() {
return ( return (
session.actorName === portalData.org.owner || this.isOrgOwner() ||
session.role === "Leader" || this.getNormalizedRole() === "LEADER" ||
session.role === "CEO" (this.isDefaultOrg() && this.isSessionCeo())
); );
} }

View File

@ -17,6 +17,15 @@
[this.getModal, this.setModal] = createSignal(null); [this.getModal, this.setModal] = createSignal(null);
[this.getOrgDisbanded, this.setOrgDisbanded] = createSignal(false); [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(); OrgPortal.store = new OrgPortalStore();

View File

@ -1,15 +1,40 @@
(function () { (function () {
const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); 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) { 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) { if (props) {
Object.entries(props).forEach(([key, value]) => { Object.entries(props).forEach(([key, value]) => {
if (key.startsWith("on") && typeof value === "function") { if (key.startsWith("on") && typeof value === "function") {
el.addEventListener(key.substring(2).toLowerCase(), value); el.addEventListener(key.substring(2).toLowerCase(), value);
} else if (key === "className") { } else if (key === "className") {
el.className = value; if (isSvg) {
el.setAttribute("class", value);
} else {
el.className = value;
}
} else if (key === "style" && typeof value === "object") { } else if (key === "style" && typeof value === "object") {
Object.assign(el.style, value); Object.assign(el.style, value);
} else if (typeof value === "boolean") { } else if (typeof value === "boolean") {

View File

@ -5,6 +5,37 @@
class RegistryStore { class RegistryStore {
constructor() { constructor() {
[this.getView, this.setView] = createSignal("home"); [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");
} }
} }

View File

@ -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),
};
})();

View File

@ -42,23 +42,119 @@
}, },
h( h(
"li", "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( h(
"li", "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( h(
"li", "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( h(
"li", "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( h(

View File

@ -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) { @media (max-width: 960px) {
.split-container { .split-container {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@ -6,6 +6,10 @@
RegistryApp.componentFns = RegistryApp.componentFns || {}; RegistryApp.componentFns = RegistryApp.componentFns || {};
RegistryApp.componentFns.LoginForm = function LoginForm() { RegistryApp.componentFns.LoginForm = function LoginForm() {
const bridge = RegistryApp.bridge;
const isAuthenticating = store.getIsAuthenticating();
const loginError = store.getLoginError();
const handleLogin = () => { const handleLogin = () => {
const data = { const data = {
email: String( email: String(
@ -15,8 +19,13 @@
document.getElementById("org-login-password")?.value || "", 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( return h(
@ -47,8 +56,16 @@
id: "org-login-password", id: "org-login-password",
type: "password", type: "password",
placeholder: "********", placeholder: "********",
disabled: isAuthenticating,
}), }),
), ),
loginError
? h(
"div",
{ className: "form-feedback is-error" },
loginError,
)
: null,
h( h(
"div", "div",
{ className: "form-actions" }, { className: "form-actions" },
@ -58,8 +75,11 @@
type: "button", type: "button",
style: { width: "100%" }, style: { width: "100%" },
onClick: handleLogin, onClick: handleLogin,
disabled: isAuthenticating,
}, },
"Access Authenticator", isAuthenticating
? "Authenticating..."
: "Access Authenticator",
), ),
h( h(
"span", "span",

View File

@ -26,6 +26,7 @@
<div id="app"></div> <div id="app"></div>
<script src="runtime.js"></script> <script src="runtime.js"></script>
<script src="state.js"></script> <script src="state.js"></script>
<script src="bridge.js"></script>
<script src="../portal/runtime.js"></script> <script src="../portal/runtime.js"></script>
<script src="../portal/data.js"></script> <script src="../portal/data.js"></script>
<script src="../portal/store.js"></script> <script src="../portal/store.js"></script>

View File

@ -1,15 +1,40 @@
(function () { (function () {
const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); 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) { 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) { if (props) {
Object.entries(props).forEach(([key, value]) => { Object.entries(props).forEach(([key, value]) => {
if (key.startsWith("on") && typeof value === "function") { if (key.startsWith("on") && typeof value === "function") {
el.addEventListener(key.substring(2).toLowerCase(), value); el.addEventListener(key.substring(2).toLowerCase(), value);
} else if (key === "className") { } else if (key === "className") {
el.className = value; if (isSvg) {
el.setAttribute("class", value);
} else {
el.className = value;
}
} else if (key === "style" && typeof value === "object") { } else if (key === "style" && typeof value === "object") {
Object.assign(el.style, value); Object.assign(el.style, value);
} else if (typeof value === "boolean") { } else if (typeof value === "boolean") {

View File

@ -5,6 +5,37 @@
class RegistryStore { class RegistryStore {
constructor() { constructor() {
[this.getView, this.setView] = createSignal("home"); [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");
} }
} }

View File

@ -30,7 +30,32 @@
return type.charAt(0).toUpperCase() + type.slice(1); 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() { getAssetReadiness() {
if (portalData.fleet.length === 0) {
return null;
}
const total = portalData.fleet.reduce( const total = portalData.fleet.reduce(
(sum, unit) => sum + (100 - parseInt(unit.damage, 10)), (sum, unit) => sum + (100 - parseInt(unit.damage, 10)),
0, 0,

View File

@ -9,6 +9,8 @@
OrgPortal.componentFns.OverviewCard = function OverviewCard() { OrgPortal.componentFns.OverviewCard = function OverviewCard() {
const MetricCard = OrgPortal.componentFns.MetricCard; const MetricCard = OrgPortal.componentFns.MetricCard;
const readiness = actions.getAssetReadiness();
const headquarters = portalData.org.headquarters || "ArmA Verse";
return h( return h(
"section", "section",
@ -38,7 +40,7 @@
{ className: "org-summary" }, { className: "org-summary" },
portalData.org.type, portalData.org.type,
" operating from ", " operating from ",
portalData.org.headquarters, headquarters,
". Treasury, fleet status, inventory, and roster management are surfaced here first.", ". Treasury, fleet status, inventory, and roster management are surfaced here first.",
), ),
h( h(
@ -55,7 +57,7 @@
h( h(
"span", "span",
{ className: "org-meta-value" }, { className: "org-meta-value" },
portalData.org.owner, actions.formatDisplayName(portalData.org.owner),
), ),
), ),
h( h(
@ -83,7 +85,7 @@
h( h(
"span", "span",
{ className: "org-meta-value" }, { className: "org-meta-value" },
`${actions.getAssetReadiness()}%`, readiness === null ? "N/A" : `${readiness}%`,
), ),
), ),
), ),

View File

@ -11,7 +11,6 @@
OrgPortal.componentFns.TreasuryCard = function TreasuryCard() { OrgPortal.componentFns.TreasuryCard = function TreasuryCard() {
const notice = store.getTreasuryNotice(); const notice = store.getTreasuryNotice();
const creditLines = store.getCreditLines(); const creditLines = store.getCreditLines();
const noMembers = store.getMembers().length === 0;
const allowTreasuryActions = permissions.canManageTreasury(); const allowTreasuryActions = permissions.canManageTreasury();
return h( return h(
@ -56,7 +55,6 @@
{ {
type: "button", type: "button",
onClick: () => actions.openModal("payroll"), onClick: () => actions.openModal("payroll"),
disabled: noMembers,
}, },
"Run Payroll", "Run Payroll",
), ),
@ -66,7 +64,6 @@
type: "button", type: "button",
className: "org-secondary-btn", className: "org-secondary-btn",
onClick: () => actions.openModal("transfer"), onClick: () => actions.openModal("transfer"),
disabled: noMembers,
}, },
"Send Funds", "Send Funds",
), ),
@ -76,7 +73,6 @@
type: "button", type: "button",
className: "org-secondary-btn", className: "org-secondary-btn",
onClick: () => actions.openModal("credit"), onClick: () => actions.openModal("credit"),
disabled: noMembers,
}, },
"Credit Line", "Credit Line",
), ),

View File

@ -1,6 +1,19 @@
(function () { (function () {
const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); 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 = { OrgPortal.data = {
portalData: { portalData: {
org: { org: {
@ -10,6 +23,8 @@
status: "Operational", status: "Operational",
headquarters: "Georgetown Command Annex", headquarters: "Georgetown Command Annex",
owner: "Jacob Schmidt", owner: "Jacob Schmidt",
ownerUid: "uid-jacob-schmidt",
isDefault: false,
}, },
funds: 482750, funds: 482750,
reputation: 72, reputation: 72,
@ -112,7 +127,34 @@
}, },
session: { session: {
actorName: "Jacob Schmidt", actorName: "Jacob Schmidt",
actorUid: "uid-jacob-schmidt",
role: "Leader", 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 || {});
}, },
}; };
})(); })();

View File

@ -3,11 +3,54 @@
const { portalData, session } = OrgPortal.data; const { portalData, session } = OrgPortal.data;
class OrgPortalPermissions { 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() { isOrgLeaderOrCeo() {
return ( return (
session.actorName === portalData.org.owner || this.isOrgOwner() ||
session.role === "Leader" || this.getNormalizedRole() === "LEADER" ||
session.role === "CEO" (this.isDefaultOrg() && this.isSessionCeo())
); );
} }

View File

@ -17,6 +17,15 @@
[this.getModal, this.setModal] = createSignal(null); [this.getModal, this.setModal] = createSignal(null);
[this.getOrgDisbanded, this.setOrgDisbanded] = createSignal(false); [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(); OrgPortal.store = new OrgPortalStore();