;(function() { const api = { post(url, body) { return m.request({method: "POST", url, body}) }, users: { list() { return api.post("/admin/api/users/list", {Limit: 100}) }, }, tenants: { list() { return api.post("/admin/api/tenants/list", {Page: {Limit: 100}}) }, get(id) { return api.post("/admin/api/tenants/get", {ID: id}) }, }, groups: { list() { return api.post("/admin/api/groups/list", {Limit: 100}) }, listByNamespace(ns) { return api.post("/admin/api/groups/list-by-namespace", {Namespace: ns, Page: {Limit: 100}}) }, get(id) { return api.post("/admin/api/groups/get", {ID: id}) }, create(data) { return api.post("/admin/api/groups/create", data) }, update(data) { return api.post("/admin/api/groups/update", data) }, delete(id) { return api.post("/admin/api/groups/delete", {ID: id}) }, }, roles: { list() { return api.post("/admin/api/roles/list", {Limit: 100}) }, listByNamespace(ns) { return api.post("/admin/api/roles/list-by-namespace", {Namespace: ns, Page: {Limit: 100}}) }, }, grants: { list() { return api.post("/admin/api/grants/list", {Limit: 100}) }, listByNamespace(ns) { return api.post("/admin/api/grants/list-by-namespace", {Namespace: ns, Page: {Limit: 100}}) }, }, tenantMembers: { list(tenantID) { return api.post("/admin/api/tenant-members/list", {TenantID: tenantID, Page: {Limit: 100}}) }, invite(tenantID, email) { return api.post("/admin/api/tenant-members/invite", {TenantID: tenantID, Email: email}) }, remove(tenantID, userID) { return api.post("/admin/api/tenant-members/remove", {TenantID: tenantID, UserID: userID}) }, }, } function navLink(path, label) { const cur = m.route.get() const active = cur === path || cur.startsWith(path + "/") return m("a", {href: "#!/" + path, class: active ? "active" : ""}, label) } const tenantNames = {app: "System (app)"} function getTenantName(ns) { if (tenantNames[ns]) return tenantNames[ns] tenantNames[ns] = ns // placeholder while loading api.tenants.get(ns).then(t => { tenantNames[ns] = t.Name; m.redraw() }) return ns } const Layout = { view(vnode) { const cur = m.route.get() const match = cur.match(/^\/tenants\/([^/]+)\//) const ns = match ? match[1] : null return m(".layout", [ m("nav", [ m(".logo", "IAM Admin"), navLink("users", "Users"), navLink("tenants", "Tenants"), ns ? [ m(".nav-section", getTenantName(ns)), navLink("tenants/" + ns + "/members", "Members"), navLink("tenants/" + ns + "/groups", "Groups"), navLink("tenants/" + ns + "/roles", "Roles"), navLink("tenants/" + ns + "/grants", "Grants"), ] : null, ]), m("main", vnode.children), ]) } } function table(cols, rows, empty) { if (!rows || rows.length === 0) return m(".empty", empty || "No items found.") return m("table", [ m("thead", m("tr", cols.map(c => m("th", c.label)))), m("tbody", rows.map(row => m("tr", cols.map(c => m("td", {class: c.cls || ""}, c.value(row)))) )), ]) } // Users function UsersPage() { let items = [] let loading = true api.users.list().then(res => { items = res.Items || []; loading = false; m.redraw() }) return { view() { return m(Layout, [ m("h1", "Users"), loading ? m(".empty", "Loading...") : table([ {label: "ID", cls: "mono", value: u => u.ID}, {label: "Email", value: u => u.Email}, {label: "Verified", value: u => u.Verified ? m("span.badge.badge-green", "Verified") : m("span.badge.badge-gray", "Unverified")}, ], items, "No users found."), ]) }, } } // Tenants list function TenantsPage() { let items = [] let loading = true api.tenants.list().then(res => { items = res.Items || []; loading = false; m.redraw() }) return { view() { // Prepend "System (app)" virtual entry const allItems = [{ID: "app", Name: "System (app)", Type: "system", _virtual: true}, ...(items || [])] return m(Layout, [ m("h1", "Tenants"), loading ? m(".empty", "Loading...") : table([ {label: "ID", cls: "mono", value: t => t._virtual ? "app" : t.ID}, {label: "Name", value: t => t.Name}, {label: "Type", value: t => t.Type}, {label: "", value: t => m("a.action-link", {href: "#!/tenants/" + (t._virtual ? "app" : t.ID) + "/groups"}, "View")}, ], allItems, "No tenants found."), ]) }, } } // Tenant groups page function TenantGroupsPage(vnode) { const ns = vnode.attrs.ns let items = [] let loading = true api.groups.listByNamespace(ns).then(res => { items = res.Items || []; loading = false; m.redraw() }) return { view() { return m(Layout, [ m(".page-header", [ m("h1", "Groups"), m("a.btn", {href: "#!/tenants/" + ns + "/groups/new"}, "New Group"), ]), loading ? m(".empty", "Loading...") : table([ {label: "ID", cls: "mono", value: g => g.ID}, {label: "Path", cls: "mono", value: g => g.Path}, {label: "Name", value: g => g.Name}, {label: "", value: g => m("a.action-link", {href: "#!/tenants/" + ns + "/groups/" + g.ID + "/edit"}, "Edit")}, ], items, "No groups found."), ]) }, } } // Group form (shared for create + edit, tenant-scoped) function TenantGroupFormPage(vnode) { const ns = vnode.attrs.ns const prefix = ns + "." let form = {PathSuffix: "", Name: ""} let saving = false let loading = false let error = null const id = vnode.attrs.id if (id) { loading = true api.groups.get(id).then(g => { const path = g.Path || "" form = {PathSuffix: path.startsWith(prefix) ? path.slice(prefix.length) : path, Name: g.Name || ""} loading = false m.redraw() }).catch(() => { error = "Failed to load group"; loading = false; m.redraw() }) } const bind = key => ({value: form[key], oninput: e => form[key] = e.target.value}) const submit = e => { e.preventDefault() saving = true error = null const data = {Path: prefix + form.PathSuffix, Name: form.Name} const req = id ? api.groups.update({ID: id, ...data}) : api.groups.create(data) req.then(() => m.route.set("/tenants/" + ns + "/groups")) .catch(err => { error = (err.response && err.response.Message) || "An error occurred"; saving = false; m.redraw() }) } return { view() { return m(Layout, [ m("h1", id ? "Edit Group" : "New Group"), loading ? m(".empty", "Loading...") : [ error && m(".error", error), m("form.form", {onsubmit: submit}, [ m(".form-field", [m("label", "Path"), m(".path-input", [m("span.path-prefix", prefix), m("input[type=text].mono", {required: true, placeholder: "engineering", ...bind("PathSuffix")})])]), m(".form-field", [m("label", "Name"), m("input[type=text]", {required: true, ...bind("Name")})]), m(".form-actions", [ m("button[type=submit].btn", {disabled: saving}, saving ? "Saving..." : (id ? "Save Changes" : "Create Group")), m("a.cancel-link", {href: "#!/tenants/" + ns + "/groups"}, "Cancel"), ]), ]), ], ]) }, } } // Tenant roles page function TenantRolesPage(vnode) { const ns = vnode.attrs.ns let items = [] let loading = true api.roles.listByNamespace(ns).then(res => { items = res.Items || []; loading = false; m.redraw() }) return { view() { return m(Layout, [ m("h1", "Roles"), loading ? m(".empty", "Loading...") : table([ {label: "ID", cls: "mono", value: r => r.ID}, {label: "Name", value: r => r.Name}, {label: "Actions", cls: "mono", value: r => (r.Actions || []).join(", ")}, ], items, "No roles found."), ]) }, } } // Tenant grants page function TenantGrantsPage(vnode) { const ns = vnode.attrs.ns let items = [] let loading = true api.grants.listByNamespace(ns).then(res => { items = res.Items || []; loading = false; m.redraw() }) return { view() { return m(Layout, [ m("h1", "Grants"), loading ? m(".empty", "Loading...") : table([ {label: "Principal ID", cls: "mono", value: g => g.PrincipalID}, {label: "Role ID", cls: "mono", value: g => g.RoleID}, ], items, "No grants found."), ]) }, } } // Tenant members page function TenantMembersPage(vnode) { const ns = vnode.attrs.ns let items = [] let loading = true let email = "" let inviting = false let error = null function load() { api.tenantMembers.list(ns).then(res => { items = res.Items || []; loading = false; m.redraw() }) } load() const invite = e => { e.preventDefault() inviting = true error = null api.tenantMembers.invite(ns, email) .then(() => { email = ""; inviting = false; loading = true; load() }) .catch(err => { error = (err.response && err.response.Message) || "An error occurred"; inviting = false; m.redraw() }) } const remove = userID => { api.tenantMembers.remove(ns, userID) .then(() => { loading = true; load() }) .catch(err => { error = (err.response && err.response.Message) || "An error occurred"; m.redraw() }) } return { view() { return m(Layout, [ m("h1", "Members"), error && m(".error", error), m("form.form.invite-form", {onsubmit: invite}, [ m("input[type=email]", {required: true, placeholder: "Email address", value: email, oninput: e => email = e.target.value}), m("button[type=submit].btn", {disabled: inviting}, inviting ? "Inviting..." : "Invite"), ]), loading ? m(".empty", "Loading...") : table([ {label: "User ID", cls: "mono", value: mb => mb.UserID}, {label: "Status", value: mb => mb.Status === "active" ? m("span.badge.badge-green", "Active") : m("span.badge.badge-gray", "Invited")}, {label: "", value: mb => m("a.action-link", {href: "#", onclick: e => { e.preventDefault(); remove(mb.UserID) }}, "Remove")}, ], items, "No members found."), ]) }, } } m.route(document.getElementById("app"), "/tenants", { "/users": UsersPage, "/tenants": TenantsPage, "/tenants/:ns/members": TenantMembersPage, "/tenants/:ns/groups": TenantGroupsPage, "/tenants/:ns/groups/new": TenantGroupFormPage, "/tenants/:ns/groups/:id/edit": TenantGroupFormPage, "/tenants/:ns/roles": TenantRolesPage, "/tenants/:ns/grants": TenantGrantsPage, }) })()