package main import ( "context" "database/sql" "embed" "errors" "net/http" "atlas9.dev/c/core" "atlas9.dev/c/core/dbi" "atlas9.dev/c/core/iam" "atlas9.dev/c/core/outbox" "atlas9.dev/c/core/routes" ) //go:embed static var adminStatic embed.FS type AdminAPI struct { DB *sql.DB Identity *IdentityApi Groups *GroupApi Roles *RoleApi Grants *GrantApi Tenants *TenantApi TenantMembers *TenantMemberApi TenantMemberStore dbi.Factory[iam.TenantMemberStore] Outbox dbi.Factory[outbox.Emitter] } type AdminCreateGroupReq struct { Path core.Path Name string } type AdminUpdateGroupReq struct { ID core.ID Path core.Path Name string } func (a *AdminAPI) listUsers(ctx context.Context, req core.PageReq) (*core.Page[iam.User], error) { page, err := a.Identity.ListUsers(ctx, req) if err != nil { return nil, err } return &page, nil } func (a *AdminAPI) listGroups(ctx context.Context, req core.PageReq) (*ListGroupsRes, error) { return a.Groups.List(ctx, ListGroupsReq{Page: req}) } func (a *AdminAPI) getGroup(ctx context.Context, req GetGroupReq) (*iam.Group, error) { return a.Groups.Get(ctx, req) } func (a *AdminAPI) createGroup(ctx context.Context, req AdminCreateGroupReq) (*iam.Group, error) { return a.Groups.Save(ctx, iam.Group{ ID: core.NewID(), Path: req.Path, Name: req.Name, }) } func (a *AdminAPI) updateGroup(ctx context.Context, req AdminUpdateGroupReq) (*iam.Group, error) { return a.Groups.Save(ctx, iam.Group{ ID: req.ID, Path: req.Path, Name: req.Name, }) } func (a *AdminAPI) deleteGroup(ctx context.Context, req DeleteGroupReq) (*DeleteGroupRes, error) { return a.Groups.Delete(ctx, req) } func (a *AdminAPI) listRoles(ctx context.Context, req core.PageReq) (*ListRolesRes, error) { return a.Roles.List(ctx, ListRolesReq{Page: req}) } func (a *AdminAPI) listGrants(ctx context.Context, req core.PageReq) (*ListAllGrantsRes, error) { return a.Grants.ListAll(ctx, req) } func (a *AdminAPI) listTenants(ctx context.Context, req ListTenantsReq) (*ListTenantsRes, error) { return a.Tenants.List(ctx, req) } func (a *AdminAPI) getTenant(ctx context.Context, req GetTenantReq) (*iam.Tenant, error) { return a.Tenants.Get(ctx, req) } func (a *AdminAPI) listGroupsByNamespace(ctx context.Context, req ListGroupsByNamespaceReq) (*ListGroupsRes, error) { return a.Groups.ListByNamespace(ctx, req) } func (a *AdminAPI) listRolesByNamespace(ctx context.Context, req ListRolesByNamespaceReq) (*ListRolesRes, error) { return a.Roles.ListByNamespace(ctx, req) } func (a *AdminAPI) listGrantsByNamespace(ctx context.Context, req ListGrantsByNamespaceReq) (*ListAllGrantsRes, error) { return a.Grants.ListByNamespace(ctx, req) } func (a *AdminAPI) listTenantMembers(ctx context.Context, req ListTenantMembersReq) (*ListTenantMembersRes, error) { return a.TenantMembers.ListByTenant(ctx, req) } type AdminInviteTenantMemberReq struct { TenantID core.ID Email string } type AdminTenantMemberInfo struct { TenantID core.ID UserID core.ID Email string Status iam.MemberStatus } func (a *AdminAPI) inviteTenantMember(ctx context.Context, req AdminInviteTenantMemberReq) (*AdminTenantMemberInfo, error) { user, err := a.Identity.GetUserByEmail(ctx, req.Email) if errors.Is(err, core.ErrNotFound) { // User doesn't exist yet — store pending invitation + send email _, err = dbi.ReadWrite(ctx, a.DB, func(tx dbi.DBI) (struct{}, error) { if err := NewPendingInvitationStore(tx).Add(ctx, req.TenantID, req.Email); err != nil { return struct{}{}, err } return struct{}{}, EmitTenantInvitation(ctx, a.Outbox(tx), req.TenantID, req.Email) }) if err != nil { return nil, err } return &AdminTenantMemberInfo{ TenantID: req.TenantID, Email: req.Email, Status: iam.MemberInvited, }, nil } if err != nil { return nil, err } _, err = dbi.ReadWrite(ctx, a.DB, func(tx dbi.DBI) (struct{}, error) { err := a.TenantMemberStore(tx).Add(ctx, req.TenantID, user.ID, iam.MemberInvited) if err != nil { return struct{}{}, err } return struct{}{}, EmitTenantInvitation(ctx, a.Outbox(tx), req.TenantID, user.Email) }) if err != nil { return nil, err } return &AdminTenantMemberInfo{ TenantID: req.TenantID, UserID: user.ID, Email: user.Email, Status: iam.MemberInvited, }, nil } func (a *AdminAPI) removeTenantMember(ctx context.Context, req RemoveTenantMemberReq) (*RemoveTenantMemberRes, error) { return a.TenantMembers.Remove(ctx, req) } func AdminRoutes(api *AdminAPI) []routes.Route { return []routes.Route{ routes.HTTP("GET /admin/mithril.js", func(w http.ResponseWriter, r *http.Request) { http.ServeFileFS(w, r, adminStatic, "static/mithril.js") }), routes.HTTP("GET /admin/admin.css", func(w http.ResponseWriter, r *http.Request) { http.ServeFileFS(w, r, adminStatic, "static/admin.css") }), routes.HTTP("GET /admin/admin.js", func(w http.ResponseWriter, r *http.Request) { http.ServeFileFS(w, r, adminStatic, "static/admin.js") }), routes.HTTP("GET /admin/", func(w http.ResponseWriter, r *http.Request) { http.ServeFileFS(w, r, adminStatic, "static/admin.html") }), routes.RPC("/admin/api/users/list", api.listUsers), routes.RPC("/admin/api/groups/list", api.listGroups), routes.RPC("/admin/api/groups/get", api.getGroup), routes.RPC("/admin/api/groups/create", api.createGroup), routes.RPC("/admin/api/groups/update", api.updateGroup), routes.RPC("/admin/api/groups/delete", api.deleteGroup), routes.RPC("/admin/api/roles/list", api.listRoles), routes.RPC("/admin/api/grants/list", api.listGrants), routes.RPC("/admin/api/tenants/list", api.listTenants), routes.RPC("/admin/api/tenants/get", api.getTenant), routes.RPC("/admin/api/groups/list-by-namespace", api.listGroupsByNamespace), routes.RPC("/admin/api/roles/list-by-namespace", api.listRolesByNamespace), routes.RPC("/admin/api/grants/list-by-namespace", api.listGrantsByNamespace), routes.RPC("/admin/api/tenant-members/list", api.listTenantMembers), routes.RPC("/admin/api/tenant-members/invite", api.inviteTenantMember), routes.RPC("/admin/api/tenant-members/remove", api.removeTenantMember), } }