;(function() { let currentUser = null const api = { post(url, body) { return m.request({method: "POST", url, body}) }, identity: { logout() { return api.post("/Identity.Logout") }, }, profiles: { get() { return api.post("/Profiles.Get", {}) }, save(data) { return api.post("/Profiles.Save", data) }, }, tenants: { create(tenant) { return api.post("/Tenants.Create", {Tenant: tenant}) }, update(tenant) { return api.post("/Tenants.Update", {Tenant: tenant}) }, get(id) { return api.post("/Tenants.Get", {ID: id}) }, list(page) { return api.post("/Tenants.List", {Page: page || {Limit: 100}}) }, }, tenantMembers: { listByTenant(tenant, page) { return api.post("/TenantMembers.ListByTenant", {Tenant: tenant, Page: page || {Limit: 100}}) }, listByUser(userID, page) { return api.post("/TenantMembers.ListByUser", {UserID: userID, Page: page || {Limit: 100}}) }, remove(tenant, userID) { return api.post("/TenantMembers.Remove", {Tenant: tenant, UserID: userID}) }, }, tenantInvitations: { create(tenant, email) { return api.post("/TenantInvitations.Create", {Tenant: tenant, Email: email}) }, accept(token) { return api.post("/TenantInvitations.Accept", {Token: token}) }, decline(token) { return api.post("/TenantInvitations.Decline", {Token: token}) }, list(tenant, page) { return api.post("/TenantInvitations.List", {Tenant: tenant, Page: page || {Limit: 100}}) }, listByEmail(email, page) { return api.post("/TenantInvitations.ListByEmail", {Email: email, Page: page || {Limit: 100}}) }, delete(tenant, email) { return api.post("/TenantInvitations.Delete", {Tenant: tenant, Email: email}) }, }, groups: { listByTenant(tenant, page) { return api.post("/Groups.ListByTenant", {Tenant: tenant, Page: page || {Limit: 100}}) }, get(tenant, path) { return api.post("/Groups.Get", {Tenant: tenant, Path: path}) }, save(group) { return api.post("/Groups.Save", {Group: group}) }, delete(tenant, path) { return api.post("/Groups.Delete", {Tenant: tenant, Path: path}) }, }, roles: { listByTenant(tenant, page) { return api.post("/Roles.ListByTenant", {Tenant: tenant, Page: page || {Limit: 100}}) }, }, grants: { listByTenant(tenant, page) { return api.post("/Grants.ListByTenant", {Tenant: tenant, Page: page || {Limit: 100}}) }, }, domains: { list(tenant, page) { return api.post("/Domain_List", {Tenant: tenant, Page: page || {Limit: 100}}) }, create(tenant, domain) { return api.post("/Domain_Create", {Tenant: tenant, Domain: domain}) }, delete(tenant, id) { return api.post("/Domain_Delete", {Tenant: tenant, ID: id}) }, verify(tenant, id) { return api.post("/Domain_Verify", {Tenant: tenant, ID: id}) }, }, } function navLink(path, label) { const cur = m.route.get() const active = cur === "/" + path || cur.startsWith("/" + path + "/") return m(m.route.Link, {href: "/" + path, class: active ? "active" : ""}, label) } const tenantNames = {} function getTenantName(id) { if (tenantNames[id]) return tenantNames[id] tenantNames[id] = id api.tenants.get(id).then(res => { tenantNames[id] = res.Tenant.Name; m.redraw() }) return id } 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", "Dashboard"), navLink("profile", "Profile"), navLink("tenants", "My Tenants"), ns ? [ m(".nav-section", getTenantName(ns)), navLink("tenants/" + ns + "/members", "Members"), navLink("tenants/" + ns + "/invitations", "Invitations"), navLink("tenants/" + ns + "/groups", "Groups"), navLink("tenants/" + ns + "/roles", "Roles"), navLink("tenants/" + ns + "/grants", "Grants"), navLink("tenants/" + ns + "/domains", "Domains"), ] : null, m(".nav-section", ""), m("a", {href: "#", onclick: e => { e.preventDefault() api.identity.logout().then(() => { window.location.href = "/login" }) }}, "Log Out"), ]), 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)))) )), ]) } // Profile function ProfilePage() { let form = {Name: "", Bio: "", Location: "", Website: "", PictureURL: ""} let email = "" let loading = true let saving = false let error = null let success = null api.profiles.get().then(res => { if (res.User) { email = res.User.Email currentUser = res.User } if (res.Profile) { form = { Name: res.Profile.Name || "", Bio: res.Profile.Bio || "", Location: res.Profile.Location || "", Website: res.Profile.Website || "", PictureURL: res.Profile.PictureURL || "", } } loading = false m.redraw() }).catch(() => { error = "Failed to load profile"; loading = false; m.redraw() }) const bind = key => ({value: form[key], oninput: e => { form[key] = e.target.value; success = null }}) const submit = e => { e.preventDefault() saving = true error = null success = null api.profiles.save(form) .then(() => { saving = false; success = "Profile saved."; m.redraw() }) .catch(err => { error = (err.response && err.response.Message) || "An error occurred"; saving = false; m.redraw() }) } return { view() { return m(Layout, [ m("h1", "Profile"), loading ? m(".empty", "Loading...") : [ error && m(".error", error), success && m(".success", success), m(".form-field", [m("label", "Email"), m("input[type=email][disabled]", {value: email})]), m("form.form", {onsubmit: submit}, [ m(".form-field", [m("label", "Name"), m("input[type=text]", {...bind("Name")})]), m(".form-field", [m("label", "Bio"), m("input[type=text]", {...bind("Bio")})]), m(".form-field", [m("label", "Location"), m("input[type=text]", {...bind("Location")})]), m(".form-field", [m("label", "Website"), m("input[type=text]", {...bind("Website")})]), m(".form-field", [m("label", "Picture URL"), m("input[type=text]", {...bind("PictureURL")})]), m(".form-caps", [ m("button[type=submit].btn", {disabled: saving}, saving ? "Saving..." : "Save"), ]), ]), ], ]) }, } } // Tenants list (user's tenants via TenantMembers.ListByUser) function TenantsPage() { let memberships = [] let tenants = {} let loading = true let name = "" let creating = false let error = null let invitations = [] let invitationsLoading = true let invitationError = null function load() { // First ensure we have the current user const getUserID = currentUser ? Promise.resolve(currentUser.ID) : api.profiles.get().then(res => { currentUser = res.User; return res.User.ID }) getUserID .then(api.tenantMembers.listByUser) .then(res => { memberships = res.Page.Items || [] // Fetch tenant names for each membership memberships.forEach(mb => { if (!tenants[mb.Tenant]) { api.tenants.get(mb.Tenant).then(res => { tenants[mb.Tenant] = res.Tenant; m.redraw() }) } }) loading = false m.redraw() }) } load() function loadInvitations() { const getEmail = currentUser ? Promise.resolve(currentUser.Email) : api.profiles.get().then(res => { currentUser = res.User; return res.User.Email }) getEmail.then(email => { return api.tenantInvitations.listByEmail(email) }).then(res => { invitations = res.Page.Items || [] invitationsLoading = false m.redraw() }).catch(() => { invitationsLoading = false; m.redraw() }) } loadInvitations() const acceptInvitation = inv => { invitationError = null api.tenantInvitations.accept(inv.Token) .then(() => { loading = true; load(); loadInvitations() }) .catch(err => { invitationError = (err.response && err.response.Message) || "An error occurred"; m.redraw() }) } const declineInvitation = inv => { invitationError = null api.tenantInvitations.decline(inv.Token) .then(() => loadInvitations()) .catch(err => { invitationError = (err.response && err.response.Message) || "An error occurred"; m.redraw() }) } const create = e => { e.preventDefault() creating = true error = null const tenant = {Name: name} api.tenants.create(tenant) .then(() => { name = ""; creating = false; loading = true; load() }) .catch(err => { error = (err.response && err.response.Message) || "An error occurred"; creating = false; m.redraw() }) } return { view() { return m(Layout, [ m(".page-header", [ m("h1", "My Tenants"), ]), error && m(".error", error), m("form.form.invite-form", {onsubmit: create}, [ m("input[type=text]", { required: true, placeholder: "New tenant name", value: name, oninput: e => name = e.target.value}), m("button[type=submit].btn", {disabled: creating}, creating ? "Creating..." : "Create"), ]), loading ? m(".empty", "Loading...") : table([ {label: "Name", value: mb => tenants[mb.Tenant] ? tenants[mb.Tenant].Name : mb.Tenant}, {label: "Role", value: mb => mb.Owner ? m("span.badge.badge-green", "Owner") : m("span.badge.badge-gray", "Member")}, {label: "", value: mb => m(m.route.Link, {href: "/tenants/" + mb.Tenant + "/members", class: "cap-link"}, "Manage")}, ], memberships, "No tenants found."), m("h2", "Pending Invitations"), invitationError && m(".error", invitationError), invitationsLoading ? m(".empty", "Loading...") : table([ {label: "Tenant", value: inv => getTenantName(inv.Tenant)}, {label: "Expires", value: inv => new Date(inv.ExpiresAt).toLocaleDateString()}, {label: "", value: inv => [ m("a.cap-link", {href: "#", onclick: e => { e.preventDefault(); acceptInvitation(inv) }}, "Accept"), " ", m("a.cap-link", {href: "#", onclick: e => { e.preventDefault(); declineInvitation(inv) }}, "Decline"), ]}, ], invitations, "No pending invitations."), ]) }, } } // Tenant members function TenantMembersPage(vnode) { const tenantID = vnode.attrs.ns let items = [] let loading = true let error = null function load() { api.tenantMembers.listByTenant(tenantID).then(res => { items = res.Page.Items || []; loading = false; m.redraw() }) } load() const remove = uid => { api.tenantMembers.remove(tenantID, uid) .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), loading ? m(".empty", "Loading...") : table([ {label: "User ID", cls: "mono", value: mb => mb.UserID}, {label: "Role", value: mb => mb.Owner ? m("span.badge.badge-green", "Owner") : m("span.badge.badge-gray", "Member")}, {label: "", value: mb => m("a.cap-link", {href: "#", onclick: e => { e.preventDefault(); remove(mb.UserID) }}, "Remove")}, ], items, "No members found."), ]) }, } } // Tenant invitations (owner view) function TenantInvitationsPage(vnode) { const tenantID = vnode.attrs.ns let items = [] let loading = true let email = "" let inviting = false let error = null let success = null function load() { loading = true api.tenantInvitations.list(tenantID).then(res => { items = res.Page.Items || [] loading = false m.redraw() }).catch(() => { loading = false; m.redraw() }) } load() const invite = e => { e.preventDefault() inviting = true error = null success = null api.tenantInvitations.create(tenantID, email) .then(() => { email = ""; inviting = false; success = "Invitation sent."; load() }) .catch(err => { error = (err.response && err.response.Message) || "An error occurred"; inviting = false; m.redraw() }) } const revoke = inv => { error = null api.tenantInvitations.delete(tenantID, inv.Email) .then(() => load()) .catch(err => { error = (err.response && err.response.Message) || "An error occurred"; m.redraw() }) } return { view() { return m(Layout, [ m("h1", "Invitations"), error && m(".error", error), success && m(".success", success), m("form.form.invite-form", {onsubmit: invite}, [ m("input[type=email]", { required: true, placeholder: "Email address", value: email, oninput: e => { email = e.target.value; success = null }}), m("button[type=submit].btn", {disabled: inviting}, inviting ? "Inviting..." : "Send Invitation"), ]), loading ? m(".empty", "Loading...") : table([ {label: "Email", value: inv => inv.Email}, {label: "Expires", value: inv => new Date(inv.ExpiresAt).toLocaleDateString()}, {label: "", value: inv => m("a.cap-link", {href: "#", onclick: e => { e.preventDefault(); revoke(inv) }}, "Revoke")}, ], items, "No pending invitations."), ]) }, } } // Tenant groups function TenantGroupsPage(vnode) { const tenantID = vnode.attrs.ns let items = [] let loading = true api.groups.listByTenant(tenantID).then(res => { items = res.Page.Items || []; loading = false; m.redraw() }) return { view() { return m(Layout, [ m(".page-header", [ m("h1", "Groups"), m(m.route.Link, {href: "/tenants/" + tenantID + "/groups/new", class: "btn"}, "New Group"), ]), loading ? m(".empty", "Loading...") : table([ {label: "Path", cls: "mono", value: g => g.Path}, {label: "Name", value: g => g.Name}, {label: "", value: g => m(m.route.Link, { href: "/tenants/" + tenantID + "/groups/" + encodeURIComponent(g.Path) + "/edit", class: "cap-link"}, "Edit")}, ], items, "No groups found."), ]) }, } } // Group form function TenantGroupFormPage(vnode) { const tenantID = vnode.attrs.ns let form = {Path: "", Name: ""} let saving = false let loading = false let error = null let isEdit = false const pathParam = vnode.attrs.path if (pathParam) { isEdit = true loading = true const path = decodeURIComponent(pathParam) api.groups.get(tenantID, path).then(g => { form = {Path: g.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 api.groups.save({Tenant: tenantID, Path: form.Path, Name: form.Name}) .then(() => m.route.set("/tenants/" + tenantID + "/groups")) .catch(err => { error = (err.response && err.response.Message) || "An error occurred"; saving = false; m.redraw() }) } return { view() { return m(Layout, [ m("h1", isEdit ? "Edit Group" : "New Group"), loading ? m(".empty", "Loading...") : [ error && m(".error", error), m("form.form", {onsubmit: submit}, [ m(".form-field", [ m("label", "Path"), m("input[type=text].mono", { required: true, placeholder: "engineering", disabled: isEdit, ...bind("Path") })]), m(".form-field", [ m("label", "Name"), m("input[type=text]", { required: true, ...bind("Name")})]), m(".form-caps", [ m("button[type=submit].btn", { disabled: saving}, saving ? "Saving..." : ( isEdit ? "Save Changes" : "Create Group")), m(m.route.Link, { href: "/tenants/" + tenantID + "/groups", class: "cancel-link"}, "Cancel"), ]), ]), ], ]) }, } } // Tenant roles function TenantRolesPage(vnode) { const tenantID = vnode.attrs.ns let items = [] let loading = true api.roles.listByTenant(tenantID).then(res => { items = res.Page.Items || []; loading = false; m.redraw() }) return { view() { return m(Layout, [ m("h1", "Roles"), loading ? m(".empty", "Loading...") : table([ {label: "Slug", cls: "mono", value: r => r.Slug}, {label: "Name", value: r => r.Name}, {label: "Caps", cls: "mono", value: r => (r.Caps || []).join(", ")}, ], items, "No roles found."), ]) }, } } // Tenant grants function TenantGrantsPage(vnode) { const tenantID = vnode.attrs.ns let items = [] let loading = true api.grants.listByTenant(tenantID).then(res => { items = res.Page.Items || []; loading = false; m.redraw() }) return { view() { return m(Layout, [ m("h1", "Grants"), loading ? m(".empty", "Loading...") : table([ {label: "Type", value: g => g.Type}, {label: "Principal", cls: "mono", value: g => g.Principal}, {label: "Role", cls: "mono", value: g => g.Role}, {label: "Path", cls: "mono", value: g => g.Path}, ], items, "No grants found."), ]) }, } } // Tenant domains function TenantDomainsPage(vnode) { const tenantID = vnode.attrs.ns let items = [] let loading = true let domain = "" let creating = false let error = null let success = null function load() { loading = true api.domains.list(tenantID).then(res => { items = res.Page.Items || [] loading = false m.redraw() }).catch(() => { loading = false; m.redraw() }) } load() const create = e => { e.preventDefault() creating = true error = null success = null api.domains.create(tenantID, domain) .then(() => { domain = ""; creating = false; success = "Domain added."; load() }) .catch(err => { error = (err.response && err.response.Message) || "An error occurred"; creating = false; m.redraw() }) } const remove = id => { error = null api.domains.delete(tenantID, id) .then(() => load()) .catch(err => { error = (err.response && err.response.Message) || "An error occurred"; m.redraw() }) } const verify = id => { api.domains.verify(tenantID, id) .then(() => load()) .catch(err => { error = (err.response && err.response.Message) || "An error occurred"; m.redraw() }) } return { view() { return m(Layout, [ m(".page-header", [ m("h1", "Domains"), ]), error && m(".error", error), success && m(".success", success), m("form.form.invite-form", {onsubmit: create}, [ m("input[type=text]", { required: true, placeholder: "Domain name (e.g., example.com)", value: domain, oninput: e => { domain = e.target.value; success = null }}), m("button[type=submit].btn", {disabled: creating}, creating ? "Adding..." : "Add Domain"), ]), loading ? m(".empty", "Loading...") : table([ {label: "Domain", cls: "mono", value: d => d.Domain}, {label: "ID", value: d => d.ID}, {label: "Verified", value: d => d.VerifiedAt ? m("span.badge.badge-green", "Yes") : m("span.badge.badge-gray", "No")}, {label: "", value: d => m("a.cap-link", {href: "#", onclick: e => { e.preventDefault(); verify(d.ID) }}, "Verify")}, {label: "", value: d => m("a.cap-link", {href: "#", onclick: e => { e.preventDefault(); remove(d.ID) }}, "Remove")}, ], items, "No domains found."), ]) }, } } m.route.prefix = "/dashboard" m.route(document.getElementById("app"), "/profile", { "/profile": ProfilePage, "/tenants": TenantsPage, "/tenants/:ns/members": TenantMembersPage, "/tenants/:ns/invitations": TenantInvitationsPage, "/tenants/:ns/groups": TenantGroupsPage, "/tenants/:ns/groups/new": TenantGroupFormPage, "/tenants/:ns/groups/:path/edit": TenantGroupFormPage, "/tenants/:ns/roles": TenantRolesPage, "/tenants/:ns/grants": TenantGrantsPage, "/tenants/:ns/domains": TenantDomainsPage, }) })()