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
===================
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 _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]; };
};

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(
"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(

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) {
.split-container {
grid-template-columns: 1fr;

View File

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

View File

@ -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",

View File

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

View File

@ -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,

View File

@ -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}%`,
),
),
),

View File

@ -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",
),

View File

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

View File

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

View File

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

View File

@ -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") {
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") {

View File

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

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(
"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(

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) {
.split-container {
grid-template-columns: 1fr;

View File

@ -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",

View File

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

View File

@ -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") {
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") {

View File

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

View File

@ -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,

View File

@ -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}%`,
),
),
),

View File

@ -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",
),

View File

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

View File

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

View File

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