[Rt-commit] rt branch, 4.4/core-assets, created. rt-4.2.11-38-g791cad5

Todd Wade todd at bestpractical.com
Fri Sep 11 09:20:30 EDT 2015


The branch, 4.4/core-assets has been created
        at  791cad5ff3be5e2de6ded42cae2f5a25764fd065 (commit)

- Log -----------------------------------------------------------------
commit 791cad5ff3be5e2de6ded42cae2f5a25764fd065
Author: Todd Wade <todd at bestpractical.com>
Date:   Thu Jul 23 13:52:51 2015 -0400

    core RT::Extension::Assets

diff --git a/.gitignore b/.gitignore
index 855514b..e7564bd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,7 @@
 /etc/upgrade/switch-templates-to
 /etc/upgrade/time-worked-history
 /etc/upgrade/upgrade-articles
+/etc/upgrade/upgrade-assets
 /etc/upgrade/vulnerable-passwords
 /lib/RT/Generated.pm
 /Makefile
diff --git a/configure.ac b/configure.ac
index 1bcf2ba..7cbf9d0 100755
--- a/configure.ac
+++ b/configure.ac
@@ -427,6 +427,7 @@ AC_CONFIG_FILES([
                  etc/upgrade/switch-templates-to
                  etc/upgrade/time-worked-history
                  etc/upgrade/upgrade-articles
+                 etc/upgrade/upgrade-assets
                  etc/upgrade/vulnerable-passwords
                  sbin/rt-attributes-viewer
                  sbin/rt-preferences-viewer
diff --git a/docs/customizing/assets_introduction.pod b/docs/customizing/assets_introduction.pod
new file mode 100644
index 0000000..f4168dd
--- /dev/null
+++ b/docs/customizing/assets_introduction.pod
@@ -0,0 +1,211 @@
+
+=head1 CONFIGURATION
+
+See L<RT_Config.pm> for documentation on the available configuration
+parameters.
+
+=head1 USAGE
+
+Assets start as a small set of fundamental record data upon which you build
+custom fields (CFs) of any type you like to describe the assets you want to
+track.  By themselves, before you setup any CFs, assets are not very useful.
+
+Just like tickets are assigned to queues, assets are assigned to B<catalogs>.
+The default catalog is named "General assets", but we suggest you rename it and
+add additional catalogs to better fit your organization.
+
+You may want to use catalogs to separate assets into departments, general type
+of asset, hardware vs. software, etc.  Catalogs, like queues, are generally
+easiest to work with when there's more than few but less than many dozen.  The
+catalog of an asset should represent some fundamental quality of it (and many
+other assets!), that could just as easily be expressed as a custom field, but
+which is more important than other qualities for categorizing, sorting, and
+searching.
+
+=head2 Managing catalogs
+
+Catalogs are managed by RT administrators, or anyone with the L</AdminCatalog>
+right.  You can find the list of catalogs, create new catalogs, and manage
+existing ones under the Tools → Configuration → Assets → Catalogs menu.
+
+Currently you need to log out and log back in to see changes to catalogs in any
+of the catalog selection dropdowns.  This doesn't affect the catalog name
+displayed on individual asset pages.
+
+=head2 Adding fields
+
+You can see the current asset CFs by navigating to Admin >
+Assets > Custom Fields.  From there you can use the "Create" link to create a
+new asset CF.  If you know you want to create a new CF right away, you can do
+so via Admin > Assets > Custom Fields > Create.
+
+When creating a CF, be sure to select "Assets" in the "Applies To" dropdown.
+You'll also need to grant rights to the groups and/or roles which need to see
+the fields, otherwise they'll be hidden.  See the following section.
+
+Similar to ticket CFs, asset custom fields are added globally or to specific
+catalogs.  Only assets within those specific catalogs will have the CFs
+available.  After creating a CF, you'll need to visit the "Applies To" page to
+add it to the catalogs you want or make it global.
+
+=head2 Rights
+
+There are three rights controlling basic access to assets and two for
+catalogs.  Each right is grantable at the global level or individual catalog
+level, and grantable to system groups, asset roles, user groups, and individual
+users (just like ticket and queue rights).
+
+=head3 ShowAsset
+
+Allows viewing an asset record and it's core fields (but not CFs).  Without
+it, no assets can be seen.  Similar to ShowTicket.
+
+=head3 CreateAsset
+
+Allows creating assets and filling in the core fields (but not CFs).  Without
+it, no assets can be created.  Similar to CreateTicket.
+
+=head3 ModifyAsset
+
+Allows modifying existing assets and their core fields (but not CFs).  Without
+it, basic asset data cannot be modified after creation.  Similar to
+ModifyTicket.
+
+Most of your rights configuration will be on the CFs, and will likely need to
+be done for each CF.  This lets you fine tune which fields are visible to
+individual groups and/or roles of users.  Relevant CF rights are
+B<SeeCustomField> and B<ModifyCustomField>.
+
+Rights related to assets may also come from the L</Lifecycle statuses>
+configuration and restrict status transitions.
+
+=head3 ShowCatalog
+
+Allows seeing a catalog's name and other details when associated with assets.
+Without it, users will see "[a hidden catalog]" or a blank space where the
+catalog name would normally be.  Similar to SeeQueue.
+
+=head3 AdminCatalog
+
+Allows creating new catalogs and modifying all aspects of existing catalogs,
+including changing the CFs associated with the catalog, granting/revoking
+rights, and adding/removing role members.  This right should only be granted to
+administrators of RT.  Similar to AdminQueue.
+
+=head3 Typical configuration
+
+A typical configuration grants the system Privileged group the following:
+B<ShowAsset>, B<CreateAsset>, B<ModifyAsset>, and B<ShowCatalog> globally, and
+B<SeeCustomField> and B<ModifyCustomField> globally on all asset CFs.
+
+If you want self service users (Unprivileged) to be able to view the assets
+they hold, grant the Held By role B<ShowAsset> and B<ShowCatalog> globally and
+B<SeeCustomField> on the necessary asset CFs.
+
+=head2 People and Roles
+
+Just like tickets, assets have various roles which users and groups may be
+assigned to.  The intended usages of these roles are described below, but
+you're free to use them for whatever you'd like, of course.
+
+The roles provide ways to keep track of who is involved with each asset, as
+well as providing a place to grant rights that depend on the user's association
+with each asset.
+
+In addition to adding people to individual asset roles, you can also add role
+members at an entire catalog level.  These catalog-level roles are useful in
+cases when you might have an entire catalog of assets for which the same people
+should be the Contacts, or which are Held By the same group.  Unlike tickets
+where the queue watchers are invisible, catalog role members are visible
+because assets are generally much longer lived than tickets.  When a problem
+with an asset arises, it's easier to see who to create a ticket for.  On
+individual asset pages, catalog role members are shown with the text "(via this
+asset's catalog)" following each name.
+
+=head3 Owner
+
+The person responsible for the asset, perhaps the purchaser or manager.
+
+Restricted to a single user.  Not available at a catalog level.
+
+=head3 Held By
+
+The person or people who physically possess the asset or are actively using the
+asset (if it isn't physical).  This may be the same as the Contacts or may be
+different.  For example, a computer workstation may be "held by" a university
+professor, but the contact may be the IT staff member responsible for all
+assets in the professor's department.  This role is most similar to Requestor
+on tickets, although not equivalent.
+
+May be multiple users and/or groups.
+
+=head3 Contact
+
+The person or people who should be contacted with questions, problems,
+notifications, etc. about the asset.  Contacts share some of the same intended
+usages of both Requestors and Ccs on tickets.
+
+May be multiple users and/or groups.
+
+=head2 Lifecycle statuses
+
+One of the basic asset fields is "Status".  Similar to tickets, the valid
+statuses and their transitions and actions can be customized via RT's standard
+Lifecycles configuration (see "Lifecycles" in F<RT_Config.pm>).  The default
+lifecycle is named "assets".  You're free to modify it as much as you'd like,
+or add your own lifecycles.  Each catalog may have its own lifecycle.
+
+For the default "assets" configuration, see F<etc/Assets_Config.pm>.
+
+=head2 Field organization
+
+=head3 Groupings
+
+You can organize your asset CFs into visual and logical "groupings" as you see
+fit.  These groupings appear as separate boxes on the asset display page and
+become separate pages for editing (showing up in the per-asset menu).
+
+By default your CFs will appear in a B<Custom Fields> box on the asset display
+page and will be editable from a box of the same name on the B<Basics> editing
+page.
+
+Using the C<%CustomFieldGroupings> option (documented in F<etc/RT_Config.pm>),
+you can move individual CFs by name into one of the four built-in groupings
+(B<Basics>, B<People>, B<Dates>, and B<Links>) or create your own just by
+naming it.  An example, assuming a date CF named "Purchased" and two "enter one
+value" CFs named "Weight" and "Color":
+
+    # In etc/RT_SiteConfig.pm
+    Set(%CustomFieldGroupings,
+        'RT::Asset' => {
+            'Dates'                 => ['Purchased'],
+            'Physical Properties'   => ['Weight', 'Color'],
+        },
+    );
+
+This configuration snippet will move all three CFs out of the generic B<Custom
+Fields> box and into the B<Dates> box and a new box titled B<Physical
+Properties>.  The "Purchased" CF will be editable from the Dates page and a new
+page titled "Physical Properties" will appear in the menu to allow editing of
+the "Weight" and "Color" CFs.
+
+=head3 Ordering
+
+Within a box, CFs come after any built-in asset fields such as Name,
+Description, Created, Last Updated, etc.  The CFs themselves are ordered
+according to the sorting seen (and adjustable) on the global Asset Custom
+Fields page (Tools → Configuration → Global → Custom Fields → Assets) and the
+individual catalog Custom Fields pages (Tools → Configuration → Assets →
+Catalogs → (Pick one) → Custom Fields).
+
+Global asset CFs may be intermixed with per-catalog CFs with ordering.
+
+=head2 Importing existing data
+
+Another extension, L<RT::Extension::Assets::Import::CSV> provides tools
+to import new and update existing assets from a CSV dump.  Its
+configuration lets you map the fields in the CSV to the asset fields
+you've already created in RT.  L<RT::Extension::Assets::AppleGSX> also
+provides tools for looking up data associated with an Apple product.
+
+=cut
diff --git a/docs/customizing/assets_tutorial.pod b/docs/customizing/assets_tutorial.pod
new file mode 100644
index 0000000..43f1aec
--- /dev/null
+++ b/docs/customizing/assets_tutorial.pod
@@ -0,0 +1,327 @@
+
+=head1 Introduction
+
+This is a basic tutorial for setting up asset tracking in RT using Best
+Practical's Assets extension. At the end, you'll have a basic configuration
+that lets you add assets, search for them, link them to tickets, etc.
+
+=begin HTML
+
+<p><img src="http://static.bestpractical.com/images/assets/asset-search.png"
+alt="Asset Search Results" /></p>
+
+=end HTML
+
+You can follow along with the tutorial and try setting things up yourself to
+get a feel for all of the administrative controls. If you want to get a jump
+start, the files to set up this basic configuration are provided in the
+L<RT::Extension::Assets> distribution in the F<etc> directory. For
+configuration, look in F<etc/Tutorial_Configuration.txt>. You can copy all or
+part of it and paste it into your F<RT_SiteConfig.pm>.
+
+To load the test catalog, custom fields, and users, follow the
+installation instructions in L<RT::Extension::Assets/INSTALLATION>, then
+run the following from your RT directory:
+
+    sbin/rt-setup-database --action insert --datafile \
+      local/plugins/RT-Extension-Assets/etc/tutorialdata
+
+This will change the default catalog name, create some users, and give those
+users asset permissions. Only asset rights are granted, so you need to grant
+additional rights if you want to experiment with creating tickets and linking
+assets. The initial user passwords are 'password'. You should only run this on
+a test RT instance, as it is not intended to be used for configuring a
+production system.
+
+=head1 Getting Started
+
+Install the extension following the instructions and some new tables will be
+added to your RT database and the assets code will be installed. As with all
+extensions, first add C<RT::Extension::Assets> to your C<@Plugins> line.
+
+There are a few configuration options you might set before starting. Assets
+offers a C<$DefaultCatalog> feature that works similar to RT's
+L<DefaultQueue|http://bestpractical.com/docs/rt/latest/RT_Config.html#DefaultQueue>,
+but you can probably skip it for now since you don't have any catalogs yet.
+
+More interesting are some optional portlets you can activate to add asset data
+to RT's pages. MyAssets and FindAsset portlets are available for placement on
+RT at a Glance or in dashboards and a UserAssets portlet is available for the
+user summary pages.
+
+These portlets are fairly self-explanatory and you can add them by finding
+C<$HomepageComponents> and C<@UserSummaryPortlets> respectively in
+F<RT_Config.pm>, copying to F<RT_SiteConfig.pm>, and adding the portlets you
+want. There are also examples in the tutorial sample configuration file. Note
+that C<$HomepageComponents> makes the portlets available, but doesn't put them
+on RT at a Glance. To add them, just click the Edit link on the upper right-hand
+corner of the homepage. C<@UserSummaryPortlets> does automatically add the
+"Assigned Assets" portlet to the User Summary page. It will appear based on the
+position in the configuration, so just place it in the list where you want it
+to appear.
+
+Once you have your configuration complete, restart your server and you're ready
+to go.
+
+=head1 Catalogs
+
+For the initial configuration, we'll log in as RT's root user so we have full
+rights on the asset configuration. You could also create an "Asset Admin" group
+and assign appropriate rights to allow other users to manage catalogs.
+
+When you log in you'll see a new Assets menu, but before looking there we need
+to look at catalogs and some other configuration. Catalogs are to assets what
+queues are to tickets, so if you've used RT, the relationship should be fairly
+familiar. Similar to the General queue, a "General assets" catalog is provided
+to get you started. You can see it at Admin > Assets > Catalogs.
+
+We're going to use the default, but change it to a name more appropriate for
+our use. Clicking on the asset name brings us to the catalog edit page and we
+can update the name to "IT Department Assets". You can update the description
+if you like as well.
+
+=begin HTML
+
+<p><img src="http://static.bestpractical.com/images/assets/edit-catalog.png"
+alt="Edit Catalog" /></p>
+
+=end HTML
+
+You'll also notice that catalogs have a lifecycle just like queues. The assets
+extension comes with a default assets lifecycle, but just like queues you can
+create new ones with custom statuses and other configuration to allow RT to
+reflect the states of your assets.
+
+You can find the asset lifecycle in the asset configuration file in your RT
+installation at:
+
+    local/plugins/RT-Extension-Assets/etc/Assets_Config.pm
+
+The initial statuses are new, allocated, in-use, recycled, stolen, and deleted.
+Depending on your process, you might add new ones like surplussed, donated, or
+in-repair. To create a new asset lifecycle, just copy the default into
+F<RT_SiteConfig.pm>, replace the top-level "assets" key with a new name, and
+make your changes.
+
+=head1 Asset Custom Fields
+
+Next we need to create some custom fields to hold our asset metadata. You can
+find asset custom fields at Admin > Assets > Custom Fields and they work just
+like custom fields for other RT objects.
+
+=begin HTML
+
+<p><img src="http://static.bestpractical.com/images/assets/asset-cfs.png"
+alt="Asset Custom Fields" /></p>
+
+=end HTML
+
+The extension will automatically provide some core values for your assets. Each
+asset can have a Name and Description and, like tickets, they have statuses
+based on the lifecycle configuration. You can use Name and Description however
+you want and they are not required. However, many of the asset pages use these
+fields so it's best to provide a descriptive name to make it easy for people
+working in RT to identify the asset quickly. The manufacturer's product name
+can be convenient (e.g., '15" Macbook Pro').
+
+Assets come with three user fields you can associate with an asset: Owner, Held
+By, and Contact. These are provided to cover different types of assets, from
+laptops to servers to software, and different asset management situations.
+Owner can hold the user who bought the asset, maybe the head of the department
+where the budget came from. Held by is who the asset is assigned to. Laptops
+are assigned to a user and servers might be held (or managed) by the system
+administrators. Contact can be used to set a manager who might need to know
+about needed system updates or equipment with expiring support. Like tickets,
+these roles give you places to attach rights, so use them however they work
+best for you.
+
+Any other information you want to track will need custom fields. We'll start
+with a few basic fields:
+
+=over
+
+=item * Serial Number (enter one value)
+
+The serial number from the asset.
+
+=item * Tracking Number (enter one value)
+
+An internal tracking number. RT will assign an asset ID as well, but you may
+have other systems to integrate with or already have a way to assign asset ids
+for accounting purposes.
+
+=item * Manufacturer (dropdown)
+
+Company that made the asset.
+
+=item * Type (dropdown)
+
+Is it a laptop, server, or cell phone?
+
+=item * Issue Date (date)
+
+When the asset was given to the owner (or held by) person.
+
+Assets keep a transaction history like tickets, so you may be able pull this
+information from the "owner set to X" transaction. Creating a separate field
+makes it easier to report on.
+
+=item * Support Expiration (date)
+
+When the current support contract expires.
+
+=back
+
+=head1 Custom Field Grouping
+
+Any custom fields you create will be displayed on the asset display page in a
+default "Custom Fields" section. That may be sufficient, but assets also
+supports RT's new custom field grouping feature, so we can group together some
+similar custom fields and give them a custom name. If we add the following to
+F<RT_SiteConfig.pm>:
+
+    Set(%CustomFieldGroupings,
+        'RT::Asset' => {
+            'Asset Details' => ['Serial Number', 'Manufacturer', 'Type', 'Tracking Number'],
+            'Dates'         => ['Support Expiration', 'Issue Date'],
+        },
+    );
+
+and restart RT, the dates will be tacked on the end of the Dates portlet and we
+get an Asset Details label on the other custom fields.
+
+=begin HTML
+
+<p><img
+src="http://static.bestpractical.com/images/assets/asset-date-details.png"
+alt="Asset Date and Details Display" /></p>
+
+=end HTML
+
+=head1 Asset Rights
+
+Now we've got the basic configuration in place to start recording asset data.
+Next we need to assign some rights so people can view and edit asset
+information. Our staff are all privileged users so we'll grant all view and
+modify rights on our catalog to the Privileged role. We'll also include rights
+to view and modify the catalog's custom fields, although you could set these
+rights individually on each custom field if you wanted to allow users to see
+some but not others.
+
+Similar to queues, you can set rights at the catalog level. Go to Admin >
+Assets > Catalogs and click on the catalog you want to edit. Click Group Rights
+in the submenu to assign asset rights to groups like the system Privileged
+group.
+
+=begin HTML
+
+<p><img src="http://static.bestpractical.com/images/assets/catalog-rights.png"
+alt="Catalog Rights" /></p>
+
+=end HTML
+
+We also want unprivileged users to be able to see their own assets to make it
+easier to submit support requests. To give them just the Name and Description
+on their own assets, we can grant SeeAssets and SeeCatalogs on the catalog to
+the Held By role.
+
+All of the asset rights are described in the Assets documentation. You can get
+much more detailed and fine-grained than this example, allowing selected groups
+and users to view and modify multiple different asset custom fields across many
+different catalogs.
+
+=head1 Working with Assets
+
+So now that we have all of that configuration done, what can we do? Here are a
+few scenarios to give you some ideas.
+
+=head2 Add Assets to Your Catalogs
+
+To start, staff can now start adding assets to RT allowing you to manage what
+you have, what state it's in, who currently has it, and when support expires.
+You could set up an intake process to get new assets added as they come in, and
+eventually have statuses updated as they are assigned, used, and eventually
+cycled out.
+
+If you already have an asset database, even something simple like a
+spreadsheet, you may be able to do an initial bulk import. Best Practical has
+released L<RT::Extension::Assets::Import::CSV> which is a CSV import tool to
+help you with this.
+
+=head2 Track Assets
+
+Your staff can now easily track work on assets by linking RT tickets to the
+assets. Assume you have an issue with an asset, like a server needs a new power
+supply. Your staff can use the asset search page to find the server. You'll
+notice that the RT search box is context sensitive, so when you're on an asset
+page, the search changes to Search Assets and you can search with that as well.
+
+Once you locate the server asset record, in the Actions menu you'll find
+"Create linked ticket", which does just that. You select the queue and which
+user to use from the asset as the Requestor, and you land on the ticket create
+page with some information pre-filled.
+
+=begin HTML
+
+<p><img
+src="http://static.bestpractical.com/images/assets/asset-ticket-create.png"
+alt="Create Ticket for Asset Work" /></p>
+
+=end HTML
+
+As you can see in the screenshot, when you create a ticket with a linked asset,
+you get an asset portlet on the create page and on the ticket display page as
+well. If you navigate back to the asset, you'll see a link back to the ticket
+in the Links section there. This gives you a record of all the tickets that
+have been opened against this asset. If this is a common scenario for you, you
+might even add a custom field on the ticket with the vendor tracking number of
+the repair. During the repair, you might flip the asset to an 'in-repair'
+status. Then when the ticket is resolved, flip it back to 'in-use'.
+
+=head2 End User Asset Tickets
+
+If an end user contacts us with some problems with their laptop, RT makes it
+easy to find the correct laptop record and create a ticket for them. Since our
+support staff do this frequently, they have added the Find User portlet to
+their RT at a glance page and can quickly search for the user and go to their
+User Summary page (new in RT 4.2).
+
+We have added the Assigned Assets portlet to the User Summary page, so the
+laptop is right there on the page when we find the user. We can just click on
+the asset, then use the "Create linked ticket" action as before to create the
+new repair ticket.
+
+=head2 End User Self Service
+
+Assume we already assign passwords to our unprivileged users so they can use
+RT's self service interface to submit tickets and they have basic permissions
+to do so (SeeQueue on the designated queue, CreateTicket, etc.). Since we've
+given some asset rights to unprivileged users, they can use RT's Self Service
+interface to find their assets (e.g., laptops, cell phones, etc.) when
+submitting support requests.
+
+When they log into the self service interface, they will see an Assets menu
+that takes them to a page displaying assets assigned to them. In our example
+configuration, this is based on the Held by setting we set when we gave out the
+laptop. When they navigate to the asset, they will see the Actions menu with
+the same "Create linked ticket" action our staff uses. When they click on that,
+they'll end up on the simplified ticket create page for self service. When the
+ticket is created, the laptop will already be linked to it, saving our staff
+the work.
+
+=begin HTML
+
+<p><img
+src="http://static.bestpractical.com/images/assets/asset-ticket-create-selfservice.png"
+alt="Self Service Ticket for Asset Work" /></p>
+
+=end HTML
+
+=head1 Summary
+
+This tutorial is only a quick overview showing how the assets extension can
+help you track assets. There are many more features you'll find as you explore
+the assets interface, like stacking multiple assets on a single ticket, bulk
+update features similar to tickets, and the search interface. Have fun!
+
+=cut
diff --git a/etc/RT_Config.pm.in b/etc/RT_Config.pm.in
index fd9e971..bb7c095 100644
--- a/etc/RT_Config.pm.in
+++ b/etc/RT_Config.pm.in
@@ -1644,7 +1644,138 @@ Set($HideArticleSearchOnReplyCreate, 0);
 
 =back
 
+=head1 Assets
 
+=over 4
+
+=item C<@AssetQueues>
+
+This should be a list of names of queues whose tickets should always
+display the "Assets" box.  This is useful for queues which deal
+primarily with assets, as it provides a ready box to link an asset to
+the ticket, even when the ticket has no related assets yet.
+
+=cut
+
+# Set(@AssetQueues, ());
+
+=item C<$DefaultCatalog>
+
+This provides the default catalog after a user initially logs in.
+However, the default catalog is "sticky," and so will remember the
+last-selected catalog thereafter.
+
+=cut
+
+# Set($DefaultCatalog, 'General assets');
+
+=item C<$AssetSearchFields>
+
+Specifies which fields of L<RT::Asset> to match against and how to match
+each field when performing a quick search on assets.  Valid match
+methods are LIKE, STARTSWITH, ENDSWITH, =, and !=.  Valid search fields
+are id, Name, Description, or custom fields, which are specified as
+"CF.1234" or "CF.Name"
+
+=cut
+
+Set($AssetSearchFields, {
+    id          => '=',
+    Name        => 'LIKE',
+    Description => 'LIKE',
+}) unless $AssetSearchFields;
+
+=item C<$AssetSearchFormat>
+
+The format that results of the asset search are displayed with.  This is
+either a string, which will be used for all catalogs, or a hash
+reference, keyed by catalog's name/id.  If a hashref and neither name or
+id is found therein, falls back to the key ''.
+
+If you wish to use the multiple catalog format, your configuration would look
+something like:
+
+    Set($AssetSearchFormat, {
+        'General assets' => q[Format String for the General Assets Catalog],
+        8                => q[Format String for Catalog 8],
+        ''               => q[Format String for any catalogs not listed explicitly],
+    });
+
+=cut
+
+# loc('Related tickets')
+Set($AssetSearchFormat, q[
+    '<a href="__WebHomePath__/Asset/Display.html?id=__id__">__Name__</a>/TITLE:Name',
+    Description,
+    '__Status__ (__Catalog__)/TITLE:Status',
+    Owner,
+    HeldBy,
+    Contacts,
+    '__ActiveTickets__ __InactiveTickets__/TITLE:Related tickets',
+]) unless $AssetSearchFormat;
+
+=item C<$AssetSummaryFormat>
+
+The information that is displayed on ticket display pages about assets
+related to the ticket.  This is displayed in a table beneath the asset
+name.
+
+=cut
+
+Set($AssetSummaryFormat, q[
+    '<a href="__WebHomePath__/Asset/Display.html?id=__id__">__Name__</a>/TITLE:Name',
+    Description,
+    '__Status__ (__Catalog__)/TITLE:Status',
+    Owner,
+    HeldBy,
+    Contacts,
+    '__ActiveTickets__ __InactiveTickets__/TITLE:Related tickets',
+]) unless $AssetSummaryFormat;
+
+=item C<$AssetSummaryRelatedTicketsFormat>
+
+The information that is displayed on ticket display pages about tickets
+related to assets related to the ticket.  This is displayed as a list of
+tickets underneath the asset properties.
+
+=cut
+
+Set($AssetSummaryRelatedTicketsFormat, q[
+    '<a href="__WebPath__/Ticket/Display.html?id=__id__">__id__</a>',
+    '(__OwnerName__)',
+    '<a href="__WebPath__/Ticket/Display.html?id=__id__">__Subject__</a>',
+    QueueName,
+    Status,
+]) unless $AssetSummaryRelatedTicketsFormat;
+
+=item C<%AdminSearchResultFormat>
+
+The C<Catalogs> key of this standard RT configuration option (see
+L<RT_Config/%AdminSearchResultFormat>) controls how catalogs are
+displayed in their list in the admin pages.
+
+=cut
+
+Set(%AdminSearchResultFormat,
+    Catalogs =>
+        q{'<a href="__WebPath__/Admin/Assets/Catalogs/Modify.html?id=__id__">__id__</a>/TITLE:#'}
+        .q{,'<a href="__WebPath__/Admin/Assets/Catalogs/Modify.html?id=__id__">__Name__</a>/TITLE:Name'}
+        .q{,__Description__,__Lifecycle__,__Disabled__},
+) unless $AdminSearchResultFormat{Catalogs};
+
+=item C<$AssetBasicCustomFieldsOnCreate>
+
+Specify a list of Asset custom fields to show in "Basics" widget on create.
+
+e.g.
+
+Set( $AssetBasicCustomFieldsOnCreate, [ 'foo', 'bar' ] );
+
+=cut
+
+# Set($AssetBasicCustomFieldsOnCreate, undef );
+
+=back
 
 =head2 Message box properties
 
@@ -2359,8 +2490,6 @@ Set($TimeInICal, 0);
 
 =back
 
-
-
 =head1 Cryptography
 
 A complete description of RT's cryptography capabilities can be found in
@@ -2875,6 +3004,52 @@ Set(%Lifecycles,
             'deleted -> open'  => { label  => 'Undelete',                    }, # loc{label}
         ],
     },
+    assets => {
+        type     => "asset",
+        initial  => [ 
+            'new' # loc
+        ],
+        active   => [ 
+            'allocated', # loc
+            'in-use' # loc
+        ],
+        inactive => [ 
+            'recycled', # loc
+            'stolen', # loc
+            'deleted' # loc
+        ],
+
+        defaults => {
+            on_create => 'new',
+        },
+
+        transitions => {
+            ''        => [qw(new allocated in-use)],
+            new       => [qw(allocated in-use stolen deleted)],
+            allocated => [qw(in-use recycled stolen deleted)],
+            "in-use"  => [qw(allocated recycled stolen deleted)],
+            recycled  => [qw(allocated)],
+            stolen    => [qw(allocated)],
+            deleted   => [qw(allocated)],
+        },
+        rights => {
+            '* -> *'        => 'ModifyAsset',
+        },
+        actions => {
+            '* -> allocated' => { 
+                label => "Allocate" # loc
+            },
+            '* -> in-use'    => { 
+                label => "Now in-use" # loc
+            },
+            '* -> recycled'  => { 
+                label => "Recycle" # loc
+            },
+            '* -> stolen'    => { 
+                label => "Report stolen" # loc
+            },
+        },
+    },
 );
 
 
diff --git a/etc/acl.Pg b/etc/acl.Pg
index a659d8e..3c05399 100644
--- a/etc/acl.Pg
+++ b/etc/acl.Pg
@@ -80,6 +80,35 @@ sub acl {
         }
     }
     return (@acls);
+
+{ # START assets ACL
+    my @tables = qw (
+        assets_id_seq
+        Assets
+        catalogs_id_seq
+        Catalogs
+    );
+
+    my $db_user = RT->Config->Get('DatabaseUser');
+
+    my $sequence_right
+        = ( $dbh->{pg_server_version} >= 80200 )
+        ? "USAGE, SELECT, UPDATE"
+        : "SELECT, UPDATE";
+
+    foreach my $table (@tables) {
+        # Tables are upper-case, sequences are lowercase in @tables
+        if ( $table =~ /^[a-z]/ ) {
+            push @acls, "GRANT $sequence_right ON $table TO \"$db_user\";"
+        }
+        else {
+            push @acls, "GRANT SELECT, INSERT, UPDATE, DELETE ON $table TO \"$db_user\";"
+        }
+    }
+
+} # END Assets ACL
+
+    return (@acls);
 }
 
 1;
diff --git a/etc/initialdata b/etc/initialdata
index dd1daf5..f876940 100644
--- a/etc/initialdata
+++ b/etc/initialdata
@@ -886,3 +886,33 @@ Hour:         { $SubscriptionObj->SubValue('Hour') }
         },
     },
 );
+
+require RT::Asset;
+# Create global role groups
+push @Final, sub {
+    foreach my $type (RT::Asset->Roles) {
+        next if $type eq "Owner";   # There's a core global role group for Owner
+
+        my $group = RT::Group->new( RT->SystemUser );
+        my ($ok, $msg) = $group->CreateRoleGroup(
+            Object              => RT->System,
+            Name                => $type,
+            InsideTransaction   => 0,
+        );
+        RT->Logger->error("Couldn't create global asset role group '$type': $msg")
+            unless $ok;
+    }
+};
+
+# Create default catalog
+push @Final, sub {
+    my $catalog = RT::Catalog->new( RT->SystemUser );
+    my ($ok, $msg) = $catalog->Create(
+        Name        => "General assets",
+        Description => "The default catalog",
+    );
+    RT->Logger->error("Couldn't create default catalog 'General assets': $msg")
+        unless $ok;
+};
+
+1;
diff --git a/etc/schema.Oracle b/etc/schema.Oracle
index effefc5..950c696 100644
--- a/etc/schema.Oracle
+++ b/etc/schema.Oracle
@@ -487,3 +487,35 @@ Created DATE,
 LastUpdatedBy NUMBER(11,0) DEFAULT 0 NOT NULL,
 LastUpdated DATE
 );
+CREATE SEQUENCE Assets_seq;
+CREATE TABLE Assets (
+    id              NUMBER(11,0)    CONSTRAINT Assets_key PRIMARY KEY,
+    Name            varchar2(255)   DEFAULT '',
+    Catalog         NUMBER(11,0)    DEFAULT 0 NOT NULL,
+    Status          varchar2(64)    DEFAULT '',
+    Description     varchar2(255)   DEFAULT '',
+    Creator         NUMBER(11,0)    DEFAULT 0 NOT NULL,
+    Created         DATE,
+    LastUpdatedBy   NUMBER(11,0)    DEFAULT 0 NOT NULL,
+    LastUpdated     DATE
+);
+
+CREATE INDEX AssetsName ON Assets (LOWER(Name));
+CREATE INDEX AssetsStatus ON Assets (Status);
+CREATE INDEX AssetsCatalog ON Assets (Catalog);
+
+CREATE SEQUENCE Catalogs_seq;
+CREATE TABLE Catalogs (
+    id              NUMBER(11,0)    CONSTRAINT Catalogs_key PRIMARY KEY,
+    Name            varchar2(255)   DEFAULT '',
+    Lifecycle       varchar2(32)    DEFAULT 'assets',
+    Description     varchar2(255)   DEFAULT '',
+    Disabled        NUMBER(11,0)    DEFAULT 0 NOT NULL,
+    Creator         NUMBER(11,0)    DEFAULT 0 NOT NULL,
+    Created         DATE,
+    LastUpdatedBy   NUMBER(11,0)    DEFAULT 0 NOT NULL,
+    LastUpdated     DATE
+);
+
+CREATE INDEX CatalogsName ON Catalogs (LOWER(Name));
+CREATE INDEX CatalogsDisabled ON Catalogs (Disabled);
diff --git a/etc/schema.Pg b/etc/schema.Pg
index e5e2a04..6fc6aaa 100644
--- a/etc/schema.Pg
+++ b/etc/schema.Pg
@@ -719,3 +719,37 @@ LastUpdated TIMESTAMP NULL,
 PRIMARY KEY (id)
 );
 
+CREATE SEQUENCE assets_id_seq;
+CREATE TABLE Assets (
+    id                integer                  DEFAULT nextval('assets_id_seq'),
+    Name              varchar(255)    NOT NULL DEFAULT '',
+    Catalog           integer         NOT NULL DEFAULT 0,
+    Status            varchar(64)     NOT NULL DEFAULT '',
+    Description       varchar(255)    NOT NULL DEFAULT '',
+    Creator           integer         NOT NULL DEFAULT 0,
+    Created           timestamp                DEFAULT NULL,
+    LastUpdatedBy     integer         NOT NULL DEFAULT 0,
+    LastUpdated       timestamp                DEFAULT NULL,
+    PRIMARY KEY (id)
+);
+
+CREATE INDEX AssetsName ON Assets (LOWER(Name));
+CREATE INDEX AssetsStatus ON Assets (Status);
+CREATE INDEX AssetsCatalog ON Assets (Catalog);
+
+CREATE SEQUENCE catalogs_id_seq;
+CREATE TABLE Catalogs (
+    id                integer                  DEFAULT nextval('catalogs_id_seq'),
+    Name              varchar(255)    NOT NULL DEFAULT '',
+    Lifecycle         varchar(32)     NOT NULL DEFAULT 'assets',
+    Description       varchar(255)    NOT NULL DEFAULT '',
+    Disabled          integer         NOT NULL DEFAULT 0,
+    Creator           integer         NOT NULL DEFAULT 0,
+    Created           timestamp                DEFAULT NULL,
+    LastUpdatedBy     integer         NOT NULL DEFAULT 0,
+    LastUpdated       timestamp                DEFAULT NULL,
+    PRIMARY KEY (id)
+);
+
+CREATE INDEX CatalogsName ON Catalogs (LOWER(Name));
+CREATE INDEX CatalogsDisabled ON Catalogs (Disabled);
diff --git a/etc/schema.SQLite b/etc/schema.SQLite
index c50e5b1..8364b33 100644
--- a/etc/schema.SQLite
+++ b/etc/schema.SQLite
@@ -519,3 +519,33 @@ Created TIMESTAMP NULL,
 LastUpdatedBy integer NOT NULL DEFAULT 0,
 LastUpdated TIMESTAMP NULL
 );
+CREATE TABLE Assets (
+    id                INTEGER PRIMARY KEY,
+    Name              varchar(255)    NOT NULL DEFAULT '',
+    Catalog           int(11)         NOT NULL DEFAULT 0,
+    Status            varchar(64)     NOT NULL DEFAULT '',
+    Description       varchar(255)    NOT NULL DEFAULT '',
+    Creator           int(11)         NOT NULL DEFAULT 0,
+    Created           timestamp                DEFAULT NULL,
+    LastUpdatedBy     int(11)         NOT NULL DEFAULT 0,
+    LastUpdated       timestamp                DEFAULT NULL
+);
+
+CREATE INDEX AssetsName on Assets (Name);
+CREATE INDEX AssetsStatus ON Assets (Status);
+CREATE INDEX AssetsCatalog ON Assets (Catalog);
+
+CREATE TABLE Catalogs (
+    id                INTEGER PRIMARY KEY,
+    Name              varchar(255)    NOT NULL DEFAULT '',
+    Lifecycle         varchar(32)     NOT NULL DEFAULT 'assets',
+    Description       varchar(255)    NOT NULL DEFAULT '',
+    Disabled          int2            NOT NULL DEFAULT 0,
+    Creator           int(11)         NOT NULL DEFAULT 0,
+    Created           timestamp                DEFAULT NULL,
+    LastUpdatedBy     int(11)         NOT NULL DEFAULT 0,
+    LastUpdated       timestamp                DEFAULT NULL
+);
+
+CREATE INDEX CatalogsName on Catalogs (Name);
+CREATE INDEX CatalogsDisabled ON Catalogs (Disabled);
diff --git a/etc/schema.mysql b/etc/schema.mysql
index da14e72..b12127b 100644
--- a/etc/schema.mysql
+++ b/etc/schema.mysql
@@ -508,3 +508,35 @@ CREATE TABLE ObjectClasses (
   LastUpdated datetime default NULL,
   PRIMARY KEY  (id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+CREATE TABLE Assets (
+    id                int(11)         NOT NULL AUTO_INCREMENT,
+    Name              varchar(255)    NOT NULL DEFAULT '',
+    Catalog           int(11)         NOT NULL DEFAULT 0,
+    Status            varchar(64)     NOT NULL DEFAULT '',
+    Description       varchar(255)    NOT NULL DEFAULT '',
+    Creator           int(11)         NOT NULL DEFAULT 0,
+    Created           datetime                 DEFAULT NULL,
+    LastUpdatedBy     int(11)         NOT NULL DEFAULT 0,
+    LastUpdated       datetime                 DEFAULT NULL,
+    PRIMARY KEY (id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
+CREATE INDEX AssetsName ON Assets (Name);
+CREATE INDEX AssetsStatus ON Assets (Status);
+CREATE INDEX AssetsCatalog ON Assets (Catalog);
+
+CREATE TABLE Catalogs (
+    id                int(11)         NOT NULL AUTO_INCREMENT,
+    Name              varchar(255)    NOT NULL DEFAULT '',
+    Lifecycle         varchar(32)     NOT NULL DEFAULT 'assets',
+    Description       varchar(255)    NOT NULL DEFAULT '',
+    Disabled          int2            NOT NULL DEFAULT 0,
+    Creator           int(11)         NOT NULL DEFAULT 0,
+    Created           datetime                 DEFAULT NULL,
+    LastUpdatedBy     int(11)         NOT NULL DEFAULT 0,
+    LastUpdated       datetime                 DEFAULT NULL,
+    PRIMARY KEY (id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
+CREATE INDEX CatalogsName ON Catalogs (Name);
+CREATE INDEX CatalogsDisabled ON Catalogs (Disabled);
diff --git a/etc/upgrade/4.3.10/content b/etc/upgrade/4.3.10/content
new file mode 100644
index 0000000..93d0e9a
--- /dev/null
+++ b/etc/upgrade/4.3.10/content
@@ -0,0 +1,31 @@
+
+require RT::Asset;
+
+# Create global role groups
+push @Final, sub {
+    foreach my $type (RT::Asset->Roles) {
+        next if $type eq "Owner";   # There's a core global role group for Owner
+
+        my $group = RT::Group->new( RT->SystemUser );
+        my ($ok, $msg) = $group->CreateRoleGroup(
+            Object              => RT->System,
+            Name                => $type,
+            InsideTransaction   => 0,
+        );
+        RT->Logger->error("Couldn't create global asset role group '$type': $msg")
+            unless $ok;
+    }
+};
+
+# Create default catalog
+push @Final, sub {
+    my $catalog = RT::Catalog->new( RT->SystemUser );
+    my ($ok, $msg) = $catalog->Create(
+        Name        => "General assets",
+        Description => "The default catalog",
+    );
+    RT->Logger->error("Couldn't create default catalog 'General assets': $msg")
+        unless $ok;
+};
+
+1;
diff --git a/etc/upgrade/4.3.10/schema.Oracle b/etc/upgrade/4.3.10/schema.Oracle
new file mode 100644
index 0000000..646b53e
--- /dev/null
+++ b/etc/upgrade/4.3.10/schema.Oracle
@@ -0,0 +1,33 @@
+
+CREATE SEQUENCE Assets_seq;
+CREATE TABLE Assets (
+    id              NUMBER(11,0)    CONSTRAINT Assets_key PRIMARY KEY,
+    Name            varchar2(255)   DEFAULT '',
+    Catalog         NUMBER(11,0)    DEFAULT 0 NOT NULL,
+    Status          varchar2(64)    DEFAULT '',
+    Description     varchar2(255)   DEFAULT '',
+    Creator         NUMBER(11,0)    DEFAULT 0 NOT NULL,
+    Created         DATE,
+    LastUpdatedBy   NUMBER(11,0)    DEFAULT 0 NOT NULL,
+    LastUpdated     DATE
+);
+
+CREATE INDEX AssetsName ON Assets (LOWER(Name));
+CREATE INDEX AssetsStatus ON Assets (Status);
+CREATE INDEX AssetsCatalog ON Assets (Catalog);
+
+CREATE SEQUENCE Catalogs_seq;
+CREATE TABLE Catalogs (
+    id              NUMBER(11,0)    CONSTRAINT Catalogs_key PRIMARY KEY,
+    Name            varchar2(255)   DEFAULT '',
+    Lifecycle       varchar2(32)    DEFAULT 'assets',
+    Description     varchar2(255)   DEFAULT '',
+    Disabled        NUMBER(11,0)    DEFAULT 0 NOT NULL,
+    Creator         NUMBER(11,0)    DEFAULT 0 NOT NULL,
+    Created         DATE,
+    LastUpdatedBy   NUMBER(11,0)    DEFAULT 0 NOT NULL,
+    LastUpdated     DATE
+);
+
+CREATE INDEX CatalogsName ON Catalogs (LOWER(Name));
+CREATE INDEX CatalogsDisabled ON Catalogs (Disabled);
diff --git a/etc/upgrade/4.3.10/schema.Pg b/etc/upgrade/4.3.10/schema.Pg
new file mode 100644
index 0000000..7194261
--- /dev/null
+++ b/etc/upgrade/4.3.10/schema.Pg
@@ -0,0 +1,35 @@
+
+CREATE SEQUENCE assets_id_seq;
+CREATE TABLE Assets (
+    id                integer                  DEFAULT nextval('assets_id_seq'),
+    Name              varchar(255)    NOT NULL DEFAULT '',
+    Catalog           integer         NOT NULL DEFAULT 0,
+    Status            varchar(64)     NOT NULL DEFAULT '',
+    Description       varchar(255)    NOT NULL DEFAULT '',
+    Creator           integer         NOT NULL DEFAULT 0,
+    Created           timestamp                DEFAULT NULL,
+    LastUpdatedBy     integer         NOT NULL DEFAULT 0,
+    LastUpdated       timestamp                DEFAULT NULL,
+    PRIMARY KEY (id)
+);
+
+CREATE INDEX AssetsName ON Assets (LOWER(Name));
+CREATE INDEX AssetsStatus ON Assets (Status);
+CREATE INDEX AssetsCatalog ON Assets (Catalog);
+
+CREATE SEQUENCE catalogs_id_seq;
+CREATE TABLE Catalogs (
+    id                integer                  DEFAULT nextval('catalogs_id_seq'),
+    Name              varchar(255)    NOT NULL DEFAULT '',
+    Lifecycle         varchar(32)     NOT NULL DEFAULT 'assets',
+    Description       varchar(255)    NOT NULL DEFAULT '',
+    Disabled          integer         NOT NULL DEFAULT 0,
+    Creator           integer         NOT NULL DEFAULT 0,
+    Created           timestamp                DEFAULT NULL,
+    LastUpdatedBy     integer         NOT NULL DEFAULT 0,
+    LastUpdated       timestamp                DEFAULT NULL,
+    PRIMARY KEY (id)
+);
+
+CREATE INDEX CatalogsName ON Catalogs (LOWER(Name));
+CREATE INDEX CatalogsDisabled ON Catalogs (Disabled);
diff --git a/etc/upgrade/4.3.10/schema.SQLite b/etc/upgrade/4.3.10/schema.SQLite
new file mode 100644
index 0000000..38c7ee0
--- /dev/null
+++ b/etc/upgrade/4.3.10/schema.SQLite
@@ -0,0 +1,31 @@
+
+CREATE TABLE Assets (
+    id                INTEGER PRIMARY KEY,
+    Name              varchar(255)    NOT NULL DEFAULT '',
+    Catalog           int(11)         NOT NULL DEFAULT 0,
+    Status            varchar(64)     NOT NULL DEFAULT '',
+    Description       varchar(255)    NOT NULL DEFAULT '',
+    Creator           int(11)         NOT NULL DEFAULT 0,
+    Created           timestamp                DEFAULT NULL,
+    LastUpdatedBy     int(11)         NOT NULL DEFAULT 0,
+    LastUpdated       timestamp                DEFAULT NULL
+);
+
+CREATE INDEX AssetsName on Assets (Name);
+CREATE INDEX AssetsStatus ON Assets (Status);
+CREATE INDEX AssetsCatalog ON Assets (Catalog);
+
+CREATE TABLE Catalogs (
+    id                INTEGER PRIMARY KEY,
+    Name              varchar(255)    NOT NULL DEFAULT '',
+    Lifecycle         varchar(32)     NOT NULL DEFAULT 'assets',
+    Description       varchar(255)    NOT NULL DEFAULT '',
+    Disabled          int2            NOT NULL DEFAULT 0,
+    Creator           int(11)         NOT NULL DEFAULT 0,
+    Created           timestamp                DEFAULT NULL,
+    LastUpdatedBy     int(11)         NOT NULL DEFAULT 0,
+    LastUpdated       timestamp                DEFAULT NULL
+);
+
+CREATE INDEX CatalogsName on Catalogs (Name);
+CREATE INDEX CatalogsDisabled ON Catalogs (Disabled);
diff --git a/etc/upgrade/4.3.10/schema.mysql b/etc/upgrade/4.3.10/schema.mysql
new file mode 100644
index 0000000..98c9f2b
--- /dev/null
+++ b/etc/upgrade/4.3.10/schema.mysql
@@ -0,0 +1,33 @@
+
+CREATE TABLE Assets (
+    id                int(11)         NOT NULL AUTO_INCREMENT,
+    Name              varchar(255)    NOT NULL DEFAULT '',
+    Catalog           int(11)         NOT NULL DEFAULT 0,
+    Status            varchar(64)     NOT NULL DEFAULT '',
+    Description       varchar(255)    NOT NULL DEFAULT '',
+    Creator           int(11)         NOT NULL DEFAULT 0,
+    Created           datetime                 DEFAULT NULL,
+    LastUpdatedBy     int(11)         NOT NULL DEFAULT 0,
+    LastUpdated       datetime                 DEFAULT NULL,
+    PRIMARY KEY (id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
+CREATE INDEX AssetsName ON Assets (Name);
+CREATE INDEX AssetsStatus ON Assets (Status);
+CREATE INDEX AssetsCatalog ON Assets (Catalog);
+
+CREATE TABLE Catalogs (
+    id                int(11)         NOT NULL AUTO_INCREMENT,
+    Name              varchar(255)    NOT NULL DEFAULT '',
+    Lifecycle         varchar(32)     NOT NULL DEFAULT 'assets',
+    Description       varchar(255)    NOT NULL DEFAULT '',
+    Disabled          int2            NOT NULL DEFAULT 0,
+    Creator           int(11)         NOT NULL DEFAULT 0,
+    Created           datetime                 DEFAULT NULL,
+    LastUpdatedBy     int(11)         NOT NULL DEFAULT 0,
+    LastUpdated       datetime                 DEFAULT NULL,
+    PRIMARY KEY (id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
+CREATE INDEX CatalogsName ON Catalogs (Name);
+CREATE INDEX CatalogsDisabled ON Catalogs (Disabled);
diff --git a/etc/upgrade/upgrade-assets.in b/etc/upgrade/upgrade-assets.in
new file mode 100644
index 0000000..d46980b
--- /dev/null
+++ b/etc/upgrade/upgrade-assets.in
@@ -0,0 +1,101 @@
+#!@PERL@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2015 Best Practical Solutions, LLC
+#                                          <sales at bestpractical.com>
+#
+# (Except where explicitly superseded by other copyright notices)
+#
+#
+# LICENSE:
+#
+# This work is made available to you under the terms of Version 2 of
+# the GNU General Public License. A copy of that license should have
+# been provided with this software, but in any event can be snarfed
+# from www.gnu.org.
+#
+# This work is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301 or visit their web page on the internet at
+# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+#
+#
+# CONTRIBUTION SUBMISSION POLICY:
+#
+# (The following paragraph is not intended to limit the rights granted
+# to you to modify and distribute this software under the terms of
+# the GNU General Public License and is only of importance to you if
+# you choose to contribute your changes and enhancements to the
+# community by submitting them to Best Practical Solutions, LLC.)
+#
+# By intentionally submitting any modifications, corrections or
+# derivatives to this work, or any other work intended for use with
+# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+# you are the copyright holder for those contributions and you grant
+# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+# royalty-free, perpetual, license to use, copy, create derivative
+# works based on those contributions, and sublicense and distribute
+# those contributions and any derivatives thereof.
+#
+# END BPS TAGGED BLOCK }}}
+use 5.10.1;
+use strict;
+use warnings;
+
+use lib "@LOCAL_LIB_PATH@";
+use lib "@RT_LIB_PATH@";
+
+use RT::Interface::CLI qw(Init);
+Init();
+
+my $db_name = RT->Config->Get('DatabaseName');
+my $db_type = RT->Config->Get('DatabaseType');
+
+my $dbh = $RT::Handle->dbh;
+
+my $found_assets_tables;
+foreach my $name ( $RT::Handle->_TableNames ) {
+    next unless $name =~ /^RTx_/i;
+    $found_assets_tables->{lc $name}++;
+}
+
+unless ( $found_assets_tables->{rtxassets} && $found_assets_tables->{rtxcatalogs} ) {
+    warn "Could not find RT::Extension::Assets data to migrate";
+    exit;
+}
+
+{ # port over Catalogs
+    my @columns = qw(id Name Lifecycle Description Disabled Creator Created LastUpdatedBy LastUpdated);
+    copy_tables('RTxCatalogs','Catalogs',\@columns);
+
+}
+
+
+{ # port over Assets
+    my @columns = qw(id Name Catalog Status Description Creator Created LastUpdatedBy LastUpdated);
+    copy_tables('RTxAssets','Assets',\@columns);
+}
+
+sub copy_tables {
+    my ($source, $dest, $columns) = @_;
+    my $column_list = join(', ',@$columns);
+    my $sql;
+    # SQLite: http://www.sqlite.org/lang_insert.html
+    if ( $db_type eq 'mysql' || $db_type eq 'SQLite' ) {
+        $sql = "insert into $dest ($column_list) select $column_list from $source";
+    }
+    # Oracle: http://www.adp-gmbh.ch/ora/sql/insert/select_and_subquery.html
+    elsif ( $db_type eq 'Pg' || $db_type eq 'Oracle' ) {
+        $sql = "insert into $dest ($column_list) (select $column_list from $source)";
+    }
+    $RT::Logger->debug($sql);
+    $dbh->do($sql);
+}
diff --git a/lib/RT.pm b/lib/RT.pm
index 4147992..339549f 100644
--- a/lib/RT.pm
+++ b/lib/RT.pm
@@ -477,6 +477,10 @@ sub InitClasses {
     require RT::Topics;
     require RT::Link;
     require RT::Links;
+    require RT::Catalog;
+    require RT::Catalogs;
+    require RT::Asset;
+    require RT::Assets;
 
     _BuildTableAttributes();
 
diff --git a/lib/RT/Asset.pm b/lib/RT/Asset.pm
new file mode 100644
index 0000000..d2fe338
--- /dev/null
+++ b/lib/RT/Asset.pm
@@ -0,0 +1,647 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+#                                          <sales at bestpractical.com>
+#
+# (Except where explicitly superseded by other copyright notices)
+#
+#
+# LICENSE:
+#
+# This work is made available to you under the terms of Version 2 of
+# the GNU General Public License. A copy of that license should have
+# been provided with this software, but in any event can be snarfed
+# from www.gnu.org.
+#
+# This work is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301 or visit their web page on the internet at
+# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+#
+#
+# CONTRIBUTION SUBMISSION POLICY:
+#
+# (The following paragraph is not intended to limit the rights granted
+# to you to modify and distribute this software under the terms of
+# the GNU General Public License and is only of importance to you if
+# you choose to contribute your changes and enhancements to the
+# community by submitting them to Best Practical Solutions, LLC.)
+#
+# By intentionally submitting any modifications, corrections or
+# derivatives to this work, or any other work intended for use with
+# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+# you are the copyright holder for those contributions and you grant
+# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+# royalty-free, perpetual, license to use, copy, create derivative
+# works based on those contributions, and sublicense and distribute
+# those contributions and any derivatives thereof.
+#
+# END BPS TAGGED BLOCK }}}
+
+use strict;
+use warnings;
+use 5.10.1;
+
+package RT::Asset;
+use base 'RT::Record';
+
+use Role::Basic "with";
+with "RT::Record::Role::Status",
+     "RT::Record::Role::Links",
+     "RT::Record::Role::Roles" => {
+         -rename => {
+             # We provide ACL'd wraps of these.
+             AddRoleMember    => "_AddRoleMember",
+             DeleteRoleMember => "_DeleteRoleMember",
+             RoleGroup        => "_RoleGroup",
+         },
+     };
+
+require RT::Catalog;
+require RT::CustomField;
+require RT::URI::asset;
+
+=head1 NAME
+
+RT::Asset - Represents a single asset record
+
+=cut
+
+sub LifecycleColumn { "Catalog" }
+
+# Assets are primarily built on custom fields
+RT::CustomField->RegisterLookupType( CustomFieldLookupType() => 'Assets' );
+RT::CustomField->RegisterBuiltInGroupings(
+    'RT::Asset' => [qw( Basics Dates People Links )]
+);
+
+# loc('Owner')
+# loc('HeldBy')
+# loc('Contact')
+for my $role ('Owner', 'HeldBy', 'Contact') {
+    state $i = 1;
+    RT::Asset->RegisterRole(
+        Name            => $role,
+        EquivClasses    => ["RT::Catalog"],
+        SortOrder       => $i++,
+        ( $role eq "Owner"
+            ? ( Single         => 1,
+                ACLOnlyInEquiv => 1, )
+            : () ),
+    );
+}
+
+=head1 DESCRIPTION
+
+An Asset is a small record object upon which zero to many custom fields are
+applied.  The core fields are:
+
+=over 4
+
+=item id
+
+=item Name
+
+Limited to 255 characters.
+
+=item Description
+
+Limited to 255 characters.
+
+=item Catalog
+
+=item Status
+
+=item Creator
+
+=item Created
+
+=item LastUpdatedBy
+
+=item LastUpdated
+
+=back
+
+All of these are readable through methods of the same name and mutable through
+methods of the same name with C<Set> prefixed.  The last four are automatically
+managed.
+
+=head1 METHODS
+
+=head2 Load ID or NAME
+
+Loads the specified Asset into the current object.
+
+=cut
+
+sub Load {
+    my $self = shift;
+    my $id   = shift;
+    return unless $id;
+
+    if ( $id =~ /\D/ ) {
+        return $self->LoadByCols( Name => $id );
+    }
+    else {
+        return $self->SUPER::Load($id);
+    }
+}
+
+=head2 Create PARAMHASH
+
+Create takes a hash of values and creates a row in the database.  Available keys are:
+
+=over 4
+
+=item Name
+
+=item Description
+
+=item Catalog
+
+Name or numeric ID
+
+=item CustomField-<ID>
+
+Sets the value for this asset of the custom field specified by C<< <ID> >>.
+
+C<< <ID> >> should be a numeric ID, but may also be a Name if and only if your
+custom fields have unique names.  Without unique names, the behaviour is
+undefined.
+
+=item Status
+
+=item Owner, HeldBy, Contact
+
+A single principal ID or array ref of principal IDs to add as members of the
+respective role groups for the new asset.
+
+User Names and EmailAddresses may also be used, but Groups must be referenced
+by ID.
+
+=item RefersTo, ReferredToBy, DependsOn, DependedOnBy, Parents, Children, and aliases
+
+Any of these link types accept either a single value or arrayref of values
+parseable by L<RT::URI>.
+
+=back
+
+Returns a tuple of (status, msg) on failure and (id, msg, non-fatal errors) on
+success, where the third value is an array reference of errors that occurred
+but didn't prevent creation.
+
+=cut
+
+sub Create {
+    my $self = shift;
+    my %args = (
+        Name            => '',
+        Description     => '',
+        Catalog         => undef,
+
+        Owner           => undef,
+        HeldBy          => undef,
+        Contact         => undef,
+
+        Status          => undef,
+        @_
+    );
+    my @non_fatal_errors;
+
+    return (0, $self->loc("Invalid Catalog"))
+        unless $self->ValidateCatalog( $args{'Catalog'} );
+
+    my $catalog = RT::Catalog->new( $self->CurrentUser );
+    $catalog->Load($args{'Catalog'});
+
+    $args{'Catalog'} = $catalog->id;
+
+    return (0, $self->loc("Permission Denied"))
+        unless $catalog->CurrentUserHasRight('CreateAsset');
+
+    return (0, $self->loc('Invalid Name (names may not be all digits)'))
+        unless $self->ValidateName( $args{'Name'} );
+
+    # XXX TODO: This status/lifecycle pattern is duplicated in RT::Ticket and
+    # should be refactored into a role helper.
+    my $cycle = $catalog->LifecycleObj;
+    unless ( defined $args{'Status'} && length $args{'Status'} ) {
+        $args{'Status'} = $cycle->DefaultOnCreate;
+    }
+
+    $args{'Status'} = lc $args{'Status'};
+    unless ( $cycle->IsValid( $args{'Status'} ) ) {
+        return ( 0,
+            $self->loc("Status '[_1]' isn't a valid status for assets.",
+                $self->loc($args{'Status'}))
+        );
+    }
+
+    unless ( $cycle->IsTransition( '' => $args{'Status'} ) ) {
+        return ( 0,
+            $self->loc("New assets cannot have status '[_1]'.",
+                $self->loc($args{'Status'}))
+        );
+    }
+
+    my $roles = {};
+    my @errors = $self->_ResolveRoles( $roles, %args );
+    return (0, @errors) if @errors;
+
+    RT->DatabaseHandle->BeginTransaction();
+
+    my ( $id, $msg ) = $self->SUPER::Create(
+        map { $_ => $args{$_} } grep {exists $args{$_}}
+            qw(id Name Description Catalog Status),
+    );
+    unless ($id) {
+        RT->DatabaseHandle->Rollback();
+        return (0, $self->loc("Asset create failed: [_1]", $msg));
+    }
+
+    # Let users who just created an asset see it until the end of this method.
+    $self->{_object_is_readable} = 1;
+
+    # Create role groups
+    unless ($self->_CreateRoleGroups()) {
+        RT->Logger->error("Couldn't create role groups for asset ". $self->id);
+        RT->DatabaseHandle->Rollback();
+        return (0, $self->loc("Couldn't create role groups for asset"));
+    }
+
+    # Figure out users for roles
+    push @non_fatal_errors, $self->_AddRolesOnCreate( $roles, map { $_ => sub {1} } $self->Roles );
+
+    # Add CFs
+    foreach my $key (keys %args) {
+        next unless $key =~ /^CustomField-(.+)$/i;
+        my $cf   = $1;
+        my @vals = ref $args{$key} eq 'ARRAY' ? @{ $args{$key} } : $args{$key};
+        foreach my $value (@vals) {
+            next unless defined $value;
+
+            my ( $cfid, $cfmsg ) = $self->AddCustomFieldValue(
+                (ref($value) eq 'HASH'
+                    ? %$value
+                    : (Value => $value)),
+                Field             => $cf,
+                RecordTransaction => 0
+            );
+            unless ($cfid) {
+                RT->DatabaseHandle->Rollback();
+                return (0, $self->loc("Couldn't add custom field value on create: [_1]", $cfmsg));
+            }
+        }
+    }
+
+    # Create transaction
+    my ( $txn_id, $txn_msg, $txn ) = $self->_NewTransaction( Type => 'Create' );
+    unless ($txn_id) {
+        RT->DatabaseHandle->Rollback();
+        return (0, $self->loc( 'Asset Create txn failed: [_1]', $txn_msg ));
+    }
+
+    # Add links
+    push @non_fatal_errors, $self->_AddLinksOnCreate(\%args);
+
+    RT->DatabaseHandle->Commit();
+
+    # Let normal ACLs take over.
+    delete $self->{_object_is_readable};
+
+    return ($id, $self->loc('Asset #[_1] created: [_2]', $self->id, $args{'Name'}), \@non_fatal_errors);
+}
+
+=head2 ValidateName NAME
+
+Requires that Names contain at least one non-digit.  Empty names are OK.
+
+=cut
+
+sub ValidateName {
+    my $self = shift;
+    my $name = shift;
+    return 1 unless defined $name and length $name;
+    return 0 unless $name =~ /\D/;
+    return 1;
+}
+
+=head2 ValidateCatalog
+
+Takes a catalog name or ID.  Returns true if the catalog exists and is not
+disabled, otherwise false.
+
+=cut
+
+sub ValidateCatalog {
+    my $self    = shift;
+    my $name    = shift;
+    my $catalog = RT::Catalog->new( $self->CurrentUser );
+    $catalog->Load($name);
+    return 1 if $catalog->id and not $catalog->Disabled;
+    return 0;
+}
+
+=head2 Delete
+
+Assets may not be deleted.  Always returns failure.
+
+You should disable the asset instead with C<< $asset->SetStatus('deleted') >>.
+
+=cut
+
+sub Delete {
+    my $self = shift;
+    return (0, $self->loc("Assets may not be deleted"));
+}
+
+=head2 CurrentUserHasRight RIGHTNAME
+
+Returns true if the current user has the right for this asset, or globally if
+this is called on an unloaded object.
+
+=cut
+
+sub CurrentUserHasRight {
+    my $self  = shift;
+    my $right = shift;
+
+    return (
+        $self->CurrentUser->HasRight(
+            Right        => $right,
+            Object       => ($self->id ? $self : RT->System),
+        )
+    );
+}
+
+=head2 CurrentUserCanSee
+
+Returns true if the current user can see the asset, either because they just
+created it or they have the I<ShowAsset> right.
+
+=cut
+
+sub CurrentUserCanSee {
+    my $self = shift;
+    return $self->{_object_is_readable} || $self->CurrentUserHasRight('ShowAsset');
+}
+
+=head2 URI
+
+Returns this asset's URI
+
+=cut
+
+sub URI {
+    my $self = shift;
+    my $uri = RT::URI::asset->new($self->CurrentUser);
+    return $uri->URIForObject($self);
+}
+
+=head2 CatalogObj
+
+Returns the L<RT::Catalog> object for this asset's catalog.
+
+=cut
+
+sub CatalogObj {
+    my $self = shift;
+    my $catalog = RT::Catalog->new($self->CurrentUser);
+    $catalog->Load( $self->__Value("Catalog") );
+    return $catalog;
+}
+
+=head2 SetCatalog
+
+Validates the supplied catalog and updates the column if valid.  Transitions
+Status if necessary.  Returns a (status, message) tuple.
+
+=cut
+
+sub SetCatalog {
+    my $self  = shift;
+    my $value = shift;
+
+    return (0, $self->loc("Permission Denied"))
+        unless $self->CurrentUserHasRight("ModifyAsset");
+
+    my ($ok, $msg, $status) = $self->_SetLifecycleColumn(
+        Value           => $value,
+        RequireRight    => "CreateAsset"
+    );
+    return ($ok, $msg);
+}
+
+
+=head2 Owner
+
+Returns an L<RT::User> object for this asset's I<Owner> role group.  On error,
+returns undef.
+
+=head2 HeldBy
+
+Returns an L<RT::Group> object for this asset's I<HeldBy> role group.  The object
+may be unloaded if permissions aren't satisfied.
+
+=head2 Contacts
+
+Returns an L<RT::Group> object for this asset's I<Contact> role
+group.  The object may be unloaded if permissions aren't satisfied.
+
+=cut
+
+sub Owner {
+    my $self  = shift;
+    my $group = $self->RoleGroup("Owner");
+    return unless $group and $group->id;
+    return $group->UserMembersObj->First;
+}
+sub HeldBy   { $_[0]->RoleGroup("HeldBy")  }
+sub Contacts { $_[0]->RoleGroup("Contact") }
+
+=head2 AddRoleMember
+
+Checks I<ModifyAsset> before calling L<RT::Record::Role::Roles/_AddRoleMember>.
+
+=cut
+
+sub AddRoleMember {
+    my $self = shift;
+
+    return (0, $self->loc("No permission to modify this asset"))
+        unless $self->CurrentUserHasRight("ModifyAsset");
+
+    return $self->_AddRoleMember(@_);
+}
+
+=head2 DeleteRoleMember
+
+Checks I<ModifyAsset> before calling L<RT::Record::Role::Roles/_DeleteRoleMember>.
+
+=cut
+
+sub DeleteRoleMember {
+    my $self = shift;
+
+    return (0, $self->loc("No permission to modify this asset"))
+        unless $self->CurrentUserHasRight("ModifyAsset");
+
+    return $self->_DeleteRoleMember(@_);
+}
+
+=head2 RoleGroup
+
+An ACL'd version of L<RT::Record::Role::Roles/_RoleGroup>.  Checks I<ShowAsset>.
+
+=cut
+
+sub RoleGroup {
+    my $self = shift;
+    if ($self->CurrentUserCanSee) {
+        return $self->_RoleGroup(@_);
+    } else {
+        return RT::Group->new( $self->CurrentUser );
+    }
+}
+
+=head1 INTERNAL METHODS
+
+Public methods, but you shouldn't need to call these unless you're
+extending Assets.
+
+=head2 CustomFieldLookupType
+
+=cut
+
+sub CustomFieldLookupType { "RT::Catalog-RT::Asset" }
+
+=head2 ACLEquivalenceObjects
+
+=cut
+
+sub ACLEquivalenceObjects {
+    my $self = shift;
+    return $self->CatalogObj;
+}
+
+=head2 ModifyLinkRight
+
+=cut
+
+# Used for StrictLinkACL and RT::Record::Role::Links.
+#
+# Historically StrictLinkACL has only applied between tickets, but
+# if you care about it enough to turn it on, you probably care when
+# linking an asset to an asset or an asset to a ticket.
+
+sub ModifyLinkRight { "ShowAsset" }
+
+=head2 LoadCustomFieldByIdentifier
+
+Finds and returns the custom field of the given name for the asset,
+overriding L<RT::Record/LoadCustomFieldByIdentifier> to look for
+catalog-specific CFs before global ones.
+
+=cut
+
+sub LoadCustomFieldByIdentifier {
+    my $self  = shift;
+    my $field = shift;
+
+    return $self->SUPER::LoadCustomFieldByIdentifier($field)
+        if ref $field or $field =~ /^\d+$/;
+
+    my $cf = RT::CustomField->new( $self->CurrentUser );
+    $cf->SetContextObject( $self );
+    $cf->LoadByNameAndCatalog( Name => $field, Catalog => $self->Catalog );
+    $cf->LoadByNameAndCatalog( Name => $field, Catalog => 0 ) unless $cf->id;
+    return $cf;
+}
+
+=head1 PRIVATE METHODS
+
+Documented for internal use only, do not call these from outside RT::Asset
+itself.
+
+=head2 _Set
+
+Checks if the current user can I<ModifyAsset> before calling C<SUPER::_Set>
+and records a transaction against this object if C<SUPER::_Set> was
+successful.
+
+=cut
+
+sub _Set {
+    my $self = shift;
+    my %args = (
+        Field => undef,
+        Value => undef,
+        @_
+    );
+
+    return (0, $self->loc("Permission Denied"))
+        unless $self->CurrentUserHasRight('ModifyAsset');
+
+    my $old = $self->_Value( $args{'Field'} );
+
+    my ($ok, $msg) = $self->SUPER::_Set(@_);
+
+    # Only record the transaction if the _Set worked
+    return ($ok, $msg) unless $ok;
+
+    my $txn_type = $args{Field} eq "Status" ? "Status" : "Set";
+
+    my ($txn_id, $txn_msg, $txn) = $self->_NewTransaction(
+        Type     => $txn_type,
+        Field    => $args{'Field'},
+        NewValue => $args{'Value'},
+        OldValue => $old,
+    );
+
+    # Ensure that we can read the transaction, even if the change just made
+    # the asset unreadable to us.  This is only in effect for the lifetime of
+    # $txn, i.e. as soon as this method returns.
+    $txn->{ _object_is_readable } = 1;
+
+    return ($txn_id, scalar $txn->BriefDescription);
+}
+
+=head2 _Value
+
+Checks L</CurrentUserCanSee> before calling C<SUPER::_Value>.
+
+=cut
+
+sub _Value {
+    my $self = shift;
+    return unless $self->CurrentUserCanSee;
+    return $self->SUPER::_Value(@_);
+}
+
+sub Table { "Assets" }
+
+sub _CoreAccessible {
+    {
+        id            => { read => 1, type => 'int(11)',        default => '' },
+        Name          => { read => 1, type => 'varchar(255)',   default => '',  write => 1 },
+        Status        => { read => 1, type => 'varchar(64)',    default => '',  write => 1 },
+        Description   => { read => 1, type => 'varchar(255)',   default => '',  write => 1 },
+        Catalog       => { read => 1, type => 'int(11)',        default => '0', write => 1 },
+        Creator       => { read => 1, type => 'int(11)',        default => '0', auto => 1 },
+        Created       => { read => 1, type => 'datetime',       default => '',  auto => 1 },
+        LastUpdatedBy => { read => 1, type => 'int(11)',        default => '0', auto => 1 },
+        LastUpdated   => { read => 1, type => 'datetime',       default => '',  auto => 1 },
+    }
+}
+
+RT::Base->_ImportOverlays();
+
+1;
diff --git a/lib/RT/Assets.pm b/lib/RT/Assets.pm
new file mode 100644
index 0000000..67b1c45
--- /dev/null
+++ b/lib/RT/Assets.pm
@@ -0,0 +1,311 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+#                                          <sales at bestpractical.com>
+#
+# (Except where explicitly superseded by other copyright notices)
+#
+#
+# LICENSE:
+#
+# This work is made available to you under the terms of Version 2 of
+# the GNU General Public License. A copy of that license should have
+# been provided with this software, but in any event can be snarfed
+# from www.gnu.org.
+#
+# This work is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301 or visit their web page on the internet at
+# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+#
+#
+# CONTRIBUTION SUBMISSION POLICY:
+#
+# (The following paragraph is not intended to limit the rights granted
+# to you to modify and distribute this software under the terms of
+# the GNU General Public License and is only of importance to you if
+# you choose to contribute your changes and enhancements to the
+# community by submitting them to Best Practical Solutions, LLC.)
+#
+# By intentionally submitting any modifications, corrections or
+# derivatives to this work, or any other work intended for use with
+# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+# you are the copyright holder for those contributions and you grant
+# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+# royalty-free, perpetual, license to use, copy, create derivative
+# works based on those contributions, and sublicense and distribute
+# those contributions and any derivatives thereof.
+#
+# END BPS TAGGED BLOCK }}}
+
+use strict;
+use warnings;
+
+package RT::Assets;
+use base 'RT::SearchBuilder';
+
+use Role::Basic "with";
+with "RT::SearchBuilder::Role::Roles" => { -rename => {RoleLimit => '_RoleLimit'}};
+
+use Scalar::Util qw/blessed/;
+
+=head1 NAME
+
+RT::Assets - a collection of L<RT::Asset> objects
+
+=head1 METHODS
+
+Only additional methods or overridden behaviour beyond the L<RT::SearchBuilder>
+(itself a L<DBIx::SearchBuilder>) class are documented below.
+
+=head2 LimitToActiveStatus
+
+=cut
+
+sub LimitToActiveStatus {
+    my $self = shift;
+
+    $self->Limit( FIELD => 'Status', VALUE => $_ )
+        for RT::Catalog->LifecycleObj->Valid('initial', 'active');
+}
+
+=head2 LimitCatalog
+
+Limit Catalog
+
+=cut
+
+sub LimitCatalog {
+    my $self = shift;
+    my %args = (
+        FIELD    => 'Catalog',
+        OPERATOR => '=',
+        @_
+    );
+
+    if ( $args{OPERATOR} eq '=' ) {
+        $self->{Catalog} = $args{VALUE};
+    }
+    $self->SUPER::Limit(%args);
+}
+
+=head2 Limit
+
+Defaults CASESENSITIVE to 0
+
+=cut
+
+sub Limit {
+    my $self = shift;
+    my %args = (
+        CASESENSITIVE => 0,
+        @_
+    );
+    $self->SUPER::Limit(%args);
+}
+
+=head2 RoleLimit
+
+Re-uses the underlying JOIN, if possible.
+
+=cut
+
+sub RoleLimit {
+    my $self = shift;
+    my %args = (
+        TYPE => '',
+        SUBCLAUSE => '',
+        OPERATOR => '=',
+        @_
+    );
+
+    my $key = "role-join-".join("-",map {$args{$_}//''} qw/SUBCLAUSE TYPE OPERATOR/);
+    my @ret = $self->_RoleLimit(%args, BUNDLE => $self->{$key} );
+    $self->{$key} = \@ret;
+}
+
+=head1 INTERNAL METHODS
+
+Public methods which encapsulate implementation details.  You shouldn't need to
+call these in normal code.
+
+=head2 AddRecord
+
+Checks the L<RT::Asset> is readable before adding it to the results
+
+=cut
+
+sub AddRecord {
+    my $self  = shift;
+    my $asset = shift;
+    return unless $asset->CurrentUserCanSee;
+
+    return if $asset->__Value('Status') eq 'deleted'
+        and not $self->{'allow_deleted_search'};
+
+    $self->SUPER::AddRecord($asset, @_);
+}
+
+=head2 NewItem
+
+Returns a new empty RT::Asset item
+
+=cut
+
+sub NewItem {
+    my $self = shift;
+    return RT::Asset->new( $self->CurrentUser );
+}
+
+=head1 PRIVATE METHODS
+
+=head2 _Init
+
+Sets default ordering by Name ascending.
+
+=cut
+
+sub _Init {
+    my $self = shift;
+
+    $self->OrderBy( FIELD => 'Name', ORDER => 'ASC' );
+    return $self->SUPER::_Init( @_ );
+}
+
+sub SimpleSearch {
+    my $self = shift;
+    my %args = (
+        Fields      => RT->Config->Get('AssetSearchFields'),
+        Catalog     => RT->Config->Get('DefaultCatalog'),
+        Term        => undef,
+        @_
+    );
+
+    # XXX: We only search a single catalog so that we can map CF names
+    # to their ids, as searching CFs by CF name is rather complicated
+    # and currently fails in odd ways.  Such a mapping obviously assumes
+    # that names are unique within the catalog, but ids are also
+    # allowable as well.
+    my $catalog;
+    if (ref $args{Catalog}) {
+        $catalog = $args{Catalog};
+    } else {
+        $catalog = RT::Catalog->new( $self->CurrentUser );
+        $catalog->Load( $args{Catalog} );
+    }
+
+    my %cfs;
+    my $cfs = $catalog->AssetCustomFields;
+    while (my $customfield = $cfs->Next) {
+        $cfs{$customfield->id} = $cfs{$customfield->Name}
+            = $customfield;
+    }
+
+    $self->LimitCatalog( VALUE => $catalog->id );
+
+    while (my ($name, $op) = each %{$args{Fields}}) {
+        $op = 'STARTSWITH'
+            unless $op =~ /^(?:LIKE|(?:START|END)SWITH|=|!=)$/i;
+
+        if ($name =~ /^CF\.(?:\{(.*)}|(.*))$/) {
+            my $cfname = $1 || $2;
+            $self->LimitCustomField(
+                CUSTOMFIELD     => $cfs{$cfname},
+                OPERATOR        => $op,
+                VALUE           => $args{Term},
+                ENTRYAGGREGATOR => 'OR',
+                SUBCLAUSE       => 'autocomplete',
+            ) if $cfs{$cfname};
+        } elsif ($name eq 'id' and $op =~ /(?:LIKE|(?:START|END)SWITH)$/i) {
+            $self->Limit(
+                FUNCTION        => "CAST( main.$name AS TEXT )",
+                OPERATOR        => $op,
+                VALUE           => $args{Term},
+                ENTRYAGGREGATOR => 'OR',
+                SUBCLAUSE       => 'autocomplete',
+            ) if $args{Term} =~ /^\d+$/;
+        } else {
+            $self->Limit(
+                FIELD           => $name,
+                OPERATOR        => $op,
+                VALUE           => $args{Term},
+                ENTRYAGGREGATOR => 'OR',
+                SUBCLAUSE       => 'autocomplete',
+            ) unless $args{Term} =~ /\D/ and $name eq 'id';
+        }
+    }
+    return $self;
+}
+
+sub OrderByCols {
+    my $self = shift;
+    my @res  = ();
+
+    my $class = $self->_RoleGroupClass;
+
+    for my $row (@_) {
+        if ($row->{FIELD} =~ /^CF\.(?:\{(.*)\}|(.*))$/) {
+            my $name = $1 || $2;
+            my $cf = RT::CustomField->new( $self->CurrentUser );
+            $cf->LoadByNameAndCatalog(
+                Name => $name,
+                Catalog => $self->{'Catalog'},
+            );
+            if ( $cf->id ) {
+                push @res, $self->_OrderByCF( $row, $cf->id, $cf );
+            }
+        } elsif ($row->{FIELD} =~ /^(\w+)(?:\.(\w+))?$/) {
+            my ($role, $subkey) = ($1, $2);
+            if ($class->HasRole($role)) {
+                $self->{_order_by_role}{ $role }
+                        ||= ( $self->_WatcherJoin( Name => $role, Class => $class) )[2];
+                push @res, {
+                    %$row,
+                    ALIAS => $self->{_order_by_role}{ $role },
+                    FIELD => $subkey || 'EmailAddress',
+                };
+            } else {
+                push @res, $row;
+            }
+        } else {
+            push @res, $row;
+        }
+    }
+    return $self->SUPER::OrderByCols( @res );
+}
+
+=head2 _DoSearch
+
+=head2 _DoCount
+
+Limits to non-deleted assets unless the C<allow_deleted_search> flag is set.
+
+=cut
+
+sub _DoSearch {
+    my $self = shift;
+    $self->Limit( FIELD => 'Status', OPERATOR => '!=', VALUE => 'deleted', SUBCLAUSE => "not_deleted" )
+        unless $self->{'allow_deleted_search'};
+    $self->SUPER::_DoSearch(@_);
+}
+
+sub _DoCount {
+    my $self = shift;
+    $self->Limit( FIELD => 'Status', OPERATOR => '!=', VALUE => 'deleted', SUBCLAUSE => "not_deleted" )
+        unless $self->{'allow_deleted_search'};
+    $self->SUPER::_DoCount(@_);
+}
+
+sub Table { "Assets" }
+
+RT::Base->_ImportOverlays();
+
+1;
diff --git a/lib/RT/Catalog.pm b/lib/RT/Catalog.pm
new file mode 100644
index 0000000..6da0a77
--- /dev/null
+++ b/lib/RT/Catalog.pm
@@ -0,0 +1,503 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+#                                          <sales at bestpractical.com>
+#
+# (Except where explicitly superseded by other copyright notices)
+#
+#
+# LICENSE:
+#
+# This work is made available to you under the terms of Version 2 of
+# the GNU General Public License. A copy of that license should have
+# been provided with this software, but in any event can be snarfed
+# from www.gnu.org.
+#
+# This work is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301 or visit their web page on the internet at
+# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+#
+#
+# CONTRIBUTION SUBMISSION POLICY:
+#
+# (The following paragraph is not intended to limit the rights granted
+# to you to modify and distribute this software under the terms of
+# the GNU General Public License and is only of importance to you if
+# you choose to contribute your changes and enhancements to the
+# community by submitting them to Best Practical Solutions, LLC.)
+#
+# By intentionally submitting any modifications, corrections or
+# derivatives to this work, or any other work intended for use with
+# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+# you are the copyright holder for those contributions and you grant
+# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+# royalty-free, perpetual, license to use, copy, create derivative
+# works based on those contributions, and sublicense and distribute
+# those contributions and any derivatives thereof.
+#
+# END BPS TAGGED BLOCK }}}
+
+use strict;
+use warnings;
+
+package RT::Catalog;
+use base 'RT::Record';
+
+use Role::Basic 'with';
+with "RT::Record::Role::Lifecycle",
+     "RT::Record::Role::Roles" => {
+         -rename => {
+             # We provide ACL'd wraps of these.
+             AddRoleMember    => "_AddRoleMember",
+             DeleteRoleMember => "_DeleteRoleMember",
+             RoleGroup        => "_RoleGroup",
+         },
+     },
+     "RT::Record::Role::Rights";
+
+require RT::ACE;
+
+=head1 NAME
+
+RT::Catalog - A logical set of assets
+
+=cut
+
+# For the Lifecycle role
+sub LifecycleType { "asset" }
+
+# Setup rights
+__PACKAGE__->AddRight( General => ShowCatalog  => 'See catalogs' ); #loc
+__PACKAGE__->AddRight( Admin   => AdminCatalog => 'Create, modify, and disable catalogs' ); #loc
+
+__PACKAGE__->AddRight( General => ShowAsset    => 'See assets' ); #loc
+__PACKAGE__->AddRight( Staff   => CreateAsset  => 'Create assets' ); #loc
+__PACKAGE__->AddRight( Staff   => ModifyAsset  => 'Modify assets' ); #loc
+
+__PACKAGE__->AddRight( General => SeeCustomField      => 'View custom field values' ); # loc
+__PACKAGE__->AddRight( Staff   => ModifyCustomField   => 'Modify custom field values' ); # loc
+
+RT::ACE->RegisterCacheHandler(sub {
+    my %args = (
+        Action      => "",
+        RightName   => "",
+        @_
+    );
+
+    return unless $args{Action}    =~ /^(Grant|Revoke)$/i
+              and $args{RightName} =~ /^(ShowCatalog|CreateAsset)$/;
+
+    RT::Catalog->CacheNeedsUpdate(1);
+});
+
+=head1 DESCRIPTION
+
+Catalogs are for assets what queues are for tickets or classes are for
+articles.
+
+It announces the rights for assets, and rights are granted at the catalog or
+global level.  Asset custom fields are either applied globally to all Catalogs
+or individually to specific Catalogs.
+
+=over 4
+
+=item id
+
+=item Name
+
+Limited to 255 characters.
+
+=item Description
+
+Limited to 255 characters.
+
+=item Lifecycle
+
+=item Disabled
+
+=item Creator
+
+=item Created
+
+=item LastUpdatedBy
+
+=item LastUpdated
+
+=back
+
+All of these are readable through methods of the same name and mutable through
+methods of the same name with C<Set> prefixed.  The last four are automatically
+managed.
+
+=head1 METHODS
+
+=head2 Load ID or NAME
+
+Loads the specified Catalog into the current object.
+
+=cut
+
+sub Load {
+    my $self = shift;
+    my $id   = shift;
+    return unless $id;
+
+    if ( $id =~ /\D/ ) {
+        return $self->LoadByCols( Name => $id );
+    }
+    else {
+        return $self->SUPER::Load($id);
+    }
+}
+
+=head2 Create PARAMHASH
+
+Create takes a hash of values and creates a row in the database.  Available keys are:
+
+=over 4
+
+=item Name
+
+=item Description
+
+=item Lifecycle
+
+=item HeldBy, Contact
+
+A single principal ID or array ref of principal IDs to add as members of the
+respective role groups for the new catalog.
+
+User Names and EmailAddresses may also be used, but Groups must be referenced
+by ID.
+
+=item Disabled
+
+=back
+
+Returns a tuple of (status, msg) on failure and (id, msg, non-fatal errors) on
+success, where the third value is an array reference of errors that occurred
+but didn't prevent creation.
+
+=cut
+
+sub Create {
+    my $self = shift;
+    my %args = (
+        Name            => '',
+        Description     => '',
+        Lifecycle       => 'assets',
+
+        HeldBy          => undef,
+        Contact         => undef,
+
+        Disabled        => 0,
+
+        @_
+    );
+    my @non_fatal_errors;
+
+    return (0, $self->loc("Permission Denied"))
+        unless $self->CurrentUserHasRight('AdminCatalog');
+
+    return (0, $self->loc('Invalid Name (names must be unique and may not be all digits)'))
+        unless $self->ValidateName( $args{'Name'} );
+
+    $args{'Lifecycle'} ||= 'assets';
+
+    return (0, $self->loc('[_1] is not a valid lifecycle', $args{'Lifecycle'}))
+        unless $self->ValidateLifecycle( $args{'Lifecycle'} );
+
+    RT->DatabaseHandle->BeginTransaction();
+
+    my ( $id, $msg ) = $self->SUPER::Create(
+        map { $_ => $args{$_} } qw(Name Description Lifecycle Disabled),
+    );
+    unless ($id) {
+        RT->DatabaseHandle->Rollback();
+        return (0, $self->loc("Catalog create failed: [_1]", $msg));
+    }
+
+    # Create role groups
+    unless ($self->_CreateRoleGroups()) {
+        RT->Logger->error("Couldn't create role groups for catalog ". $self->id);
+        RT->DatabaseHandle->Rollback();
+        return (0, $self->loc("Couldn't create role groups for catalog"));
+    }
+
+    # Figure out users for roles
+    my $roles = {};
+    push @non_fatal_errors, $self->_ResolveRoles( $roles, %args );
+    push @non_fatal_errors, $self->_AddRolesOnCreate( $roles, map { $_ => sub {1} } $self->Roles );
+
+    # Create transaction
+    my ( $txn_id, $txn_msg, $txn ) = $self->_NewTransaction( Type => 'Create' );
+    unless ($txn_id) {
+        RT->DatabaseHandle->Rollback();
+        return (0, $self->loc( 'Catalog Create txn failed: [_1]', $txn_msg ));
+    }
+
+    $self->CacheNeedsUpdate(1);
+    RT->DatabaseHandle->Commit();
+
+    return ($id, $self->loc('Catalog #[_1] created: [_2]', $self->id, $args{'Name'}), \@non_fatal_errors);
+}
+
+=head2 ValidateName NAME
+
+Requires that Names contain at least one non-digit and doesn't already exist.
+
+=cut
+
+sub ValidateName {
+    my $self = shift;
+    my $name = shift;
+    return 0 unless defined $name and length $name;
+    return 0 unless $name =~ /\D/;
+
+    my $catalog = RT::Catalog->new( RT->SystemUser );
+    $catalog->Load($name);
+    return 0 if $catalog->id;
+
+    return 1;
+}
+
+=head2 Delete
+
+Catalogs may not be deleted.  Always returns failure.
+
+You should disable the catalog instead using C<< $catalog->SetDisabled(1) >>.
+
+=cut
+
+sub Delete {
+    my $self = shift;
+    return (0, $self->loc("Catalogs may not be deleted"));
+}
+
+=head2 CurrentUserCanSee
+
+Returns true if the current user can see the catalog via the I<ShowCatalog> or
+I<AdminCatalog> rights.
+
+=cut
+
+sub CurrentUserCanSee {
+    my $self = shift;
+    return $self->CurrentUserHasRight('ShowCatalog')
+        || $self->CurrentUserHasRight('AdminCatalog');
+}
+
+=head2 Owner
+
+Returns an L<RT::User> object for this catalog's I<Owner> role group.  On error,
+returns undef.
+
+=head2 HeldBy
+
+Returns an L<RT::Group> object for this catalog's I<HeldBy> role group.  The object
+may be unloaded if permissions aren't satisfied.
+
+=head2 Contacts
+
+Returns an L<RT::Group> object for this catalog's I<Contact> role
+group.  The object may be unloaded if permissions aren't satisfied.
+
+=cut
+
+sub Owner {
+    my $self  = shift;
+    my $group = $self->RoleGroup("Owner");
+    return unless $group and $group->id;
+    return $group->UserMembersObj->First;
+}
+sub HeldBy   { $_[0]->RoleGroup("HeldBy")  }
+sub Contacts { $_[0]->RoleGroup("Contact") }
+
+=head2 AddRoleMember
+
+Checks I<AdminCatalog> before calling L<RT::Record::Role::Roles/_AddRoleMember>.
+
+=cut
+
+sub AddRoleMember {
+    my $self = shift;
+
+    return (0, $self->loc("No permission to modify this catalog"))
+        unless $self->CurrentUserHasRight("AdminCatalog");
+
+    return $self->_AddRoleMember(@_);
+}
+
+=head2 DeleteRoleMember
+
+Checks I<AdminCatalog> before calling L<RT::Record::Role::Roles/_DeleteRoleMember>.
+
+=cut
+
+sub DeleteRoleMember {
+    my $self = shift;
+
+    return (0, $self->loc("No permission to modify this catalog"))
+        unless $self->CurrentUserHasRight("AdminCatalog");
+
+    return $self->_DeleteRoleMember(@_);
+}
+
+=head2 RoleGroup
+
+An ACL'd version of L<RT::Record::Role::Roles/_RoleGroup>.  Checks I<ShowCatalog>.
+
+=cut
+
+sub RoleGroup {
+    my $self = shift;
+    if ($self->CurrentUserCanSee) {
+        return $self->_RoleGroup(@_);
+    } else {
+        return RT::Group->new( $self->CurrentUser );
+    }
+}
+
+=head2 AssetCustomFields
+
+Returns an L<RT::CustomFields> object containing all global and
+catalog-specific B<asset> custom fields.
+
+=cut
+
+sub AssetCustomFields {
+    my $self = shift;
+    my $cfs  = RT::CustomFields->new( $self->CurrentUser );
+    if ($self->CurrentUserCanSee) {
+        $cfs->SetContextObject( $self );
+        $cfs->LimitToGlobalOrObjectId( $self->Id );
+        $cfs->LimitToLookupType( RT::Asset->CustomFieldLookupType );
+        $cfs->ApplySortOrder;
+    } else {
+        $cfs->Limit( FIELD => 'id', VALUE => 0, SUBCLAUSE => 'acl' );
+    }
+    return ($cfs);
+}
+
+=head1 INTERNAL METHODS
+
+=head2 CacheNeedsUpdate
+
+Takes zero or one arguments.
+
+If a true argument is provided, marks any Catalog caches as needing an update.
+This happens when catalogs are created, disabled/enabled, or modified.  Returns
+nothing.
+
+If no arguments are provided, returns an epoch time that any catalog caches
+should be newer than.
+
+May be called as a class or object method.
+
+=cut
+
+sub CacheNeedsUpdate {
+    my $class  = shift;
+    my $update = shift;
+
+    if ($update) {
+        RT->System->SetAttribute(Name => 'CatalogCacheNeedsUpdate', Content => time);
+        return;
+    } else {
+        my $attribute = RT->System->FirstAttribute('CatalogCacheNeedsUpdate');
+        return $attribute ? $attribute->Content : 0;
+    }
+}
+
+=head1 PRIVATE METHODS
+
+Documented for internal use only, do not call these from outside RT::Catalog
+itself.
+
+=head2 _Set
+
+Checks if the current user can I<AdminCatalog> before calling C<SUPER::_Set>
+and records a transaction against this object if C<SUPER::_Set> was
+successful.
+
+=cut
+
+sub _Set {
+    my $self = shift;
+    my %args = (
+        Field => undef,
+        Value => undef,
+        @_
+    );
+
+    return (0, $self->loc("Permission Denied"))
+        unless $self->CurrentUserHasRight('AdminCatalog');
+
+    my $old = $self->_Value( $args{'Field'} );
+
+    my ($ok, $msg) = $self->SUPER::_Set(@_);
+
+    # Only record the transaction if the _Set worked
+    return ($ok, $msg) unless $ok;
+
+    my $txn_type = "Set";
+    if ($args{'Field'} eq "Disabled") {
+        if (not $old and $args{'Value'}) {
+            $txn_type = "Disabled";
+        }
+        elsif ($old and not $args{'Value'}) {
+            $txn_type = "Enabled";
+        }
+    }
+
+    $self->CacheNeedsUpdate(1);
+
+    my ($txn_id, $txn_msg, $txn) = $self->_NewTransaction(
+        Type     => $txn_type,
+        Field    => $args{'Field'},
+        NewValue => $args{'Value'},
+        OldValue => $old,
+    );
+    return ($txn_id, scalar $txn->BriefDescription);
+}
+
+=head2 _Value
+
+Checks L</CurrentUserCanSee> before calling C<SUPER::_Value>.
+
+=cut
+
+sub _Value {
+    my $self = shift;
+    return unless $self->CurrentUserCanSee;
+    return $self->SUPER::_Value(@_);
+}
+
+sub Table { "Catalogs" }
+
+sub _CoreAccessible {
+    {
+        id            => { read => 1, type => 'int(11)',        default => '' },
+        Name          => { read => 1, type => 'varchar(255)',   default => '',          write => 1 },
+        Description   => { read => 1, type => 'varchar(255)',   default => '',          write => 1 },
+        Lifecycle     => { read => 1, type => 'varchar(32)',    default => 'assets',    write => 1 },
+        Disabled      => { read => 1, type => 'int(2)',         default => '0',         write => 1 },
+        Creator       => { read => 1, type => 'int(11)',        default => '0', auto => 1 },
+        Created       => { read => 1, type => 'datetime',       default => '',  auto => 1 },
+        LastUpdatedBy => { read => 1, type => 'int(11)',        default => '0', auto => 1 },
+        LastUpdated   => { read => 1, type => 'datetime',       default => '',  auto => 1 },
+    }
+}
+
+RT::Base->_ImportOverlays();
+
+1;
diff --git a/lib/RT/Catalogs.pm b/lib/RT/Catalogs.pm
new file mode 100644
index 0000000..6ee6ee9
--- /dev/null
+++ b/lib/RT/Catalogs.pm
@@ -0,0 +1,130 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+#                                          <sales at bestpractical.com>
+#
+# (Except where explicitly superseded by other copyright notices)
+#
+#
+# LICENSE:
+#
+# This work is made available to you under the terms of Version 2 of
+# the GNU General Public License. A copy of that license should have
+# been provided with this software, but in any event can be snarfed
+# from www.gnu.org.
+#
+# This work is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301 or visit their web page on the internet at
+# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+#
+#
+# CONTRIBUTION SUBMISSION POLICY:
+#
+# (The following paragraph is not intended to limit the rights granted
+# to you to modify and distribute this software under the terms of
+# the GNU General Public License and is only of importance to you if
+# you choose to contribute your changes and enhancements to the
+# community by submitting them to Best Practical Solutions, LLC.)
+#
+# By intentionally submitting any modifications, corrections or
+# derivatives to this work, or any other work intended for use with
+# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+# you are the copyright holder for those contributions and you grant
+# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+# royalty-free, perpetual, license to use, copy, create derivative
+# works based on those contributions, and sublicense and distribute
+# those contributions and any derivatives thereof.
+#
+# END BPS TAGGED BLOCK }}}
+
+use strict;
+use warnings;
+
+package RT::Catalogs;
+use base 'RT::SearchBuilder';
+
+=head1 NAME
+
+RT::Catalogs - a collection of L<RT::Catalog> objects
+
+=head1 METHODS
+
+Only additional methods or overridden behaviour beyond the L<RT::SearchBuilder>
+(itself a L<DBIx::SearchBuilder>) class are documented below.
+
+=head2 Limit
+
+Defaults CASESENSITIVE to 0
+
+=cut
+
+sub Limit {
+    my $self = shift;
+    my %args = (
+        CASESENSITIVE => 0,
+        @_
+    );
+    $self->SUPER::Limit(%args);
+}
+
+=head1 INTERNAL METHODS
+
+Public methods which encapsulate implementation details.  You shouldn't need to
+call these in normal code.
+
+=head2 AddRecord
+
+Checks the L<RT::Catalog> is readable before adding it to the results
+
+=cut
+
+sub AddRecord {
+    my $self    = shift;
+    my $catalog = shift;
+    return unless $catalog->CurrentUserCanSee;
+
+    $self->SUPER::AddRecord($catalog, @_);
+}
+
+=head2 NewItem
+
+Returns a new empty RT::Catalog item
+
+=cut
+
+sub NewItem {
+    my $self = shift;
+    return RT::Catalog->new( $self->CurrentUser );
+}
+
+=head1 PRIVATE METHODS
+
+=head2 _Init
+
+Sets default ordering by Name ascending.
+
+=cut
+
+sub _Init {
+    my $self = shift;
+
+    $self->{'with_disabled_column'} = 1;
+
+    $self->OrderBy( FIELD => 'Name', ORDER => 'ASC' );
+    return $self->SUPER::_Init( @_ );
+}
+
+sub Table { "Catalogs" }
+
+RT::Base->_ImportOverlays();
+
+1;
diff --git a/lib/RT/CustomField.pm b/lib/RT/CustomField.pm
index 0603927..9627b1b 100644
--- a/lib/RT/CustomField.pm
+++ b/lib/RT/CustomField.pm
@@ -2331,6 +2331,91 @@ sub __DependsOn {
     return $self->SUPER::__DependsOn( %args );
 }
 
+=head2 LoadByNameAndCatalog
+
+Loads the described asset custom field, if one is found, into the current
+object.  This method only consults custom fields applied to L<RT::Catalog> for
+L<RT::Asset> objects.
+
+Takes a hash with the keys:
+
+=over
+
+=item Name
+
+A L<RT::CustomField> ID or Name which applies to L<assets|RT::Asset>.
+
+=item Catalog
+
+Optional.  An L<RT::Catalog> ID or Name.
+
+=back
+
+If Catalog is specified, only a custom field added to that Catalog will be loaded.
+
+If Catalog is C<0>, only global asset custom fields will be loaded.
+
+If no Catalog is specified, all asset custom fields are searched including
+global and catalog-specific CFs.
+
+Please note that this method may load a Disabled custom field if no others
+matching the same criteria are found.  Enabled CFs are preferentially loaded.
+
+=cut
+
+# To someday be merged into RT::CustomField::LoadByName
+sub LoadByNameAndCatalog {
+    my $self = shift;
+    my %args = (
+                Catalog => undef,
+                Name  => undef,
+                @_,
+               );
+
+    unless ( defined $args{'Name'} && length $args{'Name'} ) {
+        $RT::Logger->error("Couldn't load Custom Field without Name");
+        return wantarray ? (0, $self->loc("No name provided")) : 0;
+    }
+
+    # if we're looking for a catalog by name, make it a number
+    if ( defined $args{'Catalog'} && ($args{'Catalog'} =~ /\D/ || !$self->ContextObject) ) {
+        my $CatalogObj = RT::Catalog->new( $self->CurrentUser );
+        my ($ok, $msg) = $CatalogObj->Load( $args{'Catalog'} );
+        if ( $ok ){
+            $args{'Catalog'} = $CatalogObj->Id;
+        }
+        elsif ($args{'Catalog'}) {
+            RT::Logger->error("Unable to load catalog " . $args{'Catalog'} . $msg);
+            return (0, $msg);
+        }
+        $self->SetContextObject( $CatalogObj )
+          unless $self->ContextObject;
+    }
+
+    my $CFs = RT::CustomFields->new( $self->CurrentUser );
+    $CFs->SetContextObject( $self->ContextObject );
+    my $field = $args{'Name'} =~ /\D/? 'Name' : 'id';
+    $CFs->Limit( FIELD => $field, VALUE => $args{'Name'}, CASESENSITIVE => 0);
+
+    # Limit to catalog, if provided. This will also limit to RT::Asset types.
+    $CFs->LimitToCatalog( $args{'Catalog'} );
+
+    # When loading by name, we _can_ load disabled fields, but prefer
+    # non-disabled fields.
+    $CFs->FindAllRows;
+    $CFs->OrderByCols(
+                      {
+                       FIELD => "Disabled", ORDER => 'ASC' },
+                     );
+
+    # We only want one entry.
+    $CFs->RowsPerPage(1);
+
+    return (0, $self->loc("Not found")) unless my $first = $CFs->First;
+    return $self->LoadById( $first->id );
+}
+
+
 RT::Base->_ImportOverlays();
 
 1;
diff --git a/lib/RT/CustomFields.pm b/lib/RT/CustomFields.pm
index a93bfc8..7b86390 100644
--- a/lib/RT/CustomFields.pm
+++ b/lib/RT/CustomFields.pm
@@ -409,6 +409,37 @@ sub NewItem {
     return $res;
 }
 
+=head2 LimitToCatalog
+
+Takes a numeric L<RT::Catalog> ID.  Limits the L<RT::CustomFields> collection
+to only those fields applied directly to the specified catalog.  This limit is
+OR'd with other L</LimitToCatalog> and L<RT::CustomFields/LimitToObjectId>
+calls.
+
+Note that this will cause the collection to only return asset CFs.
+
+=cut
+
+sub LimitToCatalog  {
+    my $self = shift;
+    my $catalog = shift;
+
+    $self->Limit (ALIAS => $self->_OCFAlias,
+                  ENTRYAGGREGATOR => 'OR',
+                  FIELD => 'ObjectId',
+                  VALUE => "$catalog")
+      if defined $catalog;
+
+    $self->LimitToLookupType( RT::Asset->CustomFieldLookupType );
+    $self->ApplySortOrder;
+
+    unless ($self->ContextObject) {
+        my $obj = RT::Catalog->new( $self->CurrentUser );
+        $obj->Load( $catalog );
+        $self->SetContextObject( $obj );
+    }
+}
+
 RT::Base->_ImportOverlays();
 
 1;
diff --git a/lib/RT/Interface/Web.pm b/lib/RT/Interface/Web.pm
index 413c165..f5c2f91 100644
--- a/lib/RT/Interface/Web.pm
+++ b/lib/RT/Interface/Web.pm
@@ -127,6 +127,7 @@ sub JSFiles {
       forms.js
       event-registration.js
       late.js
+      assets.js
       /static/RichText/ckeditor.js
       }, RT->Config->Get('JSFiles');
 }
@@ -3868,6 +3869,250 @@ sub GetPrincipalsMap {
     return @map;
 }
 
+sub LoadCatalog {
+    my $id = shift
+        or Abort(loc("No catalog specified."));
+
+    my $catalog = RT::Catalog->new( $session{CurrentUser} );
+    $catalog->Load($id);
+
+    Abort(loc("Unable to find catalog [_1]", $id))
+        unless $catalog->id;
+
+    Abort(loc("You don't have permission to view this catalog."))
+        unless $catalog->CurrentUserCanSee;
+
+    return $catalog;
+}
+
+sub LoadAsset {
+    my $id = shift
+        or Abort(loc("No asset ID specified."));
+
+    my $asset = RT::Asset->new( $session{CurrentUser} );
+    $asset->Load($id);
+
+    Abort(loc("Unable to find asset #[_1]", $id))
+        unless $asset->id;
+
+    Abort(loc("You don't have permission to view this asset."))
+        unless $asset->CurrentUserCanSee;
+
+    return $asset;
+}
+
+sub ProcessRoleMembers {
+    my $object = shift;
+    my %ARGS   = (@_);
+    my @results;
+
+    for my $arg (keys %ARGS) {
+        if ($arg =~ /^Add(User|Group)RoleMember$/) {
+            next unless $ARGS{$arg} and $ARGS{"$arg-Role"};
+
+            my ($ok, $msg) = $object->AddRoleMember(
+                Type => $ARGS{"$arg-Role"},
+                $1   => $ARGS{$arg},
+            );
+            push @results, $msg;
+        }
+        elsif ($arg =~ /^SetRoleMember-(.+)$/) {
+            my $role = $1;
+            my $group = $object->RoleGroup($role);
+            next unless $group->id and $group->SingleMemberRoleGroup;
+            next if $ARGS{$arg} eq $group->UserMembersObj->First->Name;
+            my ($ok, $msg) = $object->AddRoleMember(
+                Type => $role,
+                User => $ARGS{$arg} || 'Nobody',
+            );
+            push @results, $msg;
+        }
+        elsif ($arg =~ /^(Add|Remove)RoleMember-(.+)$/) {
+            my $role = $2;
+            my $method = $1 eq 'Add'? 'AddRoleMember' : 'DeleteRoleMember';
+
+            my $is = 'User';
+            if ( ($ARGS{"$arg-Type"}||'') =~ /^(User|Group)$/ ) {
+                $is = $1;
+            }
+
+            my ($ok, $msg) = $object->$method(
+                Type        => $role,
+                ($ARGS{$arg} =~ /\D/
+                    ? ($is => $ARGS{$arg})
+                    : (PrincipalId => $ARGS{$arg})
+                ),
+            );
+            push @results, $msg;
+        }
+        elsif ($arg =~ /^RemoveAllRoleMembers-(.+)$/) {
+            my $role = $1;
+            my $group = $object->RoleGroup($role);
+            next unless $group->id;
+
+            my $gms = $group->MembersObj;
+            while ( my $gm = $gms->Next ) {
+                my ($ok, $msg) = $object->DeleteRoleMember(
+                    Type        => $role,
+                    PrincipalId => $gm->MemberId,
+                );
+                push @results, $msg;
+            }
+        }
+    }
+    return @results;
+}
+
+
+# If provided a catalog, load it and return the object.
+# If no catalog is passed, load the first active catalog.
+
+sub LoadDefaultCatalog {
+    my $catalog = shift;
+    my $catalog_obj = RT::Catalog->new($session{CurrentUser});
+
+    if ( $catalog ){
+        $catalog_obj->Load($catalog);
+        RT::Logger->error("Unable to load catalog: " . $catalog)
+            unless $catalog_obj->Id;
+    }
+    elsif ( $session{'DefaultCatalog'} ){
+        $catalog_obj->Load($session{'DefaultCatalog'});
+        RT::Logger->error("Unable to load remembered catalog: " .
+                          $session{'DefaultCatalog'})
+            unless $catalog_obj->Id;
+    }
+    elsif ( RT->Config->Get("DefaultCatalog") ){
+        $catalog_obj->Load( RT->Config->Get("DefaultCatalog") );
+        RT::Logger->error("Unable to load default catalog: "
+                          . RT->Config->Get("DefaultCatalog"))
+            unless $catalog_obj->Id;
+    }
+    else {
+        # If no catalog, default to the first active catalog
+        my $catalogs = RT::Catalogs->new($session{CurrentUser});
+        $catalogs->UnLimit;
+        $catalog_obj = $catalogs->First();
+        RT::Logger->error("No active catalogs.")
+            unless $catalog_obj and $catalog_obj->Id;
+    }
+
+    return $catalog_obj;
+}
+
+sub ProcessAssetsSearchArguments {
+    my %args = (
+        Catalog => undef,
+        Assets => undef,
+        ARGSRef => undef,
+        @_
+    );
+    my $ARGSRef = $args{'ARGSRef'};
+
+    my @PassArguments;
+
+    if ($ARGSRef->{q}) {
+        if ($ARGSRef->{q} =~ /^\d+$/) {
+            my $asset = RT::Asset->new( $session{CurrentUser} );
+            $asset->Load( $ARGSRef->{q} );
+            RT::Interface::Web::Redirect(
+                RT->Config->Get('WebURL')."Asset/Display.html?id=".$ARGSRef->{q}
+            ) if $asset->id;
+        }
+        $args{'Assets'}->SimpleSearch( Term => $ARGSRef->{q}, Catalog => $args{Catalog} );
+        push @PassArguments, "q";
+    } elsif ( $ARGSRef->{'SearchAssets'} ){
+        for my $key (keys %$ARGSRef) {
+            my $value = ref $ARGSRef->{$key} ? $ARGSRef->{$key}[0] : $ARGSRef->{$key};
+            next unless defined $value and length $value;
+
+            my $orig_key = $key;
+            my $negative = ($key =~ s/^!// ? 1 : 0);
+            if ($key =~ /^(Name|Description)$/) {
+                $args{'Assets'}->Limit(
+                    FIELD => $key,
+                    OPERATOR => ($negative ? 'NOT LIKE' : 'LIKE'),
+                    VALUE => $value,
+                    ENTRYAGGREGATOR => "AND",
+                );
+            } elsif ($key eq 'Catalog') {
+                $args{'Assets'}->LimitCatalog(
+                    OPERATOR => ($negative ? '!=' : '='),
+                    VALUE => $value,
+                    ENTRYAGGREGATOR => "AND",
+                );
+            } elsif ($key eq 'Status') {
+                $args{'Assets'}->Limit(
+                    FIELD => $key,
+                    OPERATOR => ($negative ? '!=' : '='),
+                    VALUE => $value,
+                    ENTRYAGGREGATOR => "AND",
+                );
+            } elsif ($key =~ /^Role\.(.+)/) {
+                my $role = $1;
+                $args{'Assets'}->RoleLimit(
+                    TYPE      => $role,
+                    FIELD     => $_,
+                    OPERATOR  => ($negative ? '!=' : '='),
+                    VALUE     => $value,
+                    SUBCLAUSE => $role,
+                    ENTRYAGGREGATOR => ($negative ? "AND" : "OR"),
+                    CASESENSITIVE   => 0,
+                ) for qw/EmailAddress Name/;
+            } elsif ($key =~ /^CF\.\{(.+?)\}$/ or $key =~ /^CF\.(.*)/) {
+                my $cf = RT::Asset->new( $session{CurrentUser} )
+                  ->LoadCustomFieldByIdentifier( $1 );
+                next unless $cf->id;
+                if ( $value eq 'NULL' ) {
+                    $args{'Assets'}->LimitCustomField(
+                        CUSTOMFIELD => $cf->Id,
+                        OPERATOR    => ($negative ? "IS NOT" : "IS"),
+                        VALUE       => 'NULL',
+                        QUOTEVALUE  => 0,
+                        ENTRYAGGREGATOR => "AND",
+                    );
+                } else {
+                    $args{'Assets'}->LimitCustomField(
+                        CUSTOMFIELD => $cf->Id,
+                        OPERATOR    => ($negative ? "NOT LIKE" : "LIKE"),
+                        VALUE       => $value,
+                        ENTRYAGGREGATOR => "AND",
+                    );
+                }
+            }
+            else {
+                next;
+            }
+            push @PassArguments, $orig_key;
+        }
+        push @PassArguments, 'SearchAssets';
+    }
+
+    my $Format = RT->Config->Get('AssetSearchFormat');
+    $Format = $Format->{$args{'Catalog'}->id}
+        || $Format->{$args{'Catalog'}->Name}
+        || $Format->{''} if ref $Format;
+    $Format ||= q[
+        '<b><a href="__WebPath__/Asset/Display.html?id=__id__">__id__</a></b>/TITLE:#',
+        '<b><a href="__WebPath__/Asset/Display.html?id=__id__">__Name__</a></b>/TITLE:Name',
+        Description,
+        Status,
+    ];
+
+    $ARGSRef->{OrderBy} ||= 'id';
+
+    push @PassArguments, qw/OrderBy Order Page/;
+
+    return (
+        OrderBy         => 'id',
+        Order           => 'ASC',
+        Rows            => 50,
+        (map { $_ => $ARGSRef->{$_} } grep { defined $ARGSRef->{$_} } @PassArguments),
+        PassArguments   => \@PassArguments,
+        Format          => $Format,
+    );
+}
+
 =head2 _load_container_object ( $type, $id );
 
 Instantiate container object for saving searches.
diff --git a/lib/RT/Lifecycle/Asset.pm b/lib/RT/Lifecycle/Asset.pm
new file mode 100644
index 0000000..88f0c6b
--- /dev/null
+++ b/lib/RT/Lifecycle/Asset.pm
@@ -0,0 +1,92 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+#                                          <sales at bestpractical.com>
+#
+# (Except where explicitly superseded by other copyright notices)
+#
+#
+# LICENSE:
+#
+# This work is made available to you under the terms of Version 2 of
+# the GNU General Public License. A copy of that license should have
+# been provided with this software, but in any event can be snarfed
+# from www.gnu.org.
+#
+# This work is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301 or visit their web page on the internet at
+# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+#
+#
+# CONTRIBUTION SUBMISSION POLICY:
+#
+# (The following paragraph is not intended to limit the rights granted
+# to you to modify and distribute this software under the terms of
+# the GNU General Public License and is only of importance to you if
+# you choose to contribute your changes and enhancements to the
+# community by submitting them to Best Practical Solutions, LLC.)
+#
+# By intentionally submitting any modifications, corrections or
+# derivatives to this work, or any other work intended for use with
+# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+# you are the copyright holder for those contributions and you grant
+# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+# royalty-free, perpetual, license to use, copy, create derivative
+# works based on those contributions, and sublicense and distribute
+# those contributions and any derivatives thereof.
+#
+# END BPS TAGGED BLOCK }}}
+
+use strict;
+use warnings;
+
+package RT::Lifecycle::Asset;
+__PACKAGE__->RegisterRights;
+
+use base qw(RT::Lifecycle);
+
+=head2 Catalogs
+
+Returns L<RT::Catalogs> collection with catalogs that use this lifecycle.
+
+=cut
+
+sub Catalogs {
+    my $self = shift;
+    require RT::Catalogs;
+    my $catalogs = RT::Catalogs->new( RT->SystemUser );
+    $catalogs->Limit( FIELD => 'Lifecycle', VALUE => $self->Name );
+    return $catalogs;
+}
+
+=head2 RegisterRights
+
+Asset lifecycle rights are registered (and thus grantable) at the
+catalog level.
+
+=cut
+
+sub RegisterRights {
+    my $self = shift;
+
+    my %rights = $self->RightsDescription( 'asset' );
+
+    require RT::ACE;
+
+    while ( my ($right, $description) = each %rights ) {
+        next if RT::ACE->CanonicalizeRightName( $right );
+
+        RT::Catalog->AddRight( Status => $right => $description );
+    }
+}
+
+1;
diff --git a/lib/RT/Test/Assets.pm b/lib/RT/Test/Assets.pm
new file mode 100644
index 0000000..75af2fd
--- /dev/null
+++ b/lib/RT/Test/Assets.pm
@@ -0,0 +1,83 @@
+use strict;
+use warnings;
+
+package RT::Test::Assets;
+use base 'RT::Test';
+
+our @EXPORT = qw(create_catalog create_asset create_assets create_cf apply_cfs);
+
+sub import {
+    my $class = shift;
+    my %args  = @_;
+
+    $class->SUPER::import( %args );
+    __PACKAGE__->export_to_level(1);
+}
+
+sub diag {
+    Test::More::diag(@_) if $ENV{TEST_VERBOSE};
+}
+
+sub create_catalog {
+    my %info  = @_;
+    my $catalog = RT::Catalog->new( RT->SystemUser );
+    my ($id, $msg) = $catalog->Create( %info );
+    if ($id) {
+        diag("Created catalog #$id: " . $catalog->Name);
+        return $catalog;
+    } else {
+        my $spec = join "/", map { "$_=$info{$_}" } keys %info;
+        RT->Logger->error("Failed to create catalog ($spec): $msg");
+        return;
+    }
+}
+
+sub create_asset {
+    my %info  = @_;
+    my $asset = RT::Asset->new( RT->SystemUser );
+    my ($id, $msg) = $asset->Create( %info );
+    if ($id) {
+        diag("Created asset #$id: " . $asset->Name);
+        return $asset;
+    } else {
+        my $spec = join "/", map { "$_=$info{$_}" } keys %info;
+        RT->Logger->error("Failed to create asset ($spec): $msg");
+        return;
+    }
+}
+
+sub create_assets {
+    my $error = 0;
+    for my $info (@_) {
+        create_asset(%$info)
+            or $error++;
+    }
+    return not $error;
+}
+
+sub create_cf {
+    my %args = (
+        Name        => "Test Asset CF ".($$ + rand(1024)),
+        Type        => "FreeformSingle",
+        LookupType  => RT::Asset->CustomFieldLookupType,
+        @_,
+    );
+    my $cf = RT::CustomField->new( RT->SystemUser );
+    my ($ok, $msg) = $cf->Create(%args);
+    RT->Logger->error("Can't create CF: $msg") unless $ok;
+    return $cf;
+}
+
+sub apply_cfs {
+    my $success = 1;
+    for my $cf (@_) {
+        my ($ok, $msg) = $cf->AddToObject( RT::Catalog->new(RT->SystemUser) );
+        if (not $ok) {
+            RT->Logger->error("Couldn't apply CF: $msg");
+            $success = 0;
+        }
+    }
+    return $success;
+}
+
+1;
diff --git a/lib/RT/Transaction.pm b/lib/RT/Transaction.pm
index d909171..b4a4a01 100644
--- a/lib/RT/Transaction.pm
+++ b/lib/RT/Transaction.pm
@@ -1285,7 +1285,16 @@ sub _FormatUser {
             "#reminder-", $ticket->id, \'">', $ticket->Subject, \'</a>'
         ];
         return ("Reminder '[_1]' completed", $subject); #loc()
-    }
+    },
+    'RT::Asset-Set-Catalog' => sub {
+        my $self = shift;
+        return ("[_1] changed from [_2] to [_3]",   #loc
+                $self->loc($self->Field), map {
+                    my $c = RT::Catalog->new($self->CurrentUser);
+                    $c->Load($_);
+                    $c->Name || $self->loc("~[a hidden catalog~]")
+                } $self->OldValue, $self->NewValue);
+    },
 );
 
 
diff --git a/lib/RT/URI/asset.pm b/lib/RT/URI/asset.pm
new file mode 100644
index 0000000..aeab115
--- /dev/null
+++ b/lib/RT/URI/asset.pm
@@ -0,0 +1,212 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+#                                          <sales at bestpractical.com>
+#
+# (Except where explicitly superseded by other copyright notices)
+#
+#
+# LICENSE:
+#
+# This work is made available to you under the terms of Version 2 of
+# the GNU General Public License. A copy of that license should have
+# been provided with this software, but in any event can be snarfed
+# from www.gnu.org.
+#
+# This work is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301 or visit their web page on the internet at
+# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+#
+#
+# CONTRIBUTION SUBMISSION POLICY:
+#
+# (The following paragraph is not intended to limit the rights granted
+# to you to modify and distribute this software under the terms of
+# the GNU General Public License and is only of importance to you if
+# you choose to contribute your changes and enhancements to the
+# community by submitting them to Best Practical Solutions, LLC.)
+#
+# By intentionally submitting any modifications, corrections or
+# derivatives to this work, or any other work intended for use with
+# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+# you are the copyright holder for those contributions and you grant
+# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+# royalty-free, perpetual, license to use, copy, create derivative
+# works based on those contributions, and sublicense and distribute
+# those contributions and any derivatives thereof.
+#
+# END BPS TAGGED BLOCK }}}
+
+use strict;
+use warnings;
+
+package RT::URI::asset;
+use base qw/RT::URI::base/;
+
+require RT::Asset;
+
+=head1 NAME
+
+RT::URI::asset - Internal URIs for linking to an L<RT::Asset>
+
+=head1 DESCRIPTION
+
+This class should rarely be used directly, but via L<RT::URI> instead.
+
+Represents, parses, and generates internal RT URIs such as:
+
+    asset:42
+    asset://example.com/42
+
+These URIs are used to link between objects in RT such as associating an asset
+with a ticket or an asset with another asset.
+
+=head1 METHODS
+
+Much of the interface below is dictated by L<RT::URI> and L<RT::URI::base>.
+
+=head2 Scheme
+
+Return the URI scheme for assets
+
+=cut
+
+sub Scheme { "asset" }
+
+=head2 LocalURIPrefix
+
+Returns the site-specific prefix for a local asset URI
+
+=cut
+
+sub LocalURIPrefix {
+    my $self = shift;
+    return $self->Scheme . "://" . RT->Config->Get('Organization') . "/";
+}
+
+=head2 IsLocal
+
+Returns a true value, the asset ID, if this object represents a local asset,
+undef otherwise.
+
+=cut
+
+sub IsLocal {
+    my $self   = shift;
+    my $prefix = $self->LocalURIPrefix;
+    return $1 if $self->{uri} =~ /^\Q$prefix\E(\d+)/i;
+    return undef;
+}
+
+=head2 URIForObject RT::Asset
+
+Returns the URI for a local L<RT::Asset> object
+
+=cut
+
+sub URIForObject {
+    my $self = shift;
+    my $obj  = shift;
+    return $self->LocalURIPrefix . $obj->Id;
+}
+
+=head2 ParseURI URI
+
+Primarily used by L<RT::URI> to set internal state.
+
+Figures out from an C<asset:> URI whether it refers to a local asset and the
+asset ID.
+
+Returns the asset ID if local, otherwise returns false.
+
+=cut
+
+sub ParseURI {
+    my $self = shift;
+    my $uri  = shift;
+
+    my $scheme = $self->Scheme;
+
+    # canonicalize "42" and "asset:42" -> asset://example.com/42
+    if ($uri =~ /^(?:\Q$scheme\E:)?(\d+)$/i) {
+        $self->{'uri'} = $self->LocalURIPrefix . $1;
+    }
+    else {
+        $self->{'uri'} = $uri;
+    }
+
+    my $asset = RT::Asset->new( $self->CurrentUser );
+    if ( my $id = $self->IsLocal ) {
+        $asset->Load($id);
+
+        if ($asset->id) {
+            $self->{'object'} = $asset;
+        } else {
+            RT->Logger->error("Can't load Asset #$id by URI '$uri'");
+            return;
+        }
+    }
+    return $asset->id;
+}
+
+=head2 Object
+
+Returns the object for this URI, if it's local. Otherwise returns undef.
+
+=cut
+
+sub Object {
+    my $self = shift;
+    return $self->{'object'};
+}
+
+=head2 HREF
+
+If this is a local asset, return an HTTP URL for it.
+
+Otherwise, return its URI.
+
+=cut
+
+sub HREF {
+    my $self = shift;
+    if ($self->IsLocal and $self->Object) {
+        return RT->Config->Get('WebURL')
+             . ( $self->CurrentUser->Privileged ? "" : "SelfService/" )
+             . "Asset/Display.html?id="
+             . $self->Object->Id;
+    } else {
+        return $self->URI;
+    }
+}
+
+=head2 AsString
+
+Returns a description of this object
+
+=cut
+
+sub AsString {
+    my $self = shift;
+    if ($self->IsLocal and $self->Object) {
+        my $object = $self->Object;
+        if ( $object->Name ) {
+            return $self->loc('Asset #[_1]: [_2]', $object->id, $object->Name);
+        } else {
+            return $self->loc('Asset #[_1]', $object->id);
+        }
+    } else {
+        return $self->SUPER::AsString(@_);
+    }
+}
+
+1;
diff --git a/share/html/Admin/Assets/Catalogs/Create.html b/share/html/Admin/Assets/Catalogs/Create.html
new file mode 100644
index 0000000..1179595
--- /dev/null
+++ b/share/html/Admin/Assets/Catalogs/Create.html
@@ -0,0 +1,87 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /Admin/Elements/Header, Title => loc("Create catalog") &>
+<& /Elements/Tabs &>
+<& /Elements/ListActions, actions => \@results &>
+
+<form method="post" enctype="multipart/form-data" id="CreateCatalog" action="Create.html">
+  <input type="hidden" name="id" value="new">
+
+  <&| /Widgets/TitleBox, title => loc("Basics"), class => "catalog-basics" &>
+    <& Elements/EditBasics, %ARGS, CatalogObj => $catalog &>
+  </&>
+
+  <& /Elements/Submit, Label => loc('Create catalog'), Name => "Update" &>
+</form>
+<%args>
+$id => ""
+</%args>
+<%init>
+my @results;
+my $catalog = RT::Catalog->new( $session{CurrentUser} );
+
+if ($id eq "new") {
+    my %create;
+    for ($catalog->WritableAttributes) {
+        $create{$_} = $ARGS{$_} if exists $ARGS{$_};
+    }
+
+    $m->callback( %ARGS, CatalogObj => $catalog, Create => \%create, CallbackName => 'MassageCreate' );
+
+    my ($ok, $msg, $nonfatal) = $catalog->Create( %create );
+    push @results, $msg, @{$nonfatal || []};
+
+    if ($ok) {
+        MaybeRedirectForResults(
+            Actions     => \@results,
+            Path        => "/Admin/Assets/Catalogs/Modify.html",
+            Arguments   => { id => $catalog->id },
+        );
+    }
+}
+</%init>
diff --git a/share/html/Admin/Assets/Catalogs/CustomFields.html b/share/html/Admin/Assets/Catalogs/CustomFields.html
new file mode 100644
index 0000000..a345c7d
--- /dev/null
+++ b/share/html/Admin/Assets/Catalogs/CustomFields.html
@@ -0,0 +1,61 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /Elements/Header, Title => loc("Catalog [_1]: Edit Custom Fields", $Catalog->Name) &>
+<& /Elements/Tabs &>
+<& /Admin/Elements/EditCustomFields,
+    %ARGS,
+    Object      => $Catalog,
+    ObjectType  => 'RT::Catalog',
+    SubType     => 'RT::Asset',
+    &>
+<%init>
+my $Catalog = LoadCatalog($id);
+</%init>
+<%args>
+$id => undef
+</%args>
diff --git a/share/html/Admin/Assets/Catalogs/Elements/EditBasics b/share/html/Admin/Assets/Catalogs/Elements/EditBasics
new file mode 100644
index 0000000..e084fb6
--- /dev/null
+++ b/share/html/Admin/Assets/Catalogs/Elements/EditBasics
@@ -0,0 +1,82 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<table>
+  <tr>
+    <td class="label"><label for="Name"><&|/l&>Name</&></label></td>
+    <td><input name="Name" value="<% $current{Name} %>" size="40"></td>
+  </tr>
+  <tr>
+    <td class="label"><label for="Description"><&|/l&>Description</&></label></td>
+    <td><input name="Description" value="<% $current{Description} %>" size="40"></td>
+  </tr>
+  <tr class="lifecycle">
+    <td class="label"><label for="Lifecycle"><&|/l&>Lifecycle</&></label></td>
+    <td>
+      <& /Widgets/Form/Select:InputOnly,
+          Name         => 'Lifecycle',
+          Values       => [ sort { loc($a) cmp loc($b) } RT::Lifecycle->List( $CatalogObj->LifecycleType ) ],
+          CurrentValue => $current{Lifecycle},
+          Default      => 0,
+      &>
+    </td>
+  </tr>
+  <tr>
+    <td class="label"><label for="Disabled"><&|/l&>Disabled?</&></label></td>
+    <td>
+      <input name="Disabled" type="checkbox" value="1" <% $current{Disabled} ? "checked" : "" %>>
+      <input name="SetDisabled" type="hidden" value="1">
+    </td>
+  </tr>
+</table>
+<%args>
+$CatalogObj
+</%args>
+<%init>
+my %current = map { $_ => ($ARGS{$_} || $CatalogObj->$_ || '') }
+                  $CatalogObj->WritableAttributes;
+</%init>
diff --git a/share/html/Admin/Assets/Catalogs/GroupRights.html b/share/html/Admin/Assets/Catalogs/GroupRights.html
new file mode 100644
index 0000000..333d476
--- /dev/null
+++ b/share/html/Admin/Assets/Catalogs/GroupRights.html
@@ -0,0 +1,64 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /Admin/Elements/Header, Title => loc("Catalog [_1]: Modify group rights", $Catalog->Name) &>
+<& /Elements/Tabs &>
+<& /Elements/ListActions, actions => \@results &>
+
+<form method="post" action="GroupRights.html" id="ModifyGroupRights" name="ModifyGroupRights">
+  <input type="hidden" class="hidden" name="id" value="<% $Catalog->id %>" />
+  <& /Admin/Elements/EditRights, Context => $Catalog, Principals => \@principals &>
+  <& /Elements/Submit, Label => loc('Save Changes') &>
+</form>
+<%init>
+my $Catalog    = LoadCatalog($id);
+my @results    = ProcessACLs(\%ARGS);
+my @principals = GetPrincipalsMap($Catalog, qw(System Roles Groups));
+</%init>
+<%args>
+$id => undef
+</%args>
diff --git a/share/html/Admin/Assets/Catalogs/Modify.html b/share/html/Admin/Assets/Catalogs/Modify.html
new file mode 100644
index 0000000..cf9a7aa
--- /dev/null
+++ b/share/html/Admin/Assets/Catalogs/Modify.html
@@ -0,0 +1,88 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /Admin/Elements/Header, Title => loc("Catalog [_1]: Modify basics", $catalog->Name) &>
+<& /Elements/Tabs &>
+<& /Elements/ListActions, actions => \@results &>
+
+<form method="post" enctype="multipart/form-data" id="ModifyCatalog" action="Modify.html">
+  <input type="hidden" name="id" value="<% $catalog->id %>">
+
+  <&| /Widgets/TitleBox, title => loc("Basics"), class => "catalog-basics" &>
+    <& Elements/EditBasics, %ARGS, CatalogObj => $catalog &>
+  </&>
+
+  <& /Elements/Submit, Label => loc('Save'), Name => "Update" &>
+</form>
+<%args>
+$id     => undef
+$Update => 0
+</%args>
+<%init>
+my @results;
+my $catalog = LoadCatalog($id);
+
+if ($Update) {
+    my @attributes = $catalog->WritableAttributes;
+
+    # Disabled isn't submitted if unchecked, so use our hidden field to know if
+    # it was included in the form.
+    $ARGS{Disabled} = $ARGS{Disabled} ? 1 : 0
+        if $ARGS{SetDisabled};
+
+    push @results, UpdateRecordObject(
+        Object          => $catalog,
+        AttributesRef   => \@attributes,
+        ARGSRef         => \%ARGS,
+    );
+
+    MaybeRedirectForResults(
+        Actions     => \@results,
+        Arguments   => { id => $catalog->id },
+    );
+}
+</%init>
diff --git a/share/html/Admin/Assets/Catalogs/Roles.html b/share/html/Admin/Assets/Catalogs/Roles.html
new file mode 100644
index 0000000..33bfd4f
--- /dev/null
+++ b/share/html/Admin/Assets/Catalogs/Roles.html
@@ -0,0 +1,82 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /Elements/Header, Title => loc("Catalog [_1]: Modify roles", $catalog->Name) &>
+<& /Elements/Tabs &>
+<& /Elements/ListActions, actions => \@results &>
+
+<form method="post" enctype="multipart/form-data" id="ModifyCatalogRoles" action="Roles.html">
+  <input type="hidden" name="id" value="<% $catalog->id %>">
+
+  <&| /Widgets/TitleBox, title => loc("Roles"), class => "catalog-roles" &>
+    <table width="60%" class="edit">
+      <tr>
+        <td><& /Elements/Assets/EditPeople, %ARGS, Object => $catalog &></td>
+        <td><& /Elements/Assets/AddPeople, Object => $catalog &></td>
+      </tr>
+    </table>
+  </&>
+
+  <& /Elements/Submit, Label => loc('Save'), Name => "Update" &>
+</form>
+<%args>
+$id     => undef
+$Update => 0
+</%args>
+<%init>
+my $catalog = LoadCatalog($id);
+my @results;
+
+if ($Update) {
+    push @results, ProcessRoleMembers( $catalog => %ARGS );
+
+    MaybeRedirectForResults(
+        Actions     => \@results,
+        Arguments   => { id => $catalog->id },
+    );
+}
+</%init>
diff --git a/share/html/Admin/Assets/Catalogs/UserRights.html b/share/html/Admin/Assets/Catalogs/UserRights.html
new file mode 100644
index 0000000..d676678
--- /dev/null
+++ b/share/html/Admin/Assets/Catalogs/UserRights.html
@@ -0,0 +1,64 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /Admin/Elements/Header, Title => loc("Catalog [_1]: Modify user rights", $Catalog->Name) &>
+<& /Elements/Tabs &>
+<& /Elements/ListActions, actions => \@results &>
+
+<form method="post" action="UserRights.html" id="ModifyUserRights" name="ModifyUserRights">
+  <input type="hidden" class="hidden" name="id" value="<% $Catalog->id %>" />
+  <& /Admin/Elements/EditRights, Context => $Catalog, Principals => \@principals &>
+  <& /Elements/Submit, Label => loc('Save Changes') &>
+</form>
+<%init>
+my $Catalog    = LoadCatalog($id);
+my @results    = ProcessACLs(\%ARGS);
+my @principals = GetPrincipalsMap($Catalog, qw(Users));
+</%init>
+<%args>
+$id => undef
+</%args>
diff --git a/share/html/Admin/Assets/Catalogs/index.html b/share/html/Admin/Assets/Catalogs/index.html
new file mode 100644
index 0000000..572888c
--- /dev/null
+++ b/share/html/Admin/Assets/Catalogs/index.html
@@ -0,0 +1,118 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /Admin/Elements/Header, Title => loc("Admin Catalogs") &>
+<& /Elements/Tabs &>
+
+<h1><% $caption %></h1>
+<p><&|/l&>Select a catalog</&>:</p>
+% unless ( $catalogs->Count ) {
+<em><&|/l&>No catalogs matching search criteria found.</&></em>
+% } else {
+<& /Elements/CollectionList,
+    OrderBy => 'Name',
+    Order => 'ASC',
+    %ARGS,
+    Format => $Format,
+    Collection => $catalogs,
+    AllowSorting => 1,
+    PassArguments => [qw(
+        Rows Page Order OrderBy FindDisabled String Op Field
+    )],
+&>
+% }
+<form method="post" action="<% RT->Config->Get('WebPath') %>/Admin/Assets/Catalogs/index.html">
+% foreach my $field( qw(Rows Page Order OrderBy) ) {
+%     next unless defined $ARGS{ $field } && length $ARGS{ $field };
+<input type="hidden" name="<% $field %>" value="<% $ARGS{ $field } %>" />
+% }
+
+<select name="Field">
+% foreach my $col (qw(id Name Description Lifecycle)) {
+<option <% $Field eq $col ? 'selected="selected"' : '' |n %> value="<% $col %>"><% loc($col) %></option>
+% }
+</select>
+<& /Elements/SelectMatch, Name => 'Op', Default => $Op &>
+<input size="20" name="String" value="<% $String %>" />
+<br />
+
+<input type="checkbox" class="checkbox" id="FindDisabled" name="FindDisabled" value="1" <% $FindDisabled ? 'checked="checked"': '' |n%> />
+<label for="FindDisabled"><&|/l&>Include disabled catalogs in listing.</&></label>
+<div align="right"><input type="submit" class="button" value="<&|/l&>Search</&>" /></div> 
+</form>
+
+<%INIT>
+my $catalogs = RT::Catalogs->new($session{'CurrentUser'});
+$catalogs->FindAllRows if $FindDisabled;
+
+my ($caption);
+if ( defined $String && length $String ) {
+    $caption = $FindDisabled
+        ? loc("All catalogs matching search criteria")
+        : loc("Enabled catalogs matching search criteria");
+    $catalogs->Limit(
+        FIELD    => $Field,
+        OPERATOR => $Op,
+        VALUE    => $String,
+    );
+    RT::Interface::Web::Redirect(RT->Config->Get('WebURL')."Admin/Assets/Catalogs/Modify.html?id=".$catalogs->First->id)
+          if $catalogs->Count == 1;
+} else {
+    $catalogs->UnLimit;
+    $caption = $FindDisabled
+        ? loc("All Catalogs")
+        : loc("Enabled Catalogs");
+}
+
+my $Format = RT->Config->Get('AdminSearchResultFormat')->{'Catalogs'};
+</%INIT>
+<%ARGS>
+$FindDisabled => 0
+$Field        => 'Name'
+$Op           => '='
+$String       => ''
+</%ARGS>
diff --git a/share/html/Admin/Assets/index.html b/share/html/Admin/Assets/index.html
new file mode 100644
index 0000000..a4ac4bd
--- /dev/null
+++ b/share/html/Admin/Assets/index.html
@@ -0,0 +1,50 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /Elements/Header, Title => loc('Asset Administration') &>
+<& /Elements/Tabs &>
+<& /Elements/ListMenu, menu => Menu()->child('admin')->child('assets') &>
diff --git a/share/html/Admin/Global/CustomFields/Catalog-Assets.html b/share/html/Admin/Global/CustomFields/Catalog-Assets.html
new file mode 100644
index 0000000..f85874b
--- /dev/null
+++ b/share/html/Admin/Global/CustomFields/Catalog-Assets.html
@@ -0,0 +1,54 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /Admin/Elements/Header, Title => $title &>
+<& /Elements/Tabs &>
+<& /Admin/Elements/EditCustomFields, %ARGS, title => $title, ObjectType => "RT::Catalog", Object=> $object, SubType => "RT::Asset" &>
+<%init>
+my $title = loc('Edit Custom Fields for Assets in all Catalogs');
+my $object = RT::Catalog->new($session{'CurrentUser'});
+</%init>
diff --git a/share/html/Asset/Create.html b/share/html/Asset/Create.html
new file mode 100644
index 0000000..233c81d
--- /dev/null
+++ b/share/html/Asset/Create.html
@@ -0,0 +1,185 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /Elements/Header, Title => loc("Create a new asset in catalog [_1]", $catalog->Name),
+    RT->Config->Get('AssetBasicCustomFieldsOnCreate')
+      ? (
+        onload => "function () { hide('Asset-Create-details') }"
+      )
+      : (),
+ &>
+<& /Elements/Tabs &>
+
+<& /Elements/ListActions, actions => \@results &>
+
+<span class="catalog <% CSSClass($catalog->Name) %>">
+<form method="post" enctype="multipart/form-data" id="CreateAsset" action="Create.html">
+  <input type="hidden" name="id" value="new">
+  <input type="hidden" name="Catalog" value="<% $catalog->id %>">
+
+<a name="basics"></a>
+<div id="Asset-Create-basics" class="asset-metadata">
+    <table>
+        <tbody>
+        <tr>
+            <td>
+  <&| /Widgets/TitleBox, title => loc("Basics"), class => "asset-basics", title_class => "inverse" &>
+    <& Elements/EditBasics, %ARGS, AssetObj => $asset, CatalogObj => $catalog &>
+  </&>
+
+  <&| /Widgets/TitleBox, title => loc("People"), class => "asset-people", title_class => "inverse" &>
+    <& Elements/EditPeople, %ARGS, AssetObj => $asset &>
+  </&>
+  </td><td>
+  <&| /Widgets/TitleBox, title => loc("Links"), class => "asset-links", title_class => "inverse" &>
+    <& /Elements/AddLinks,
+        Object          => $asset,
+        CustomFields    => $catalog->AssetCustomFields &>
+  </&>
+  </td>
+
+  </tr>
+  </tbody>
+  </table>
+</div>
+
+<div id="Asset-Create-details">
+<a name="details"></a>
+
+  <& /Elements/EditCustomFieldCustomGroupings,
+        Object => $asset,
+        TitleBoxARGS => { title_class => "inverse" },
+        KeepValue => 1,
+        CustomFieldGenerator => sub { $catalog->AssetCustomFields } &>
+</div>
+
+  <& /Elements/Submit, Label => loc('Create asset') &>
+</form>
+</span>
+
+<%args>
+$id => ""
+$Catalog => undef
+</%args>
+<%init>
+my $asset   = RT::Asset->new( $session{CurrentUser} );
+my $catalog = RT::Catalog->new( $session{CurrentUser} );
+$catalog->Load($Catalog);
+
+Abort(loc("Unable to find catalog '[_1]'", $Catalog))
+    unless $catalog->id;
+
+Abort(loc("You don't have permission to create assets in catalog [_1].",
+          $catalog->Name || $catalog->id))
+    unless $catalog->CurrentUserHasRight("CreateAsset");
+
+# Update the current default with the latest selection
+$session{'DefaultCatalog'} = $catalog->Id;
+
+my @results;
+
+if ($id eq "new") {
+    my $skip_create = 0;
+
+    my ($cf_ok, @cf_errors) = $m->comp(
+        '/Elements/ValidateCustomFields',
+        Object          => $asset,
+        CustomFields    => $catalog->AssetCustomFields,
+        ARGSRef         => \%ARGS
+    );
+
+    $m->callback(
+        CallbackName    => 'BeforeCreate',
+        AssetObj        => $asset,
+        CatalogObj      => $catalog,
+        results         => \@results,
+        cf_ok           => \$cf_ok,
+        skip_create     => \$skip_create,
+        ARGSRef         => \%ARGS
+    );
+
+    if ($cf_ok and not $skip_create) {
+        # Handle CFs and links
+        my %create = (
+            ProcessObjectCustomFieldUpdatesForCreate(
+                ARGSRef       => \%ARGS,
+                ContextObject => $catalog,
+            ),
+            ProcessLinksForCreate( ARGSRef => \%ARGS ),
+            map {
+                $_ => $ARGS{$_}
+            } $asset->Roles,
+        );
+
+        # Handle basic fields
+        for ($asset->WritableAttributes) {
+            $create{$_} = $ARGS{$_} if exists $ARGS{$_};
+        }
+
+        $m->callback( %ARGS, AssetObj => $asset, CatalogObj => $catalog, Create => \%create, CallbackName => 'MassageCreate' );
+
+        my ($ok, $msg, $nonfatal) = $asset->Create( %create );
+        push @results, $msg, @{$nonfatal || []};
+
+        if ($ok) {
+            MaybeRedirectForResults(
+                Actions     => \@results,
+                Path        => "/Asset/Display.html",
+                Arguments   => { id => $asset->id },
+            );
+        }
+    } else {
+        push @results, @cf_errors;
+    }
+}
+
+if ( RT->Config->Get('AssetBasicCustomFieldsOnCreate') ) {
+    PageMenu->child( basics => raw_html =>  q[<a href="#basics" onclick="return switchVisibility('Asset-Create-basics','Asset-Create-details');">] . loc('Basics') . q[</a>]);
+    PageMenu->child( details => raw_html =>  q[<a href="#details" onclick="return switchVisibility('Asset-Create-details','Asset-Create-basics');">] . loc('Details') . q[</a>]);
+}
+
+</%init>
diff --git a/share/html/Asset/CreateInCatalog.html b/share/html/Asset/CreateInCatalog.html
new file mode 100644
index 0000000..985ad37
--- /dev/null
+++ b/share/html/Asset/CreateInCatalog.html
@@ -0,0 +1,51 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /Elements/Header,
+    Title => loc("Create new asset") &>
+<& /Elements/Tabs &>
+<& /Asset/Elements/CreateInCatalog &>
diff --git a/share/html/Asset/CreateLinkedTicket.html b/share/html/Asset/CreateLinkedTicket.html
new file mode 100644
index 0000000..104b205
--- /dev/null
+++ b/share/html/Asset/CreateLinkedTicket.html
@@ -0,0 +1,58 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<%args>
+$Asset => undef
+$Requestors => ''
+</%args>
+<%init>
+my $asset = LoadAsset($Asset);
+</%init>
+<& /Elements/Header,
+    Title => loc("Create linked ticket for asset #[_1]: [_2]", $asset->id, $asset->Name) &>
+<& /Elements/Tabs &>
+<& /Asset/Elements/CreateLinkedTicket, AssetObj => $asset, Requestors => $Requestors &>
diff --git a/share/html/Asset/Display.html b/share/html/Asset/Display.html
new file mode 100644
index 0000000..f155136
--- /dev/null
+++ b/share/html/Asset/Display.html
@@ -0,0 +1,75 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /Elements/Header, Title => loc("Asset #[_1]: [_2]", $asset->id, $asset->Name) &>
+<& /Elements/Tabs &>
+
+% $m->callback(CallbackName => 'BeforeActionList', ARGSRef => \%ARGS, Asset => $asset);
+
+<& /Elements/ListActions &>
+
+<span class="catalog <% CSSClass($asset->CatalogObj->Name) %>">
+<& Elements/ShowSummary, AssetObj => $asset &>
+
+% $m->callback(CallbackName => 'AfterShowSummary', ARGSRef => \%ARGS, Asset => $asset);
+
+<& /Elements/ShowHistory,
+    Object => $asset,
+    ShowDisplayModes => 0,
+    DisplayPath => 'History.html',
+    &>
+
+% $m->callback(CallbackName => 'AfterShowHistory', ARGSRef => \%ARGS, Asset => $asset);
+</span>
+
+<%args>
+$id => undef
+</%args>
+<%init>
+my $asset = LoadAsset($id);
+$m->callback(CallbackName => 'BeforeDisplay', ARGSRef => \%ARGS, Asset => $asset);
+</%init>
diff --git a/share/html/Asset/Elements/AssetSearchBasics b/share/html/Asset/Elements/AssetSearchBasics
new file mode 100644
index 0000000..ec1742b
--- /dev/null
+++ b/share/html/Asset/Elements/AssetSearchBasics
@@ -0,0 +1,87 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<&| /Widgets/TitleBox, title => loc('Basics'), class=>'asset-search-grouping asset-search-basics' &>
+<table>
+<tr class="asset-catalog"><td class="label"><label for="Catalog"><&|/l&>Catalog</&></label></td>
+    <td class="value" colspan="3">
+<& /Asset/Elements/SelectCatalog, Name => 'Catalog', CheckRight => "ShowCatalog",
+       Default => $ARGS{'Catalog'}, OnChange => "jQuery('#AssetSearch').submit()" &>
+</td></tr>
+<tr class="asset-status"><td class="label"><label for="Status"><&|/l&>Status</&></label></td>
+    <td class="value" colspan="3">
+<& /Asset/Elements/SelectStatus, Name => 'Status', CatalogObj => $CatalogObj, DefaultValue => 1,
+       Default => ($ARGS{'Status'} || '') &>
+</td></tr>
+<tr class="asset-name"><td class="label"><label for="Name"><&|/l&>Name</&></label></td>
+    <td class="value"><input type="text" id="Name" name="Name" value="<% $ARGS{Name} || ''%>" /></td>
+    <td class="label not"><label for="!Name"><&|/l&>not</&></td>
+    <td class="value"><input type="text" id="!Name" name="!Name" value="<% $ARGS{"!Name"} || ''%>" /></td></tr>
+<tr class="asset-description"><td class="label"><label for="Description"><&|/l&>Description</&></label></td>
+    <td class="value"><input type="text" id="Description" name="Description" value="<% $ARGS{Description} || ''%>" /></td>
+    <td class="label not"><label for="!Description"><&|/l&>not</&></td>
+    <td class="value"><input type="text" id="!Description" name="!Description" value="<% $ARGS{"!Description"} || ''%>" /></td></tr>
+% my $CFs = RT::CustomFields->new( $session{CurrentUser} );
+% $CFs->LimitToCatalog( $CatalogObj->Id );
+% $CFs->LimitToObjectId(0); # LimitToGlobal but no LookupType restriction
+% $CFs->LimitToGrouping( "RT::Asset" => "Basics" );
+% while (my $cf = $CFs->Next) {
+% my $name = "CF.{" . $cf->Name . "}";
+% my $value  = ref($ARGS{$name}) ? $ARGS{$name}[0] : $ARGS{$name} || '';
+% my $negval = ref($ARGS{"!$name"}) ? $ARGS{"!$name"}[0] : $ARGS{"!$name"} || '';
+<tr>
+  <td class="label"><label for="<% $name %>"><% $cf->Name %></label></td>
+  <td class="value"><& /Elements/SelectCustomFieldValue, CustomField => $cf, Name => $name, Default => $value &></td>
+  <td class="label not"><label for="!<% $name %>"><&|/l&>not</&></label></td>
+  <td class="value"><& /Elements/SelectCustomFieldValue, CustomField => $cf, Name => "!$name", Default => $negval &></td>
+</tr>
+% }
+</table>
+</&>
+<%args>
+$CatalogObj => undef
+</%args>
diff --git a/share/html/Asset/Elements/AssetSearchCFs b/share/html/Asset/Elements/AssetSearchCFs
new file mode 100644
index 0000000..fe1d17e
--- /dev/null
+++ b/share/html/Asset/Elements/AssetSearchCFs
@@ -0,0 +1,72 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<&| /Widgets/TitleBox, title => $Grouping ? loc($Grouping) : loc('Custom Fields'), hide_empty => 1, class=>'asset-search-grouping asset-search-cfs'  &>
+% my $CFs = RT::CustomFields->new( $session{CurrentUser} );
+% $CFs->LimitToCatalog( $CatalogObj->Id );
+% $CFs->LimitToObjectId(0); # LimitToGlobal but no LookupType restriction
+% $CFs->LimitToGrouping( "RT::Asset" => $Grouping );
+% if ( $CFs->Count > 0 ){
+<table>
+%   while (my $cf = $CFs->Next) {
+%     my $name = "CF.{" . $cf->Name . "}";
+%     my $value  = ref($ARGS{$name}) ? $ARGS{$name}[0] : $ARGS{$name} || '';
+%     my $negval = ref($ARGS{"!$name"}) ? $ARGS{"!$name"}[0] : $ARGS{"!$name"} || '';
+<tr>
+  <td class="label"><label for="<% $name %>"><% $cf->Name %></label></td>
+  <td class="value"><& /Elements/SelectCustomFieldValue, CustomField => $cf, Name => $name, Default => $value &></td>
+  <td class="label not"><label for="!<% $name %>"><&|/l&>not</&></label></td>
+  <td class="value"><& /Elements/SelectCustomFieldValue, CustomField => $cf, Name => "!$name", Default => $negval &></td>
+</tr>
+%   }
+</table>
+% }
+</&>
+<%args>
+$Grouping
+$CatalogObj => undef
+</%args>
diff --git a/share/html/Asset/Elements/AssetSearchPeople b/share/html/Asset/Elements/AssetSearchPeople
new file mode 100644
index 0000000..ef4d480
--- /dev/null
+++ b/share/html/Asset/Elements/AssetSearchPeople
@@ -0,0 +1,83 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<&| /Widgets/TitleBox, class => "asset-search-people", title => loc('People') &>
+<table>
+% for my $role (RT::Asset->Roles) {
+<tr class="asset-role-<% CSSClass($role) %>">
+  <td class="label"><label for="Role.<% $role %>"><% loc($role) %></td>
+  <td class="value">
+      <input type="text" id="Role.<% $role %>" name="Role.<% $role %>"
+             data-autocomplete="Users" value="<% $ARGS{"Role.$role"} || '' %>" />
+  </td>
+  <td class="label not"><label for="!Role.<% $role %>"><&|/l&>not</&></td>
+  <td class="value">
+      <input type="text" id="!Role.<% $role %>" name="!Role.<% $role %>"
+             data-autocomplete="Users" value="<% $ARGS{"!Role.$role"} || '' %>" />
+  </td>
+</tr>
+% }
+% my $CFs = RT::CustomFields->new( $session{CurrentUser} );
+% $CFs->LimitToCatalog( $CatalogObj->Id );
+% $CFs->LimitToObjectId(0); # LimitToGlobal but no LookupType restriction
+% $CFs->LimitToGrouping( "RT::Asset" => "People" );
+% while (my $cf = $CFs->Next) {
+% my $name = "CF.{" . $cf->Name . "}";
+% my $value  = ref($ARGS{$name}) ? $ARGS{$name}[0] : $ARGS{$name} || '';
+% my $negval = ref($ARGS{"!$name"}) ? $ARGS{"!$name"}[0] : $ARGS{"!$name"} || '';
+<tr>
+  <td class="label"><label for="<% $name %>"><% $cf->Name %></label></td>
+  <td class="value"><& /Elements/SelectCustomFieldValue, CustomField => $cf, Name => $name, Default => $value &></td>
+  <td class="label not"><label for="!<% $name %>"><&|/l&>not</&></label></td>
+  <td class="value"><& /Elements/SelectCustomFieldValue, CustomField => $cf, Name => "!$name", Default => $negval &></td>
+</tr>
+% }
+</table>
+</&>
+<%args>
+$CatalogObj => undef
+</%args>
diff --git a/share/html/Asset/Elements/CreateInCatalog b/share/html/Asset/Elements/CreateInCatalog
new file mode 100644
index 0000000..055dd9e
--- /dev/null
+++ b/share/html/Asset/Elements/CreateInCatalog
@@ -0,0 +1,53 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<form action="<% RT->Config->Get("WebPath") %>/Asset/Create.html" id="AssetCreateInCatalog">
+  <&|/l_unsafe,
+    $m->scomp("/Asset/Elements/SelectCatalog"),
+    &>Create a new asset in the catalog [_1].</&>
+  <& /Elements/Submit, Label => loc("Go"), Caption => loc("This will take you to a partially prefilled asset creation form.") &>
+</form>
diff --git a/share/html/Asset/Elements/CreateLinkedTicket b/share/html/Asset/Elements/CreateLinkedTicket
new file mode 100644
index 0000000..63a8eaa
--- /dev/null
+++ b/share/html/Asset/Elements/CreateLinkedTicket
@@ -0,0 +1,78 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<%args>
+$AssetObj
+$Requestors => ''
+</%args>
+<%init>
+my @description = map { $m->interp->apply_escapes($_, 'h') }
+    $AssetObj->id, $AssetObj->Name;
+</%init>
+<form action="<% RT->Config->Get("WebPath") %><% $session{CurrentUser}->Privileged ? "/Ticket" : "/SelfService" %>/Create.html" id="AssetCreateLinkedTicket">
+  <input name="new-RefersTo" value="asset:<% $AssetObj->id %>" type="hidden">
+  <input name="Subject" value="<% $AssetObj->Name %>" type="hidden">
+  <&|/l_unsafe,
+    $m->scomp("/Elements/SelectNewTicketQueue"),
+    @description &>Create a new ticket in the [_1] queue about asset #[_2]: [_3].</&>
+% if ($Requestors) {
+    <input type="hidden" name="Requestors" value="<% $Requestors%>" />
+% } else {
+%     my $first = 1;
+%     for my $role ($AssetObj->Roles) {
+%         my $addr = $AssetObj->RoleGroup($role)->MemberEmailAddressesAsString;
+%         next unless defined $addr and length $addr;
+  <br>
+  <label>
+    <input type="radio" name="Requestors" value="<% $addr %>" <% $first ? 'checked="checked"' : '' |n%>/>
+    <&|/l, loc($role), $addr &>Use asset [_1] as ticket Requestor: [_2]</&>
+  </label>
+%         $first = 0;
+%     }
+% }
+  <& /Elements/Submit, Label => loc("Go"), Caption => loc("This will take you to a partially prefilled ticket creation form.") &>
+</form>
diff --git a/share/html/Asset/Elements/EditBasics b/share/html/Asset/Elements/EditBasics
new file mode 100644
index 0000000..fbfde33
--- /dev/null
+++ b/share/html/Asset/Elements/EditBasics
@@ -0,0 +1,85 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<table>
+% if ($AssetObj->id) {
+  <tr class="asset-catalog">
+    <td class="label"><label for="Catalog"><&|/l&>Catalog</&>:</label></td>
+    <td><& /Asset/Elements/SelectCatalog, Default => $current{Catalog} &></td>
+  </tr>
+% }
+  <tr class="asset-name">
+    <td class="label"><label for="Name"><&|/l&>Name</&>:</label></td>
+    <td><input name="Name" value="<% $current{Name} %>" size="40"></td>
+  </tr>
+  <tr class="asset-description">
+    <td class="label"><label for="Description"><&|/l&>Description</&>:</label></td>
+    <td><input name="Description" value="<% $current{Description} %>" size="40"></td>
+  </tr>
+  <tr class="asset-status">
+    <td class="label"><label for="Status"><&|/l&>Status</&>:</label></td>
+    <td><& /Asset/Elements/SelectStatus, Name => 'Status', AssetObj => $AssetObj, CatalogObj => $CatalogObj &></td>
+  </tr>
+% if ( $AssetObj->id ) {
+  <& /Elements/EditCustomFields, Object => $AssetObj, Grouping => 'Basics', InTable => 1 &>
+% } elsif ( my @cf_names = grep { defined } @{RT->Config->Get('AssetBasicCustomFieldsOnCreate') || []} ) {
+%   my $cfs = $CatalogObj->AssetCustomFields;
+%   for my $name ( @cf_names ) {
+%       $cfs->Limit( FIELD => 'Name', VALUE => $name, CASESENSITIVE => 0 );
+%   }
+  <& /Elements/EditCustomFields, Object => $AssetObj, CustomFields => $cfs, InTable => 1, KeepValue => 1 &>
+% }
+% $m->callback(%ARGS, CallbackName => "AfterFields");
+</table>
+<%args>
+$AssetObj
+$CatalogObj => undef
+</%args>
+<%init>
+my %current = map { $_ => ($ARGS{$_} || $AssetObj->$_ || '') }
+                  $AssetObj->WritableAttributes;
+</%init>
diff --git a/share/html/Asset/Elements/EditDates b/share/html/Asset/Elements/EditDates
new file mode 100644
index 0000000..1e64a26
--- /dev/null
+++ b/share/html/Asset/Elements/EditDates
@@ -0,0 +1,72 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<table>
+  <tr>
+    <td class="label"><&|/l&>Created</&>:</td>
+    <td>
+      <&|/l_unsafe,
+          $m->interp->apply_escapes($AssetObj->CreatedAsString, 'h'),
+          $m->scomp('/Elements/ShowUser', User => $AssetObj->CreatorObj)
+        &>[_1] by [_2]</&>
+    </td>
+  </tr>
+  <tr>
+    <td class="label"><&|/l&>Last Updated</&>:</td>
+    <td>
+      <&|/l_unsafe,
+          $m->interp->apply_escapes($AssetObj->LastUpdatedAsString, 'h'),
+          $m->scomp('/Elements/ShowUser', User => $AssetObj->LastUpdatedByObj)
+        &>[_1] by [_2]</&>
+    </td>
+  </tr>
+  <& /Elements/EditCustomFields, Object => $AssetObj, Grouping => 'Dates', InTable => 1 &>
+% $m->callback(%ARGS, CallbackName => "AfterFields");
+</table>
+<%args>
+$AssetObj
+</%args>
diff --git a/share/html/Asset/Elements/EditPeople b/share/html/Asset/Elements/EditPeople
new file mode 100644
index 0000000..6f333df
--- /dev/null
+++ b/share/html/Asset/Elements/EditPeople
@@ -0,0 +1,64 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<table border="0" cellpadding="0" cellspacing="0">
+% for my $role ( $AssetObj->Roles ) {
+<tr class="asset-people-<% CSSClass($role) %>">
+<td class="label">
+<% loc($role) %>:
+</td>
+<td class="value" colspan="5">
+<& /Elements/EmailInput, Name => $role, Size => undef, Default => $ARGS{$role}, Autocomplete => 1 &>
+</td>
+</tr>
+% }
+
+</table>
+
+<%args>
+$AssetObj
+</%args>
diff --git a/share/html/Asset/Elements/SelectCatalog b/share/html/Asset/Elements/SelectCatalog
new file mode 100644
index 0000000..48e9df0
--- /dev/null
+++ b/share/html/Asset/Elements/SelectCatalog
@@ -0,0 +1,69 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /Elements/SelectObject,
+    Name           => "Catalog",
+    ShowAll        => $ShowAll,
+    ShowNullOption => 0,
+    CheckRight     => "CreateAsset",
+    %ARGS,
+    ObjectType     => "Catalog",
+    CacheNeedsUpdate => RT::Catalog->CacheNeedsUpdate,
+    Default        => $Default,
+    &>
+<%args>
+$ShowAll => 0
+$Default => undef
+$UpdateSession => 1
+</%args>
+<%init>
+my $catalog_obj = LoadDefaultCatalog($Default || '');
+if ( $UpdateSession && $catalog_obj->Id ){
+    $session{'DefaultCatalog'} = $catalog_obj->Id;
+    $Default = $catalog_obj->Id;
+}
+</%init>
diff --git a/share/html/Asset/Elements/SelectStatus b/share/html/Asset/Elements/SelectStatus
new file mode 100644
index 0000000..f16c804
--- /dev/null
+++ b/share/html/Asset/Elements/SelectStatus
@@ -0,0 +1,66 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /Elements/SelectStatus, %ARGS &>
+<%init>
+if ($AssetObj and $AssetObj->Id) {
+    $ARGS{DefaultValue} = 0;
+    $ARGS{Default} = $DECODED_ARGS->{Status} || $ARGS{Default};
+    $ARGS{Object} = $AssetObj;
+} else {
+    my $lifecycle = ($CatalogObj || "RT::Catalog")->LifecycleObj;
+    if ( not $ARGS{DefaultValue} ){
+        $ARGS{DefaultValue} = 0;
+        $ARGS{Default} ||= $DECODED_ARGS->{Status} || $lifecycle->DefaultOnCreate;
+    }
+    $ARGS{Statuses} = [ $AssetObj ? $lifecycle->Transitions("") : $lifecycle->Valid ];
+}
+</%init>
+<%args>
+$AssetObj   => undef
+$CatalogObj => undef
+</%args>
diff --git a/share/html/Asset/Elements/ShowBasics b/share/html/Asset/Elements/ShowBasics
new file mode 100644
index 0000000..da350a1
--- /dev/null
+++ b/share/html/Asset/Elements/ShowBasics
@@ -0,0 +1,70 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<table>
+  <tr class="asset-catalog">
+    <td class="label"><&|/l&>Catalog</&>:</td>
+    <td><& ShowCatalog, Asset => $AssetObj &></td>
+  </tr>
+  <tr class="asset-name">
+    <td class="label"><&|/l&>Name</&>:</td>
+    <td><% $AssetObj->Name || '' %></td>
+  </tr>
+  <tr class="asset-description">
+    <td class="label"><&|/l&>Description</&>:</td>
+    <td><% $AssetObj->Description || '' %></td>
+  </tr>
+  <tr class="asset-status">
+    <td class="label"><&|/l&>Status</&>:</td>
+    <td class="value"><% loc($AssetObj->Status) %></td>
+  </tr>
+  <& /Elements/ShowCustomFields, Object => $AssetObj, Grouping => 'Basics', Table => 0 &>
+% $m->callback(%ARGS, CallbackName => "AfterFields");
+</table>
+<%args>
+$AssetObj
+</%args>
diff --git a/share/html/Asset/Elements/ShowCatalog b/share/html/Asset/Elements/ShowCatalog
new file mode 100644
index 0000000..d7c1095
--- /dev/null
+++ b/share/html/Asset/Elements/ShowCatalog
@@ -0,0 +1,69 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<% $value %>
+<%ARGS>
+$Asset   => undef
+$Catalog => undef
+</%ARGS>
+<%INIT>
+$Catalog ||= $Asset->CatalogObj if $Asset;
+return unless $Catalog;
+
+my $value = $Catalog->Name;
+
+if ( $Asset and $Asset->CurrentUserHasRight('ShowCatalog') ) {
+    # Grab the name anyway if the current user can see the catalog based on his
+    # role for this asset.  Simply checking ShowCatalog on the catalog itself
+    # won't work since the role membership is on the asset.  We need to
+    # implement context objects (like CFs do) for catalogs.
+    $value = $Catalog->__Value('Name');
+}
+
+$value = '#'. $Catalog->id
+    unless defined $value and length $value;
+</%INIT>
diff --git a/share/html/Asset/Elements/ShowDates b/share/html/Asset/Elements/ShowDates
new file mode 100644
index 0000000..582423e
--- /dev/null
+++ b/share/html/Asset/Elements/ShowDates
@@ -0,0 +1,72 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<table>
+  <tr>
+    <td class="label"><&|/l&>Created</&>:</td>
+    <td>
+      <&|/l_unsafe,
+          $m->interp->apply_escapes($AssetObj->CreatedAsString, 'h'),
+          $m->scomp('/Elements/ShowUser', User => $AssetObj->CreatorObj)
+        &>[_1] by [_2]</&>
+    </td>
+  </tr>
+  <tr>
+    <td class="label"><&|/l&>Last Updated</&>:</td>
+    <td>
+      <&|/l_unsafe,
+          $m->interp->apply_escapes($AssetObj->LastUpdatedAsString, 'h'),
+          $m->scomp('/Elements/ShowUser', User => $AssetObj->LastUpdatedByObj)
+        &>[_1] by [_2]</&>
+    </td>
+  </tr>
+  <& /Elements/ShowCustomFields, Object => $AssetObj, Grouping => 'Dates', Table => 0 &>
+% $m->callback(%ARGS, CallbackName => "AfterFields");
+</table>
+<%args>
+$AssetObj
+</%args>
diff --git a/share/html/Asset/Elements/ShowLinks b/share/html/Asset/Elements/ShowLinks
new file mode 100644
index 0000000..3b9a6f0
--- /dev/null
+++ b/share/html/Asset/Elements/ShowLinks
@@ -0,0 +1,48 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /Elements/ShowLinks, Object => delete $ARGS{AssetObj}, %ARGS &>
diff --git a/share/html/Asset/Elements/ShowPeople b/share/html/Asset/Elements/ShowPeople
new file mode 100644
index 0000000..af4f466
--- /dev/null
+++ b/share/html/Asset/Elements/ShowPeople
@@ -0,0 +1,76 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<%args>
+$AssetObj
+</%args>
+<%init>
+my $CatalogObj = $AssetObj->CatalogObj;
+</%init>
+<table>
+% for my $role ($AssetObj->Roles) {
+<tr><td class="label"><% loc($role) %>:
+% if ($AssetObj->Role($role)->{Single}) {
+%      my $users = $AssetObj->RoleGroup($role)->UserMembersObj(Recursively => 0);
+%      $users->FindAllRows;
+%      my $user = $users->Next;
+<& /Elements/ShowUser, User => $user, Link => 1 &></td></tr>
+%      next if $user->id == RT->Nobody->id;
+<tr><td>
+<& ShowRoleMembers, Group => $AssetObj->RoleGroup($role), Title => 0 &>
+</div></td></tr>
+% } else {
+</td></tr><tr><td class="user-accordion">
+    <& ShowRoleMembers, Group => $AssetObj->RoleGroup($role) &>
+    <& ShowRoleMembers, Group => $CatalogObj->RoleGroup($role), Skip => $AssetObj->RoleGroup($role), Note => loc("(via this asset's catalog)") &>
+  </td></tr>
+% }
+% }
+<& /Elements/ShowCustomFields, Object => $AssetObj, Grouping => 'People', Table => 0 &>
+
+</table>
+% $m->callback( %ARGS, CallbackName => 'AfterPeople' );
diff --git a/share/html/Asset/Elements/ShowRoleMembers b/share/html/Asset/Elements/ShowRoleMembers
new file mode 100644
index 0000000..a716ca9
--- /dev/null
+++ b/share/html/Asset/Elements/ShowRoleMembers
@@ -0,0 +1,90 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+% my $users = $Group->UserMembersObj( Recursively => 0 );
+% while ( my $user = $users->Next ) {
+%     next if $Skip and $Skip->HasMember( $user->PrincipalId );
+% if (not $Title) {
+% } elsif ($Single) {
+<h3><& /Elements/ShowUser, User => $user, Link => 1 &></h3>
+%      next if $user->id == RT->Nobody->id;
+% } else {
+<h3><a href="#"><& /Elements/ShowUser, User => $user, Link => 0 &>
+%     if ($Note) {
+ <span class="note"><% $Note %></span>
+%     }
+</a>
+% if ( $session{'CurrentUser'}->Privileged ){
+<a class="user-summary" href="<%RT->Config->Get('WebPath')%>/User/Summary.html?id=<% $user->Id %>"><&|/l&>User Summary</&></a>
+% }
+</h3>
+% }
+  <div class="details">
+<& /User/Elements/UserInfo,
+       User => $user,
+       FormatConfig => 'UserAssetExtraInfo',
+       ClassPrefix => 'asset-user' &>
+% $m->callback(CallbackName => 'AfterRecord', User => $user, Group => $Group );
+  </div>
+% }
+% my $groups = $Group->GroupMembersObj( Recursively => 0 );
+% $groups->LimitToUserDefinedGroups;
+% while (my $g = $groups->Next) {
+%     next if $Skip and $Skip->HasMember( $g->PrincipalId );
+<h3><a href="#"><&|/l, $g->Name &>Group: [_1]</&></a>
+%     if ($Note) {
+ <span class="note"><% $Note %></span>
+%     }
+</h3>
+% }
+<%ARGS>
+$Group => undef
+$Note  => ''
+$Skip  => undef
+$Single => 0
+$Title => 1
+</%ARGS>
diff --git a/share/html/Asset/Elements/ShowSummary b/share/html/Asset/Elements/ShowSummary
new file mode 100644
index 0000000..ebfe7f3
--- /dev/null
+++ b/share/html/Asset/Elements/ShowSummary
@@ -0,0 +1,83 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<%args>
+$AssetObj
+</%args>
+<%init>
+my @sections = (
+    "Basics",   #loc
+    "People",   #loc
+    "Dates",    #loc
+    "Links",    #loc
+);
+
+my $can_edit = $session{CurrentUser}->Privileged
+    && $AssetObj->CurrentUserHasRight("ModifyAsset");
+
+my %link;
+for my $section (@sections) {
+    my $page = $section eq 'Basics' ? "Modify.html" : "Modify$section.html";
+    $link{$section} =
+        RT->Config->Get("WebPath")
+        . "/Asset/$page?id="
+        . $AssetObj->id;
+}
+</%init>
+<div class="asset-metadata">
+% for my $section (@sections) {
+<&| /Widgets/TitleBox, title => loc($section), title_href => $can_edit ? $link{$section} : "", title_class => "inverse", class => "asset-\L$section" &>
+    <& "Show$section", AssetObj => $AssetObj &>
+</&>
+% }
+
+<& /Elements/ShowCustomFieldCustomGroupings,
+     Object => $AssetObj,
+     title_href => $can_edit ? RT->Config->Get("WebPath") . "/Asset/ModifyCFs.html" : "",
+     TitleBoxARGS => { title_class => "inverse" },
+ &>
+</div>
diff --git a/share/html/Asset/Elements/TSVExport b/share/html/Asset/Elements/TSVExport
new file mode 100644
index 0000000..0d34357
--- /dev/null
+++ b/share/html/Asset/Elements/TSVExport
@@ -0,0 +1,126 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<%ARGS>
+$Collection
+$Format
+$PreserveNewLines => 0
+</%ARGS>
+<%ONCE>
+my $no_html = HTML::Scrubber->new( deny => '*' );
+</%ONCE>
+<%INIT>
+require HTML::Entities;
+
+$r->content_type('application/vnd.ms-excel');
+
+my $DisplayFormat = $m->comp('/Elements/ScrubHTML', Content => $Format);
+
+my @Format = $m->comp('/Elements/CollectionAsTable/ParseFormat', Format => $DisplayFormat);
+
+my @columns;
+
+my $should_loc = { map { $_ => 1 } qw(Status) };
+
+my $col_entry = sub {
+    my $col = shift;
+    # in tsv output, "#" is often a comment character but we use it for "id"
+    delete $col->{title}
+        if $col->{title} and $col->{title} =~ /^\s*#\s*$/;
+    return {
+        header => Encode::encode_utf8(loc($col->{title} || $col->{attribute})),
+        map    => $m->comp(
+            "/Elements/ColumnMap",
+            Class => "RT__Asset",
+            Name  => $col->{attribute},
+            Attr  => 'value'
+        ),
+        should_loc => $should_loc->{$col->{attribute}},
+    }
+};
+
+if ($PreserveNewLines) {
+    my $col = [];
+    push @columns, $col;
+    for (@Format) {
+        if ($_->{title} eq 'NEWLINE') {
+            $col = [];
+            push @columns, $col;
+        }
+        else {
+            push @$col, $col_entry->($_);
+        }
+    }
+}
+else {
+    push @columns, [map { $_->{attribute}
+                          ? $col_entry->($_)
+                          : () } @Format];
+}
+
+for (@columns) {
+    $m->out(join("\t", map { $_->{header} } @$_)."\n");
+}
+
+my $ii = 0;
+while (my $row = $Collection->Next) {
+    for my $col (@columns) {
+        $m->out(join("\t", map {
+            my $val = ProcessColumnMapValue($_->{map}, Arguments => [$row, $ii++], Escape => 0);
+            $val = loc($val) if $_->{should_loc};
+            # remove tabs from all field values, they screw up the tsv
+            $val = '' unless defined $val;
+            $val =~ s/(?:\n|\r)//g; $val =~ s{\t}{    }g;
+            $val = $no_html->scrub($val);
+            $val = HTML::Entities::decode_entities($val);
+            Encode::encode_utf8($val);
+        } @$col)."\n");
+    }
+}
+$m->abort();
+
+</%INIT>
diff --git a/share/html/Asset/Helpers/CreateInCatalog b/share/html/Asset/Helpers/CreateInCatalog
new file mode 100644
index 0000000..034ed91
--- /dev/null
+++ b/share/html/Asset/Helpers/CreateInCatalog
@@ -0,0 +1,49 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /Asset/Elements/CreateInCatalog &>
+% $m->abort;
diff --git a/share/html/Asset/Helpers/CreateLinkedTicket b/share/html/Asset/Helpers/CreateLinkedTicket
new file mode 100644
index 0000000..95228f5
--- /dev/null
+++ b/share/html/Asset/Helpers/CreateLinkedTicket
@@ -0,0 +1,56 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<%args>
+$Asset
+$Requestors => ''
+</%args>
+<%init>
+my $asset = LoadAsset($Asset);
+</%init>
+<& /Asset/Elements/CreateLinkedTicket, AssetObj => $asset, Requestors => $Requestors &>
+% $m->abort;
diff --git a/share/html/Asset/History.html b/share/html/Asset/History.html
new file mode 100644
index 0000000..925eeb4
--- /dev/null
+++ b/share/html/Asset/History.html
@@ -0,0 +1,64 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /Elements/Header, Title => loc("History of Asset #[_1]: [_2]", $asset->id, $asset->Name) &>
+<& /Elements/Tabs &>
+
+<span class="catalog <% CSSClass($asset->CatalogObj->Name) %>">
+<& /Elements/ShowHistory,
+    Object => $asset,
+    ShowDisplayModes => 0,
+    DisplayPath => 'History.html',
+    &>
+</span>
+
+<%args>
+$id => undef
+</%args>
+<%init>
+my $asset = LoadAsset($id);
+</%init>
diff --git a/share/html/Asset/Modify.html b/share/html/Asset/Modify.html
new file mode 100644
index 0000000..f760268
--- /dev/null
+++ b/share/html/Asset/Modify.html
@@ -0,0 +1,133 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /Elements/Header, Title => loc("Modify asset #[_1]: [_2]", $asset->id, $asset->Name) &>
+<& /Elements/Tabs &>
+
+<& /Elements/ListActions, actions => \@results &>
+
+<span class="catalog <% CSSClass($asset->CatalogObj->Name) %>">
+<form method="post" enctype="multipart/form-data" id="ModifyAsset" action="Modify.html">
+  <input type="hidden" name="id" value="<% $asset->id %>">
+  <input type="hidden" name="DisplayAfter" value="<% $DisplayAfter ? 1 : 0 %>">
+
+  <&| /Widgets/TitleBox, title => loc("Basics"), class => "asset-basics", title_class => "inverse" &>
+    <& Elements/EditBasics, %ARGS, AssetObj => $asset &>
+  </&>
+
+  <&| /Widgets/TitleBox, title => loc("Custom Fields"), class => "asset-info-cfs", title_class => "inverse" &>
+    <& /Elements/EditCustomFields, Object => $asset, Grouping => '' &>
+  </&>
+
+  <& /Elements/Submit, Label => loc('Save asset'), Name => "Update" &>
+</form>
+</span>
+
+<%args>
+$id             => undef
+$Update         => 0
+$DisplayAfter   => 0
+</%args>
+<%init>
+my $asset = LoadAsset($id);
+my @results;
+
+$m->callback( Asset => $asset, ARGSRef => \%ARGS, Update => \$Update, results => \@results );
+
+if ($Update) {
+    my $skip_update = 0;
+    my @attributes  = $asset->WritableAttributes;
+
+    # Don't update status unless we have a value; otherwise RT complains
+    @attributes = grep { $_ ne "Status" } @attributes
+        unless $ARGS{Status};
+
+    my ($cf_ok, @cf_errors) = $m->comp(
+        '/Elements/ValidateCustomFields',
+        Object          => $asset,
+        CustomFields    => $asset->CustomFields,
+        ARGSRef         => \%ARGS
+    );
+
+    $m->callback(
+        CallbackName        => 'BeforeUpdate',
+        AssetObj            => $asset,
+        UpdatableAttributes => \@attributes,
+        results             => \@results,
+        cf_ok               => \$cf_ok,
+        skip_update         => \$skip_update,
+        ARGSRef             => \%ARGS,
+    );
+
+    if ($cf_ok and not $skip_update) {
+        push @results, ProcessObjectCustomFieldUpdates( Object => $asset, ARGSRef => \%ARGS );
+        push @results, UpdateRecordObject(
+            Object          => $asset,
+            AttributesRef   => \@attributes,
+            ARGSRef         => \%ARGS
+        );
+
+        $m->callback(
+            CallbackName        => 'AfterUpdate',
+            AssetObj            => $asset,
+            UpdatableAttributes => \@attributes,
+            results             => \@results,
+            ARGSRef             => \%ARGS,
+        );
+
+        MaybeRedirectForResults(
+            Actions     => \@results,
+            Arguments   => { id => $asset->id },
+            $DisplayAfter
+                ? (Path => "/Asset/Display.html")
+                : (),
+        );
+    } else {
+        push @results, @cf_errors;
+    }
+}
+</%init>
diff --git a/share/html/Asset/ModifyCFs.html b/share/html/Asset/ModifyCFs.html
new file mode 100755
index 0000000..41728ca
--- /dev/null
+++ b/share/html/Asset/ModifyCFs.html
@@ -0,0 +1,105 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /Elements/Header, Title => loc("Modify [_3] for asset #[_1]: [_2]", $asset->id, $asset->Name, $Grouping || loc("Custom Fields") ) &>
+<& /Elements/Tabs &>
+
+<& /Elements/ListActions, actions => \@results &>
+
+<span class="catalog <% CSSClass($asset->CatalogObj->Name) %>">
+<form method="post" enctype="multipart/form-data" id="ModifyAssetCFs" action="ModifyCFs.html">
+  <input type="hidden" name="id" value="<% $asset->id %>">
+  <input type="hidden" name="Grouping" value="<% $Grouping %>">
+
+  <&| /Widgets/TitleBox, title => $Grouping ? loc($Grouping) : loc("Custom Fields"), class => "asset-info-cfs ".($Grouping ? CSSClass("asset-info-cfs-\L$Grouping") : ""), title_class => "inverse" &>
+    <& /Elements/EditCustomFields, Object => $asset, Grouping => $Grouping &>
+  </&>
+
+  <& /Elements/Submit, Label => loc('Save asset'), Name => "Update" &>
+</form>
+</span>
+
+<%args>
+$id         => undef
+$Update     => 0
+$Grouping   => ''
+</%args>
+<%init>
+my $asset = LoadAsset($id);
+my @results;
+
+my $CFs = $asset->CustomFields;
+$CFs->LimitToGrouping($asset => $Grouping);
+
+Abort(loc("No custom fields found for grouping '[_1]'", $Grouping))
+    if $Grouping and not $CFs->Count;
+
+$m->callback( Asset => $asset, ARGSRef => \%ARGS, Update => \$Update, results => \@results );
+
+if ($Update) {
+    my ($cf_ok, @cf_errors) = $m->comp(
+        '/Elements/ValidateCustomFields',
+        Object          => $asset,
+        CustomFields    => $CFs,
+        ARGSRef         => \%ARGS
+    );
+
+    if ($cf_ok) {
+        push @results, ProcessObjectCustomFieldUpdates( Object => $asset, ARGSRef => \%ARGS );
+
+        MaybeRedirectForResults(
+            Actions     => \@results,
+            Arguments   => {
+                id          => $asset->id,
+                Grouping    => $Grouping,
+            },
+        );
+    } else {
+        push @results, @cf_errors;
+    }
+}
+</%init>
diff --git a/share/html/Asset/ModifyDates.html b/share/html/Asset/ModifyDates.html
new file mode 100755
index 0000000..8ca0f3e
--- /dev/null
+++ b/share/html/Asset/ModifyDates.html
@@ -0,0 +1,97 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /Elements/Header, Title => loc("Modify dates for asset #[_1]: [_2]", $asset->id, $asset->Name) &>
+<& /Elements/Tabs &>
+
+<& /Elements/ListActions, actions => \@results &>
+
+<span class="catalog <% CSSClass($asset->CatalogObj->Name) %>">
+<form method="post" enctype="multipart/form-data" id="ModifyAssetDates" action="ModifyDates.html">
+  <input type="hidden" name="id" value="<% $asset->id %>">
+
+  <&| /Widgets/TitleBox, title => loc("Dates"), class => "asset-dates", title_class => "inverse" &>
+    <& Elements/EditDates, %ARGS, AssetObj => $asset &>
+  </&>
+
+  <& /Elements/Submit, Label => loc('Save asset'), Name => "Update" &>
+</form>
+</span>
+
+<%init>
+my $asset = LoadAsset($id);
+my @results;
+
+$m->callback( Asset => $asset, ARGSRef => \%ARGS, Update => \$Update, results => \@results );
+
+if ($Update) {
+    my $CFs = $asset->CustomFields;
+    $CFs->LimitToGrouping($asset => "Dates");
+
+    my ($cf_ok, @cf_errors) = $m->comp(
+        '/Elements/ValidateCustomFields',
+        Object          => $asset,
+        CustomFields    => $CFs,
+        ARGSRef         => \%ARGS
+    );
+
+    if ($cf_ok) {
+        push @results, ProcessObjectCustomFieldUpdates( Object => $asset, ARGSRef => \%ARGS );
+
+        MaybeRedirectForResults(
+            Actions     => \@results,
+            Arguments   => { id => $asset->id },
+        );
+    } else {
+        push @results, @cf_errors;
+    }
+}
+</%init>
+<%args>
+$id     => undef
+$Update => 0
+</%args>
diff --git a/share/html/Asset/ModifyLinks.html b/share/html/Asset/ModifyLinks.html
new file mode 100755
index 0000000..ad95b8f
--- /dev/null
+++ b/share/html/Asset/ModifyLinks.html
@@ -0,0 +1,98 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /Elements/Header, Title => loc("Modify links for asset #[_1]: [_2]", $asset->id, $asset->Name) &>
+<& /Elements/Tabs &>
+
+<& /Elements/ListActions, actions => \@results &>
+
+<span class="catalog <% CSSClass($asset->CatalogObj->Name) %>">
+<form method="post" enctype="multipart/form-data" id="ModifyAssetLinks" action="ModifyLinks.html">
+  <input type="hidden" name="id" value="<% $asset->id %>">
+
+  <&| /Widgets/TitleBox, title => loc("Links"), class => "asset-links", title_class => "inverse" &>
+    <& /Elements/EditLinks, %ARGS, Object => $asset, Merge => 0 &>
+  </&>
+
+  <& /Elements/Submit, Label => loc('Save asset'), Name => "Update" &>
+</form>
+</span>
+
+<%init>
+my $asset = LoadAsset($id);
+my @results;
+
+$m->callback( Asset => $asset, ARGSRef => \%ARGS, Update => \$Update, results => \@results );
+
+if ($Update) {
+    my $CFs = $asset->CustomFields;
+    $CFs->LimitToGrouping($asset => "Links");
+
+    my ($cf_ok, @cf_errors) = $m->comp(
+        '/Elements/ValidateCustomFields',
+        Object          => $asset,
+        CustomFields    => $CFs,
+        ARGSRef         => \%ARGS
+    );
+
+    if ($cf_ok) {
+        push @results, ProcessRecordLinks( RecordObj => $asset, ARGSRef => \%ARGS );
+        push @results, ProcessObjectCustomFieldUpdates( Object => $asset, ARGSRef => \%ARGS );
+
+        MaybeRedirectForResults(
+            Actions     => \@results,
+            Arguments   => { id => $asset->id },
+        );
+    } else {
+        push @results, @cf_errors;
+    }
+}
+</%init>
+<%args>
+$id     => undef
+$Update => 0
+</%args>
diff --git a/share/html/Asset/ModifyPeople.html b/share/html/Asset/ModifyPeople.html
new file mode 100755
index 0000000..330f530
--- /dev/null
+++ b/share/html/Asset/ModifyPeople.html
@@ -0,0 +1,111 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /Elements/Header, Title => loc("Modify people related to asset #[_1]: [_2]", $asset->id, $asset->Name) &>
+<& /Elements/Tabs &>
+
+% $m->callback(CallbackName => 'BeforeActionList', ARGSRef => \%ARGS, Asset => $asset, Actions => \@results);
+
+<& /Elements/ListActions, actions => \@results &>
+
+<span class="catalog <% CSSClass($asset->CatalogObj->Name) %>">
+<form method="post" enctype="multipart/form-data" id="ModifyAssetPeople" action="ModifyPeople.html">
+  <input type="hidden" name="id" value="<% $asset->id %>">
+
+  <&| /Widgets/TitleBox, title => loc("People"), class => "asset-people", title_class => "inverse" &>
+    <table width="100%" class="edit">
+      <tr>
+        <td width="30%"><& /Elements/Assets/EditPeople, %ARGS, Object => $asset &></td>
+        <td width="30%"><& /Elements/Assets/AddPeople, Object => $asset &></td>
+        <td>
+          <& /Elements/EditCustomFields, Object => $asset, Grouping => 'People', AsTable => 1 &>
+        </td>
+      </tr>
+    </table>
+  </&>
+
+  <& /Elements/Submit, Label => loc('Save'), Name => "Update" &>
+</form>
+</span>
+
+<%init>
+my $asset = LoadAsset($id);
+my @results;
+
+$m->callback( Asset => $asset, ARGSRef => \%ARGS, Update => \$Update, results => \@results );
+
+if ($Update) {
+    my $CFs = $asset->CustomFields;
+    $CFs->LimitToGrouping($asset => "People");
+
+    my ($cf_ok, @cf_errors) = $m->comp(
+        '/Elements/ValidateCustomFields',
+        Object          => $asset,
+        CustomFields    => $CFs,
+        ARGSRef         => \%ARGS
+    );
+
+    if ($cf_ok) {
+        push @results, ProcessRoleMembers( $asset => %ARGS );
+        push @results, ProcessObjectCustomFieldUpdates( Object => $asset, ARGSRef => \%ARGS );
+
+        MaybeRedirectForResults(
+            Actions     => \@results,
+            Arguments   => { id => $asset->id },
+        );
+    } else {
+        push @results, @cf_errors;
+    }
+}
+
+$m->callback(CallbackName => 'BeforeDisplay', ARGSRef => \%ARGS, Asset => $asset);
+</%init>
+<%args>
+$id     => undef
+$Update => 0
+</%args>
+
diff --git a/share/html/Asset/Search/Bulk.html b/share/html/Asset/Search/Bulk.html
new file mode 100644
index 0000000..74fe7db
--- /dev/null
+++ b/share/html/Asset/Search/Bulk.html
@@ -0,0 +1,197 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /Elements/Header, Title => loc("Assets") &>
+<& /Elements/Tabs &>
+
+% $m->callback(CallbackName => 'BeforeActionList', ARGSRef => \%ARGS, Assets => $assets, Actions => \@results);
+
+<& /Elements/ListActions, actions => \@results &>
+
+<form method="post" action="<% RT->Config->Get('WebPath') %>/Asset/Search/Bulk.html" enctype="multipart/form-data" name="BulkUpdate" id="BulkUpdate">
+% foreach my $var ( @{$search{'PassArguments'}} )  {
+<input type="hidden" class="hidden" name="<% $var %>" value="<% $ARGS{$var} || '' %>" />
+% }
+<& /Elements/CollectionList,
+    %search,
+    Collection      => $assets,
+    AllowSorting    => 1,
+    DisplayFormat   => $DisplayFormat,
+    &>
+% if (not $assets->Count) {
+<em><&|/l&>No assets matching search criteria found.</&></em>
+% }
+
+<& /Elements/Submit,
+    Name => 'Update',
+    Label => loc('Update'),
+    CheckboxNameRegex => '/^UpdateAsset(All)?$/',
+    CheckAll => 1, ClearAll => 1,
+&>
+
+<&| /Widgets/TitleBox, title => loc("Basics"), class => "asset-basics asset-bulk-basics", title_class => "inverse" &>
+<table>
+  <tr class="asset-catalog">
+    <td class="label"><label for="UpdateCatalog"><&|/l&>Catalog</&></label></td>
+    <td><& /Asset/Elements/SelectCatalog, Name => 'UpdateCatalog', Default => $catalog_obj->id, UpdateSession => 0, &></td>
+  </tr>
+  <tr class="asset-name">
+    <td class="label"><label for="UpdateName"><&|/l&>Name</&></label></td>
+    <td><input name="UpdateName" value="<% $ARGS{'Name'}||'' %>" size="40"></td>
+  </tr>
+  <tr class="asset-description">
+    <td class="label"><label for="UpdateDescription"><&|/l&>Description</&></label></td>
+    <td><input name="UpdateDescription" value="<% $ARGS{'Description'}||'' %>" size="40"></td>
+  </tr>
+  <tr class="asset-status">
+    <td class="label"><label for="UpdateStatus"><&|/l&>Status</&></label></td>
+    <td><& /Asset/Elements/SelectStatus, Name => 'UpdateStatus', DefaultValue => 1, CatalogObj => $catalog_obj &></td>
+  </tr>
+</table>
+</&>
+
+<&| /Widgets/TitleBox, title => loc("People"), class => "asset-people asset-bulk-people", title_class => "inverse" &>
+<table>
+% for my $rname ( $asset->Roles( ACLOnly => 0 ) ) {
+% my $role = $asset->Role( $rname );
+% if ( $role->{'Single'} ) {
+% my $input = "SetRoleMember-$rname";
+<tr class="full-width">
+<td class="label"><label for="<% $input %>"><% loc($rname) %></label></td>
+<td><input type="text" value="<% $ARGS{ $input } || '' %>" name="<% $input %>" id="<% $input %>" data-autocomplete="Users" data-autocomplete-return="Name" /></td>
+</tr>
+% } else {
+<tr>
+% my $input = "AddRoleMember-$rname";
+<td class="label"><label for="<% $input %>"><% loc("Add [_1]", loc($rname)) %></label></td>
+<td><input type="text" value="<% $ARGS{ $input } || '' %>" name="<% $input %>" id="<% $input %>" data-autocomplete="Users" data-autocomplete-return="Name" /></td>
+</tr>
+
+<tr>
+% $input = "RemoveRoleMember-$rname";
+<td class="label"><label for="<% $input %>"><% loc("Remove [_1]", loc($rname)) %></label></td>
+<td>
+  <input type="text" value="<% $ARGS{ $input } || '' %>" name="<% $input %>" id="<% $input %>" data-autocomplete="Users" data-autocomplete-return="Name" />
+  <label>
+    <input type="checkbox" name="RemoveAllRoleMembers-<% $rname %>" value="1"/>
+    <em><&|/l&>(Check to delete all values)</&></em>
+  </label>
+</td>
+</tr>
+% }
+% }
+</table>
+</&>
+
+% for my $group ( RT::CustomField->CustomGroupings( 'RT::Asset' ), '' ) {
+%   my $cfs = $catalog_obj->AssetCustomFields;
+%   $cfs->LimitToGrouping( 'RT::Asset' => $group);
+%   if ( $cfs->Count ) {
+<&| /Widgets/TitleBox, class=>'asset-bulk-grouping asset-bulk-cfs', title => loc('Edit [_1]', ($group? loc($group) : loc('Custom Fields')) ) &>
+<& /Elements/BulkCustomFields, CustomFields => $cfs, &>
+</&>
+%   }
+% }
+
+<&|/Widgets/TitleBox, title => loc('Edit Links'), color => "#336633"&>
+<& /Elements/BulkLinks, Collection => $assets, %ARGS &>
+</&>
+
+<& /Elements/Submit, Label => loc('Update'), Name => 'Update' &>
+</form>
+
+<%INIT>
+my @results;
+$m->callback(ARGSRef => \%ARGS, Results => \@results, CallbackName => 'Initial');
+
+my $catalog_obj = LoadDefaultCatalog($ARGS{'Catalog'} || '');
+$ARGS{'Catalog'} = $catalog_obj->Id;
+
+my $assets = RT::Assets->new($session{CurrentUser});
+my %search = ProcessAssetsSearchArguments(
+    Assets => $assets, Catalog => $catalog_obj, ARGSRef => \%ARGS,
+);
+
+my $DisplayFormat = "'__CheckBox.{UpdateAsset}__',". $search{'Format'};
+$DisplayFormat =~ s/\s*,\s*('?__NEWLINE__'?)/,$1,''/gi;
+
+my $asset = RT::Asset->new( $session{'CurrentUser'} );
+
+delete $ARGS{$_} foreach grep { $ARGS{$_} =~ /^$/ } keys %ARGS;
+
+$DECODED_ARGS->{'UpdateAssetAll'} = 1 unless @UpdateAsset;
+
+if ( $ARGS{Update} ) {
+    my @attributes  = $asset->WritableAttributes;
+    @attributes = grep exists $ARGS{ 'Update'. $_ }, @attributes;
+    my %basics = map { $_ => $ARGS{ 'Update'. $_ } } @attributes;
+
+    foreach my $aid ( @UpdateAsset ) {
+        my $asset = LoadAsset($aid);
+
+        my @tmp_res;
+        push @tmp_res, UpdateRecordObject(
+            Object          => $asset,
+            AttributesRef   => \@attributes,
+            ARGSRef         => \%basics,
+        );
+        push @tmp_res, ProcessRoleMembers( $asset => %ARGS );
+        push @tmp_res, ProcessObjectCustomFieldUpdates( Object => $asset, ARGSRef => \%ARGS );
+        push @tmp_res, ProcessRecordLinks( RecordObj => $asset, RecordId => 'Asset', ARGSRef => \%ARGS );
+        push @tmp_res, ProcessRecordBulkCustomFields( RecordObj => $asset, ARGSRef => \%ARGS );
+        push @results, map { loc( "Asset #[_1]: [_2]", $asset->id, $_ ) } @tmp_res;
+    }
+
+    MaybeRedirectForResults(
+        Actions     => \@results,
+        Arguments   => { map { $_ => $ARGS{$_} } grep { defined $ARGS{$_} } @{$search{'PassArguments'}} },
+    );
+}
+</%INIT>
+<%ARGS>
+ at UpdateAsset => ()
+</%ARGS>
diff --git a/share/html/Asset/Search/Results.tsv b/share/html/Asset/Search/Results.tsv
new file mode 100644
index 0000000..41248b6
--- /dev/null
+++ b/share/html/Asset/Search/Results.tsv
@@ -0,0 +1,73 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<%init>
+my $catalog_obj = LoadDefaultCatalog($ARGS{'Catalog'} || '');
+$ARGS{'Catalog'} = $catalog_obj->Id;
+
+my $assets = RT::Assets->new($session{CurrentUser});
+ProcessAssetsSearchArguments(
+    Assets => $assets, Catalog => $catalog_obj, ARGSRef => \%ARGS,
+);
+
+my $Format = q|id, Name, Description, Status, Catalog, |;
+
+$Format .= "$_, " for RT::Asset->Roles;
+
+my $CFs = RT::CustomFields->new( $session{CurrentUser} );
+$CFs->LimitToCatalog( $catalog_obj->Id );
+$CFs->LimitToObjectId( 0 ); # LimitToGlobal but no LookupType restriction
+$Format .= "'__CF.{$_}__/TITLE:$_', " for map {$_ = $_->Name; s/['\\]/\\$1/g; $_} @{$CFs->ItemsArrayRef};
+
+$m->callback(CallbackName => "ModifyFormat", Format => \$Format );
+
+my $comp = "/Asset/Elements/TSVExport";
+$comp = "/Elements/TSVExport" if $m->comp_exists("/Elements/TSVExport");
+
+$m->comp($comp, Collection => $assets, Format => $Format );
+
+</%init>
diff --git a/share/html/Asset/Search/index.html b/share/html/Asset/Search/index.html
new file mode 100644
index 0000000..a0eb3fd
--- /dev/null
+++ b/share/html/Asset/Search/index.html
@@ -0,0 +1,108 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<%init>
+my $catalog_obj = LoadDefaultCatalog($ARGS{'Catalog'} || '');
+$ARGS{'Catalog'} = $catalog_obj->Id;
+
+my $assets = RT::Assets->new($session{CurrentUser});
+my %search = ProcessAssetsSearchArguments(
+    Assets => $assets, Catalog => $catalog_obj, ARGSRef => \%ARGS,
+);
+
+my $title = ( $ARGS{'SearchAssets'} or $ARGS{q} ) ?
+      loc("Found [quant,_1,asset,assets]",$assets->Count)
+    : loc("Assets");
+
+</%init>
+<& /Elements/Header, Title => $title &>
+<& /Elements/Tabs &>
+
+% if ( $ARGS{'SearchAssets'} or $ARGS{q} ){
+<& /Elements/CollectionList,
+    %search,
+    Collection      => $assets,
+    AllowSorting    => 1,
+    &>
+%   if (not $assets->Count) {
+<em><&|/l&>No assets matching search criteria found.</&></em>
+%   }
+% }
+<span class="catalog <% CSSClass( $catalog_obj->Name ) %>">
+<form action="<% RT->Config->Get('WebPath') %>/Asset/Search/index.html" id="AssetSearch">
+<&| /Widgets/TitleBox, title => loc("Search Assets") &>
+<& /Asset/Elements/AssetSearchBasics, %ARGS, CatalogObj => $catalog_obj &>
+<& /Asset/Elements/AssetSearchPeople, %ARGS, CatalogObj => $catalog_obj &>
+<& /Elements/Submit, Label => loc('Search'), Name => 'SearchAssets' &>
+
+% foreach my $group ( 'Dates', 'Links', RT::CustomField->CustomGroupings( "RT::Asset" ), '' ) {
+    <& /Asset/Elements/AssetSearchCFs, %ARGS, Grouping => $group,
+       CatalogObj => $catalog_obj &>
+% }
+<& /Elements/Submit, Label => loc('Search'), Name => 'SearchAssets' &>
+</&>
+
+<script>
+jQuery(function() {
+    var all_inputs = jQuery("#AssetSearch input, #AssetSearch select");
+    all_inputs.each(function() {
+        var elem = jQuery(this);
+        var update_elems = all_inputs.filter(function () {
+            return jQuery(this).attr("name") == elem.attr("name");
+        }).not(elem);
+        if (update_elems.length == 0)
+            return;
+        var trigger_func = function() { update_elems.val(elem.val()) };
+        if (elem.attr("type") == "text")
+            elem.keyup( trigger_func );
+        else
+            elem.change( trigger_func );
+    });
+});
+</script>
+</form>
+</span>
diff --git a/share/html/Asset/index.html b/share/html/Asset/index.html
new file mode 100644
index 0000000..ebb2299
--- /dev/null
+++ b/share/html/Asset/index.html
@@ -0,0 +1,66 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<%init>
+my $assets = RT::Assets->new($session{CurrentUser});
+$assets->UnLimit;
+</%init>
+<& /Elements/Header, Title => loc("Assets") &>
+<& /Elements/Tabs &>
+
+<& /Elements/CollectionList,
+    Collection      => $assets,
+    OrderBy         => 'Name',
+    Order           => 'ASC',
+    Format          => q[
+        '<b><a href="__WebPath__/Asset/Display.html?id=__id__">__id__</a></b>/TITLE:#',
+        '<b><a href="__WebPath__/Asset/Display.html?id=__id__">__Name__</a></b>/TITLE:Name',
+        Description,
+        Status,
+    ],
+    AllowSorting    => 0,
+    &>
diff --git a/share/html/Elements/AddLinks b/share/html/Elements/AddLinks
index 3e34237..225fd4a 100644
--- a/share/html/Elements/AddLinks
+++ b/share/html/Elements/AddLinks
@@ -61,6 +61,7 @@ $exclude .= qq| data-autocomplete-exclude="$id"| if $Object->id;
 % if (ref($Object) eq 'RT::Ticket') {
 <i><&|/l&>Enter tickets or URIs to link tickets to. Separate multiple entries with spaces.</&>
 <br /><&|/l&>You may enter links to Articles as "a:###", where ### represents the number of the Article.</&>
+<br /><&|/l&>Enter links to assets as "asset:###", where ### represents the asset ID.</&>
 % $m->callback( CallbackName => 'ExtraLinkInstructions' );
 </i><br />
 % } elsif (ref($Object) eq 'RT::Queue') {
diff --git a/share/html/Elements/Assets/AddPeople b/share/html/Elements/Assets/AddPeople
new file mode 100644
index 0000000..e73ec5e
--- /dev/null
+++ b/share/html/Elements/Assets/AddPeople
@@ -0,0 +1,67 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<%args>
+$Object
+</%args>
+<div class="add-user">
+<h3><&|/l&>Add a person</&></h3>
+<& SelectRoleType, Object => $Object, Name => "AddUserRoleMember-Role" &>
+<input type="text" name="AddUserRoleMember"
+       data-autocomplete="Users"
+       data-autocomplete-return="Name"
+       placeholder="<% loc("Find a user...") %>">
+</div>
+
+<div class="add-group">
+<h3><&|/l&>Add a group</&></h3>
+<& SelectRoleType, Object => $Object, Name => "AddGroupRoleMember-Role" &>
+<input type="text" name="AddGroupRoleMember"
+       data-autocomplete="Groups"
+       data-autocomplete-return="Name"
+       placeholder="<% loc("Find a group...") %>">
+</div>
diff --git a/share/html/Elements/Assets/EditPeople b/share/html/Elements/Assets/EditPeople
new file mode 100644
index 0000000..6f9ccdd
--- /dev/null
+++ b/share/html/Elements/Assets/EditPeople
@@ -0,0 +1,59 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<%args>
+$Object
+</%args>
+<%init>
+</%init>
+% for my $role ($Object->Roles( ACLOnly => 0 )) {
+<div class="role-<% CSSClass($role) %> role">
+  <h3><% loc($role) %></h3>
+  <& EditRoleMembers, Group => $Object->RoleGroup($role) &>
+</div>
+% }
+<em><&|/l&>(Check box to delete)</&></em>
diff --git a/share/html/Elements/Assets/EditRoleMembers b/share/html/Elements/Assets/EditRoleMembers
new file mode 100644
index 0000000..30b6962
--- /dev/null
+++ b/share/html/Elements/Assets/EditRoleMembers
@@ -0,0 +1,79 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<%args>
+$Group       => undef
+$Recursively => 0
+</%args>
+<%init>
+my $field_name = "RemoveRoleMember-" . $Group->Name;
+</%init>
+<ul class="role-members">
+% my $Users = $Group->UserMembersObj( Recursively => $Recursively );
+% if ($Group->SingleMemberRoleGroup) {
+<input type="text" value="<% $Users->First->Name %>" name="SetRoleMember-<% $Group->Name %>" id="SetRoleMember-<% $Group->Name %>" data-autocomplete="Users" data-autocomplete-return="Name" /><br />
+% } else {
+% while ( my $user = $Users->Next ) {
+<li>
+  <label>
+    <input type="checkbox" name="<% $field_name %>" value="<% $user->PrincipalId %>">
+    <& /Elements/ShowUser, User => $user &>
+  </label>
+</li>
+% }
+% my $Groups = $Group->GroupMembersObj( Recursively => $Recursively );
+% $Groups->LimitToUserDefinedGroups;
+% while (my $group = $Groups->Next) {
+<li>
+  <label>
+    <input type="checkbox" name="<% $field_name %>" value="<% $group->PrincipalId %>">
+    <&|/l&>Group</&>: <% $group->Name %>
+  </label>
+</li>
+% }
+% }
+</ul>
diff --git a/share/html/Elements/Assets/Search b/share/html/Elements/Assets/Search
new file mode 100644
index 0000000..9750ef766
--- /dev/null
+++ b/share/html/Elements/Assets/Search
@@ -0,0 +1,53 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<form ACTION="<% RT->Config->Get('WebPath') %>/Asset/Search/">
+  <input size="12" name="q" accesskey="0" class="field" value="<% $value %>" placeholder="<&|/l&>Search Assets</&>..." />
+</form>
+<%init>
+my $value = defined $DECODED_ARGS->{q} ? $DECODED_ARGS->{q} : '';
+</%init>
diff --git a/share/html/Elements/Assets/SelectRoleType b/share/html/Elements/Assets/SelectRoleType
new file mode 100644
index 0000000..a38e305
--- /dev/null
+++ b/share/html/Elements/Assets/SelectRoleType
@@ -0,0 +1,60 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<%args>
+$Object
+$Name
+$AllowNull  => 0
+</%args>
+<select name="<% $Name %>">
+% if ($AllowNull) {
+  <option value=""></option>
+% }
+% for my $role ($Object->Roles( ACLOnly => 0, Single => 0 )) {
+  <option value="<% $role %>"><% loc($role) %></option>
+% }
+</select>
diff --git a/share/html/Elements/FindAsset b/share/html/Elements/FindAsset
new file mode 100644
index 0000000..d7e8b1f
--- /dev/null
+++ b/share/html/Elements/FindAsset
@@ -0,0 +1,53 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<&|/Widgets/TitleBox, title => loc('Find an asset') &>
+<form action="<% RT->Config->Get('WebPath') %>/Asset/Search/">
+  <input type="text" name="q" />
+  <input type="submit" value="<&|/l&>Search</&>" class="button" />
+</form>
+</&>
diff --git a/share/html/Elements/MyAssets b/share/html/Elements/MyAssets
new file mode 100644
index 0000000..615ffcf
--- /dev/null
+++ b/share/html/Elements/MyAssets
@@ -0,0 +1,48 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /User/Elements/AssetList, User => $session{'CurrentUser'}->UserObj, Roles => [qw(HeldBy)], Title => loc('My Assets') &>
diff --git a/share/html/Elements/RT__Asset/ColumnMap b/share/html/Elements/RT__Asset/ColumnMap
new file mode 100644
index 0000000..ffc8b0e
--- /dev/null
+++ b/share/html/Elements/RT__Asset/ColumnMap
@@ -0,0 +1,121 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<%ARGS>
+$Name => undef
+$Attr => undef
+</%ARGS>
+<%ONCE>
+my $linkUsers;
+$linkUsers = sub {
+    my ($what, $more) = @_;
+    if ($what->isa("RT::Group")) {
+        # Link the users (non-recursively)
+        my @ret = map {$linkUsers->($_->[1], $more), ", "}
+            sort {$a->[0] cmp $b->[0]}
+            map {+[($_->EmailAddress||''), $_]}
+            @{ $what->UserMembersObj( Recursively => 0 )->ItemsArrayRef };
+
+        # But don't link the groups
+        push @ret, map {+("Group: $_", ",")}
+            sort map {$_->Name} @{ $what->GroupMembersObj( Recursively => 0)->ItemsArrayRef };
+
+        pop @ret; # Remove ending ", "
+        return @ret;
+    } else {
+        my @ret = \($m->scomp("/Elements/ShowUser", User => $what));
+        push @ret, $more->($what) if $more;
+        return @ret;
+    }
+};
+my $COLUMN_MAP = {
+    Name => {
+        attribute => 'Name',
+        title     => 'Name',
+        value     => sub { $_[0]->Name },
+    },
+    Description => {
+        attribute => 'Description',
+        title     => 'Description',
+        value     => sub { $_[0]->Description },
+    },
+    Catalog => {
+        attribute => 'Catalog',
+        title     => 'Catalog', # loc
+        value     => sub { $_[0]->CatalogObj->Name },
+    },
+    Status => {
+        title     => 'Status',
+        attribute => 'Status',
+        value     => sub { loc($_[0]->Status) }
+    },
+    ActiveTickets => {
+        title     => 'Active tickets', # loc
+        value     => sub {
+            my $Asset = shift;
+            my $Query = "RefersTo = 'asset:" . $Asset->id . "'";
+            $Query .= " AND (" . join(" OR ", map { "Status = '$_'" } RT::Queue->ActiveStatusArray) . ")";
+            my $SearchURL = RT->Config->Get('WebPath') . '/Search/Results.html?' . $m->comp('/Elements/QueryString', Query => $Query);
+            return \'[ <a href="',$SearchURL,\'">Active</a> ]';
+        }
+    },
+    InactiveTickets => {
+        title     => 'Inactive tickets', # loc
+        value     => sub {
+            my $Asset = shift;
+            my $Query = "RefersTo = 'asset:" . $Asset->id . "'";
+            $Query .= " AND (" . join(" OR ", map { "Status = '$_'" } RT::Queue->InactiveStatusArray) . ")";
+            my $SearchURL = RT->Config->Get('WebPath') . '/Search/Results.html?' . $m->comp('/Elements/QueryString', Query => $Query);
+            return \'[ <a href="',$SearchURL,\'">Inactive</a> ]';
+        }
+    },
+};
+</%ONCE>
+<%init>
+$m->callback( COLUMN_MAP => $COLUMN_MAP, CallbackName => 'Once', CallbackOnce => 1 );
+return GetColumnMapEntry( Map => $COLUMN_MAP, Name => $Name, Attribute => $Attr );
+</%init>
diff --git a/share/html/Elements/RT__Catalog/ColumnMap b/share/html/Elements/RT__Catalog/ColumnMap
new file mode 100644
index 0000000..8aa1bd6
--- /dev/null
+++ b/share/html/Elements/RT__Catalog/ColumnMap
@@ -0,0 +1,79 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<%ARGS>
+$Name => undef
+$Attr => undef
+</%ARGS>
+<%ONCE>
+my $COLUMN_MAP = {
+    Name => {
+        attribute => 'Name',
+        title     => 'Name',
+        value     => sub { $_[0]->Name },
+    },
+    Description => {
+        attribute => 'Description',
+        title     => 'Description',
+        value     => sub { $_[0]->Description },
+    },
+    Disabled => {
+        title     => \' ',
+        attribute => 'Disabled',
+        value     => sub { return $_[0]->Disabled? $_[0]->loc('Disabled'): $_[0]->loc('Enabled') },
+    },
+    Lifecycle => {
+        title => 'Lifecycle',
+        attribute => 'Lifecycle',
+        value => sub { return $_[0]->Lifecycle },
+    },
+};
+</%ONCE>
+<%init>
+$m->callback( COLUMN_MAP => $COLUMN_MAP, CallbackName => 'Once', CallbackOnce => 1 );
+return GetColumnMapEntry( Map => $COLUMN_MAP, Name => $Name, Attribute => $Attr );
+</%init>
diff --git a/share/html/Elements/Tabs b/share/html/Elements/Tabs
index c6c6505..136216a 100644
--- a/share/html/Elements/Tabs
+++ b/share/html/Elements/Tabs
@@ -936,6 +936,156 @@ my $build_main_nav = sub {
         PageMenu()->child( edit => title => loc('Edit'), path => '/Prefs/MyRT.html' );
     }
 
+    { ### START ASSETS MENU
+        # Top level Assets menu
+        my $assets = Menu->child("tools")->add_before(
+                       "assets", title => loc("Assets"), path => "/Asset/Search/");
+        $assets->child("create", title => loc("Create"), path => "/Asset/CreateInCatalog.html");
+        $assets->child("search", title => loc("Search"), path => "/Asset/Search/");
+
+        Menu->child("search")->child("assets", title => loc("Assets"), path => "/Asset/Search/");
+
+        # Add global Assets custom field admin page
+        my $global_cfs = Menu();
+           $global_cfs = $global_cfs->child($_) or last
+                for "admin" => "global" => "custom-fields";
+        $global_cfs->child("assets", title => loc("Assets"), path => "/Admin/Global/CustomFields/Catalog-Assets.html")
+            if $global_cfs;
+
+        # Add a Catalog admin menu
+        my $config = Menu()->child("admin");
+        if ($config) {
+            my $assets   = $config->child("tools")->add_before("assets", title => loc("Assets"), path => "/Admin/Assets/");
+            my $catalogs = $assets->child("catalogs",
+                title => loc("Catalogs"),
+                description => loc("Modify asset catalogs"),
+                path => "/Admin/Assets/Catalogs/");
+            $catalogs->child("select", title => loc("Select"), path => $catalogs->path);
+            $catalogs->child("create", title => loc("Create"), path => "Create.html");
+
+            my $cfs = $assets->child("cfs",
+                title => loc("Custom Fields"),
+                description => loc("Modify asset custom fields"),
+                path => "/Admin/CustomFields/?Type=" . RT::Asset->CustomFieldLookupType);
+            $cfs->child("select", title => loc("Select"), path => $cfs->path);
+            $cfs->child("create", title => loc("Create"), path => "/Admin/CustomFields/Modify.html?Create=1&LookupType=" . RT::Asset->CustomFieldLookupType);
+        }
+
+        # Asset search
+        if ($request_path =~ m{^/Asset/}) {
+            PageWidgets()->child( asset_search => raw_html => $m->scomp('/Elements/Assets/Search') );
+            PageWidgets()->delete('create_ticket');
+            PageWidgets()->delete('simple_search');
+        }
+
+        # Page menus
+        my $page    = PageMenu();
+
+        if ($request_path =~ m{^/Asset/} and $DECODED_ARGS->{id} and $DECODED_ARGS->{id} !~ /\D/) {
+            my $id    = $DECODED_ARGS->{id};
+            my $asset = RT::Asset->new( $session{CurrentUser} );
+            $asset->Load($id);
+
+            if ($asset->id) {
+                $page->child("display",     title => loc("Display"),        path => "/Asset/Display.html?id=$id");
+                $page->child("history",     title => loc("History"),        path => "/Asset/History.html?id=$id");
+                $page->child("basics",      title => loc("Basics"),         path => "/Asset/Modify.html?id=$id");
+                $page->child("links",       title => loc("Links"),          path => "/Asset/ModifyLinks.html?id=$id");
+                $page->child("people",      title => loc("People"),         path => "/Asset/ModifyPeople.html?id=$id");
+                $page->child("dates",       title => loc("Dates"),          path => "/Asset/ModifyDates.html?id=$id");
+
+                for my $grouping (RT::CustomField->CustomGroupings($asset)) {
+                    my $cfs = $asset->CustomFields;
+                    $cfs->LimitToGrouping( $asset => $grouping );
+                    next unless $cfs->Count;
+                    $page->child(
+                        "cf-grouping-$grouping",
+                        title   => loc($grouping),
+                        path    => "/Asset/ModifyCFs.html?id=$id;Grouping=" . $m->interp->apply_escapes($grouping, 'u'),
+                    );
+                }
+
+                my $actions = $page->child("actions", title => loc("Actions"));
+                $actions->child("create-linked-ticket", title => loc("Create linked ticket"), path => "/Asset/CreateLinkedTicket.html?Asset=$id");
+
+                my $status    = $asset->Status;
+                my $lifecycle = $asset->LifecycleObj;
+                for my $action ( $lifecycle->Actions($status) ) {
+                    my $next = $action->{'to'};
+                    next unless $lifecycle->IsTransition( $status => $next );
+
+                    my $check = $lifecycle->CheckRight( $status => $next );
+                    next unless $asset->CurrentUserHasRight($check);
+
+                    my $label = $action->{'label'} || ucfirst($next);
+                    $actions->child(
+                        $label,
+                        title   => loc($label),
+                        path    => "/Asset/Modify.html?id=$id;Update=1;DisplayAfter=1;Status="
+                                    . $m->interp->apply_escapes($next, 'u'),
+
+                        class       => "asset-lifecycle-action",
+                        attributes  => {
+                            'data-current-status'   => $status,
+                            'data-next-status'      => $next,
+                        },
+                    );
+                }
+            }
+        }
+        elsif ($request_path =~ m{^/Asset/Search/}) {
+            my %search = map @{$_},
+                grep defined $_->[1] && length $_->[1],
+                map {ref $DECODED_ARGS->{$_} ? [$_, $DECODED_ARGS->{$_}[0]] : [$_, $DECODED_ARGS->{$_}] }
+                grep /^(?:q|SearchAssets|!?(Name|Description|Catalog|Status|Role\..+|CF\..+)|Order(?:By)?|Page)$/,
+                keys %$DECODED_ARGS;
+            if ( $request_path =~ /Bulk/) {
+                $page->child('search',
+                    title => loc('Show Results'),
+                    path => '/Asset/Search/?'. $query_string->(%search),
+                );
+            } else {
+                $page->child('bulk',
+                    title => loc('Bulk Update'),
+                    path => '/Asset/Search/Bulk.html?'. $query_string->(%search),
+                );
+            }
+            $page->child('csv',
+                title => loc('Download Spreadsheet'),
+                path  => '/Asset/Search/Results.tsv?' . $query_string->(%search),
+            );
+        }
+        elsif ($request_path =~ m{^/Admin/Global/CustomFields/Catalog-Assets\.html$}) {
+            $page->child("create", title => loc("Create New"), path => "/Admin/CustomFields/Modify.html?Create=1;LookupType=" . RT::Asset->CustomFieldLookupType);
+        }
+        elsif ($request_path =~ m{^/Admin/CustomFields(/|/index\.html)?$}
+               and $DECODED_ARGS->{'Type'} and $DECODED_ARGS->{'Type'} eq RT::Asset->CustomFieldLookupType) {
+            $page->child("create")->path( $page->child("create")->path . "&LookupType=" . RT::Asset->CustomFieldLookupType );
+        }
+        elsif ($request_path =~ m{^/Admin/Assets/Catalogs/}) {
+            my $actions = $request_path =~ m{/((index|Create)\.html)?$}
+                ? $page
+                : $page->child("catalogs", title => loc("Catalogs"), path => "/Admin/Assets/Catalogs/");
+
+            $actions->child("select", title => loc("Select"), path => "/Admin/Assets/Catalogs/");
+            $actions->child("create", title => loc("Create"), path => "/Admin/Assets/Catalogs/Create.html");
+
+            my $catalog = RT::Catalog->new( $session{CurrentUser} );
+            $catalog->Load($DECODED_ARGS->{id}) if $DECODED_ARGS->{id};
+
+            if ($catalog->id and $catalog->CurrentUserCanSee) {
+                my $query = "id=" . $catalog->id;
+                $page->child("modify", title => loc("Basics"), path => "/Admin/Assets/Catalogs/Modify.html?$query");
+                $page->child("people", title => loc("Roles"),  path => "/Admin/Assets/Catalogs/Roles.html?$query");
+
+                $page->child("cfs", title => loc("Asset Custom Fields"), path => "/Admin/Assets/Catalogs/CustomFields.html?$query");
+
+                $page->child("group-rights", title => loc("Group Rights"), path => "/Admin/Assets/Catalogs/GroupRights.html?$query");
+                $page->child("user-rights",  title => loc("User Rights"),  path => "/Admin/Assets/Catalogs/UserRights.html?$query");
+            }
+        }
+    } ### END ASSETS MENU
+
     $m->callback( CallbackName => 'Privileged', Path => $request_path );
 };
 
@@ -989,6 +1139,28 @@ my $build_selfservice_nav = sub {
 
     PageWidgets->child( goto => raw_html => $m->scomp('/SelfService/Elements/GotoTicket') );
 
+    { ### START ASSETS SELFSERVICE MENU
+        Menu->child("tickets")->add_after(
+            "assets",
+            title   => loc("Assets"),
+            path    => "/SelfService/Asset/",
+        );
+
+        # Page menus
+        my $page    = PageMenu();
+
+        if ($request_path =~ m{^/SelfService/Asset/} and $DECODED_ARGS->{id}) {
+            my $id = $DECODED_ARGS->{id};
+            $page->child("display",     title => loc("Display"),        path => "/SelfService/Asset/Display.html?id=$id");
+            $page->child("history",     title => loc("History"),        path => "/SelfService/Asset/History.html?id=$id");
+
+            if (Menu->child("new")) {
+                my $actions = $page->child("actions", title => loc("Actions"));
+                $actions->child("create-linked-ticket", title => loc("Create linked ticket"), path => "/SelfService/Asset/CreateLinkedTicket.html?Asset=$id");
+            }
+        }
+    } ### END ASSETS SELFSERVICE MENU
+
     $m->callback( CallbackName => 'SelfService', Path => $request_path );
 };
 
diff --git a/share/html/SelfService/Asset/CreateLinkedTicket.html b/share/html/SelfService/Asset/CreateLinkedTicket.html
new file mode 100644
index 0000000..ed4b8fd
--- /dev/null
+++ b/share/html/SelfService/Asset/CreateLinkedTicket.html
@@ -0,0 +1,57 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<%args>
+$id => undef
+</%args>
+<%init>
+my $asset = LoadAsset($id);
+</%init>
+<& /Elements/Header,
+    Title => loc("Create linked ticket for asset #[_1]: [_2]", $asset->id, $asset->Name) &>
+<& /Elements/Tabs &>
+<& /Asset/Elements/CreateLinkedTicket, AssetObj => $asset &>
diff --git a/share/html/SelfService/Asset/Display.html b/share/html/SelfService/Asset/Display.html
new file mode 100644
index 0000000..d105ced
--- /dev/null
+++ b/share/html/SelfService/Asset/Display.html
@@ -0,0 +1,57 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /SelfService/Elements/Header, Title => loc("Asset #[_1]: [_2]", $asset->id, $asset->Name) &>
+
+<& /Asset/Elements/ShowSummary, AssetObj => $asset &>
+
+<%args>
+$id => undef
+</%args>
+<%init>
+my $asset = LoadAsset($id);
+</%init>
diff --git a/share/html/SelfService/Asset/Helpers/CreateLinkedTicket b/share/html/SelfService/Asset/Helpers/CreateLinkedTicket
new file mode 100644
index 0000000..6cc25f5
--- /dev/null
+++ b/share/html/SelfService/Asset/Helpers/CreateLinkedTicket
@@ -0,0 +1,55 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<%args>
+$Asset
+</%args>
+<%init>
+my $asset = LoadAsset($Asset);
+</%init>
+<& /Asset/Elements/CreateLinkedTicket, AssetObj => $asset &>
+% $m->abort;
diff --git a/share/html/SelfService/Asset/History.html b/share/html/SelfService/Asset/History.html
new file mode 100644
index 0000000..02f82e7
--- /dev/null
+++ b/share/html/SelfService/Asset/History.html
@@ -0,0 +1,60 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /SelfService/Elements/Header, Title => loc("History of Asset #[_1]: [_2]", $asset->id, $asset->Name) &>
+
+<& /Elements/ShowHistory,
+    Object => $asset,
+    ShowDisplayModes => 0,
+    &>
+
+<%args>
+$id => undef
+</%args>
+<%init>
+my $asset = LoadAsset($id);
+</%init>
diff --git a/share/html/SelfService/Asset/index.html b/share/html/SelfService/Asset/index.html
new file mode 100644
index 0000000..79016a8
--- /dev/null
+++ b/share/html/SelfService/Asset/index.html
@@ -0,0 +1,49 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /SelfService/Elements/Header, Title => loc("My Assets") &>
+<& /User/Elements/AssetList, User => $session{'CurrentUser'}->UserObj, Roles => [''], Title => loc('My Assets') &>
diff --git a/share/html/SelfService/Display.html b/share/html/SelfService/Display.html
index 9a115eb..584dea2 100644
--- a/share/html/SelfService/Display.html
+++ b/share/html/SelfService/Display.html
@@ -80,6 +80,8 @@
 </tr>
 </table>
 
+<& /Ticket/Elements/ShowAssets, Ticket => $Ticket &>
+
 % $m->callback(CallbackName => 'BeforeShowHistory', ARGSRef=> \%ARGS, Ticket => $Ticket );
 
 <& /Elements/ShowHistory,
diff --git a/share/html/Ticket/Create.html b/share/html/Ticket/Create.html
index b0e88b0..eb43a2e 100644
--- a/share/html/Ticket/Create.html
+++ b/share/html/Ticket/Create.html
@@ -111,6 +111,9 @@
       <& /Ticket/Elements/EditTransactionCustomFields, %ARGS, QueueObj => $QueueObj, InTable => 1, KeepValue => 1, &>
     </table>
   </&>
+
+<& /Ticket/Elements/ShowAssetsOnCreate, QueueObj => $QueueObj, ARGSRef => \%ARGS &>
+
 % $m->callback( CallbackName => 'AfterBasics', QueueObj => $QueueObj, ARGSRef => \%ARGS );
 
 <& /Elements/EditCustomFieldCustomGroupings,
diff --git a/share/html/Ticket/Elements/ShowAssets b/share/html/Ticket/Elements/ShowAssets
new file mode 100644
index 0000000..7df9bb0
--- /dev/null
+++ b/share/html/Ticket/Elements/ShowAssets
@@ -0,0 +1,204 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<%args>
+$Ticket
+$ShowRelatedTickets => 10
+</%args>
+<%init>
+my $target_assets = $Ticket->Links("Base")->Clone;
+$target_assets->Limit(
+    FIELD    => "Target",
+    OPERATOR => "STARTSWITH",
+    VALUE    => RT::URI::asset->LocalURIPrefix,
+);
+my $base_assets = $Ticket->Links("Target")->Clone;
+$base_assets->Limit(
+    FIELD    => "Base",
+    OPERATOR => "STARTSWITH",
+    VALUE    => RT::URI::asset->LocalURIPrefix,
+);
+
+my @linked_assets;
+push @linked_assets, grep { defined } map { $_->TargetURI->IsLocal }
+    @{ $target_assets->ItemsArrayRef };
+push @linked_assets, grep { defined } map { $_->BaseURI->IsLocal }
+    @{ $base_assets->ItemsArrayRef };
+
+my $asset_queue;
+if (RT->Config->Get('AssetQueues')) {
+    $asset_queue = 1 if grep {$_ eq $Ticket->QueueObj->Name} @{RT->Config->Get('AssetQueues')}
+} else {
+    $asset_queue = 1;
+}
+return unless @linked_assets or ($Ticket->CurrentUserHasRight("ModifyTicket")
+                                     and $asset_queue);
+
+my $assets = RT::Assets->new( $session{CurrentUser} );
+$assets->OrderBy( FIELD => "Name", ORDER => "ASC" );
+if ( @linked_assets ) {
+    $assets->Limit(
+        FIELD       => "id",
+        OPERATOR    => "IN",
+        VALUE       => \@linked_assets,
+    );
+}
+
+my $Format = RT->Config->Get("AssetSummaryFormat") || q[
+    '<a href="__WebHomePath__/Asset/Display.html?id=__id__">__Name__</a>/TITLE:Name',
+    Description,
+    Status,
+    Catalog,
+];
+
+$m->callback(
+    CallbackName    => 'ModifyCollection',
+    Ticket          => $Ticket,
+    Assets          => $assets,
+    Format          => \$Format,
+);
+
+
+</%init>
+<&| /Widgets/TitleBox,
+    title => loc('Assets'),
+    class => 'ticket-assets',
+    title_class => "inverse",
+    &>
+
+<form action="<% RT->Config->Get("WebPath") %>/Ticket/Display.html" method="POST" enctype="multipart/form-data">
+  <input type="hidden" name="id" value="<% $Ticket->id %>">
+
+% $m->callback( CallbackName => "Start", Ticket => $Ticket, Assets => $assets );
+
+<div id="assets-accordion" class="rt-accordion">
+% my $display_path = $session{'CurrentUser'}->Privileged ? 'Asset' : 'SelfService/Asset';
+% while (my $asset = $assets->Next) {
+  <h3><a href="<% RT->Config->Get('WebPath') %>/<% $display_path %>/Display.html?id=<% $asset->id %>"><&|/l, $asset->id, $asset->Name &>#[_1]: [_2]</&></a>
+<%perl>
+if ($Ticket->CurrentUserHasRight("ModifyTicket")) {
+    my $targets = $asset->Links("Target")->Clone;
+    $targets->Limit(
+        FIELD   => "LocalBase",
+        VALUE   => $Ticket->id,
+    );
+    my $bases = $asset->Links("Base")->Clone;
+    $bases->Limit(
+        FIELD   => "LocalTarget",
+        VALUE   => $Ticket->id,
+    );
+
+    my %params;
+    $params{join("-", "DeleteLink", "", $_->Type, $_->Target)} = 1
+        for @{ $targets->ItemsArrayRef };
+    $params{join("-", "DeleteLink", $_->Base, $_->Type, "")} = 1
+        for @{ $bases->ItemsArrayRef };
+
+    my $delete_url = RT->Config->Get("WebPath")
+        . "/Ticket/Display.html?"
+        . $m->comp("/Elements/QueryString", id => $Ticket->id, %params);
+</%perl>
+<a href="<% $delete_url %>" class="unlink-asset ui-icon ui-icon-circle-close" title="Unlink asset">X</a>
+% }
+  </h3>
+  <div class="details">
+    <& /Elements/ShowRecord,
+        Object      => $asset,
+        Format      => $Format,
+        TrustFormat => 1,
+        &>
+% $m->callback( CallbackName => "BeforeTickets", Ticket => $Ticket, Asset => $asset );
+<%perl>
+if ($ShowRelatedTickets) {
+    my %search = (
+        Query   => "id != '@{[$Ticket->id]}' AND LinkedTo = 'asset:@{[$asset->id]}'",
+        OrderBy => "LastUpdated",
+        Order   => "DESC",
+    );
+    my $url = RT->Config->Get("WebPath")
+            . "/Search/Results.html?"
+            . $m->comp("/Elements/QueryString", %search);
+</%perl>
+    <div class="related-tickets">
+      <span class="label">
+        <a href="<% $url %>">
+          <&|/l, $ShowRelatedTickets &>[_1] most recently updated related tickets</&>
+        </a>
+      </span>
+      <& /Elements/CollectionList,
+          %search,
+          Class             => "RT::Tickets",
+          Format            => RT->Config->Get("AssetSummaryRelatedTicketsFormat"),
+          Rows              => $ShowRelatedTickets,
+          ShowHeader        => 0,
+          AllowSorting      => 0,
+          ShowNavigation    => 0,
+      &>
+    </div>
+% }
+
+% $m->callback( CallbackName => "PerAsset", Ticket => $Ticket, Asset => $asset );
+
+  </div>
+% }
+</div>
+
+% if ($Ticket->CurrentUserHasRight("ModifyTicket")) {
+  <div class="add-asset">
+    <label>
+      <&|/l&>Add an asset to this ticket</&>
+    <input size="5" name="<% $Ticket->id %>-RefersTo" placeholder="<&|/l&>Asset #</&>" type="text">
+    </label>
+    <input type="submit" value="+">
+  </div>
+% }
+
+% $m->callback( CallbackName => "End", Ticket => $Ticket, Assets => $assets );
+
+</form>
+
+</&>
diff --git a/share/html/Ticket/Elements/ShowAssetsOnCreate b/share/html/Ticket/Elements/ShowAssetsOnCreate
new file mode 100644
index 0000000..a6f3bb4
--- /dev/null
+++ b/share/html/Ticket/Elements/ShowAssetsOnCreate
@@ -0,0 +1,121 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<%args>
+$QueueObj
+$ARGSRef
+</%args>
+<%init>
+my @linked_assets;
+foreach my $key ( map {+("new-$_", "$_-new")} keys %RT::Link::DIRMAP ) {
+    next unless $ARGSRef->{$key};
+    for my $linktext (grep $_, split ' ', $ARGSRef->{$key}) {
+        my $uri = RT::URI->new( $session{'CurrentUser'} );
+        next unless $uri->FromURI( $linktext );
+        next unless $uri->IsLocal and $uri->Object and $uri->Object->id and $uri->Object->isa("RT::Asset");
+        push @linked_assets, $uri->Object->id;
+    }
+}
+
+my $asset_queue;
+if (RT->Config->Get('AssetQueues')) {
+    $asset_queue = 1 if grep {$_ eq $QueueObj->Name} @{RT->Config->Get('AssetQueues')}
+} else {
+    $asset_queue = 1;
+}
+
+return unless @linked_assets or $asset_queue;
+
+my $assets = RT::Assets->new( $session{CurrentUser} );
+$assets->OrderBy( FIELD => "Name", ORDER => "ASC" );
+if ( @linked_assets ) {
+    $assets->Limit(
+        FIELD       => "id",
+        OPERATOR    => "IN",
+        VALUE       => \@linked_assets,
+    );
+}
+
+my $Format = RT->Config->Get("AssetSummaryFormat") || q[
+    '<a href="__WebHomePath__/Asset/Display.html?id=__id__">__Name__</a>/TITLE:Name',
+    Description,
+    Status,
+    Catalog,
+];
+
+$m->callback(
+    CallbackName    => 'ModifyCollection',
+    Queue           => $QueueObj,
+    Assets          => $assets,
+    Format          => \$Format,
+);
+
+
+</%init>
+<&| /Widgets/TitleBox,
+    title => loc('Assets'),
+    class => 'ticket-assets',
+    title_class => "inverse",
+    &>
+
+% $m->callback( CallbackName => "Start", Queue => $QueueObj, Assets => $assets );
+
+<div id="assets-accordion" class="rt-accordion">
+% while (my $asset = $assets->Next) {
+  <h3><a href="<% RT->Config->Get('WebPath') %>/Asset/Display.html?id=<% $asset->id %>"><&|/l, $asset->id, $asset->Name &>#[_1]: [_2]</&></a></h3>
+  <div class="details">
+    <& /Elements/ShowRecord,
+        Object      => $asset,
+        Format      => $Format,
+        TrustFormat => 1,
+        &>
+% $m->callback( CallbackName => "PerAsset", Queue => $QueueObj, Asset => $asset );
+  </div>
+% }
+</div>
+% $m->callback( CallbackName => "End", Queue => $QueueObj, Assets => $assets );
+</&>
diff --git a/share/html/Ticket/Elements/ShowSummary b/share/html/Ticket/Elements/ShowSummary
index 36ddb20..b55064d 100644
--- a/share/html/Ticket/Elements/ShowSummary
+++ b/share/html/Ticket/Elements/ShowSummary
@@ -91,6 +91,7 @@
 % $m->callback( %ARGS, CallbackName => 'AfterDates' );
 % my (@extra);
 % push @extra, titleright_raw => '<a href="'. RT->Config->Get('WebPath'). '/Ticket/Graphs/index.html?id='.$Ticket->id.'">'.loc('Graph').'</a>' unless RT->Config->Get('DisableGraphViz');
+<& /Ticket/Elements/ShowAssets, Ticket => $Ticket &>
 % $m->callback( %ARGS, CallbackName => 'LinksExtra', extra => \@extra );
     <&| /Widgets/TitleBox, title => loc('Links'),
         ($can_modify ? (title_href => RT->Config->Get('WebPath')."/Ticket/ModifyLinks.html?id=".$Ticket->Id) : ()),
diff --git a/share/html/User/Elements/AssetList b/share/html/User/Elements/AssetList
new file mode 100644
index 0000000..ef5a6a9
--- /dev/null
+++ b/share/html/User/Elements/AssetList
@@ -0,0 +1,78 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<%init>
+my $assets = RT::Assets->new($session{CurrentUser});
+$m->callback( CallbackName => 'ModifyAssetSearch', %ARGS, Assets => $assets, Roles => \@Roles );
+for my $role (@Roles) {
+    $assets->RoleLimit(
+        TYPE        => $role,
+        VALUE       => $User->PrincipalId,
+        SUBCLAUSE   => "Role$role",
+    );
+}
+my $Format = q[
+    '<b><a href="__WebHomePath__/Asset/Display.html?id=__id__">__id__</a></b>/TITLE:#',
+    '<b><a href="__WebHomePath__/Asset/Display.html?id=__id__">__Name__</a></b>/TITLE:Name',
+    Description,
+];
+$m->callback( CallbackName => 'ModifyFormat', %ARGS, Format => \$Format );
+</%init>
+<&| /Widgets/TitleBox, title => $Title, class => "user asset-list" &>
+    <& /Elements/CollectionList,
+        Collection      => $assets,
+        OrderBy         => 'id',
+        Order           => 'ASC',
+        Format          => $Format,
+        AllowSorting    => 0,
+        &>
+</&>
+<%args>
+$User
+$Title
+ at Roles
+</%args>
diff --git a/share/html/User/Elements/Portlets/UserAssets b/share/html/User/Elements/Portlets/UserAssets
new file mode 100644
index 0000000..8bec6b1
--- /dev/null
+++ b/share/html/User/Elements/Portlets/UserAssets
@@ -0,0 +1,52 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+%# Roles => [''] triggers the magical RoleLimit behavior that matches any role
+<& /User/Elements/AssetList, User => $User, Roles => [''], Title => loc('Assigned Assets') &>
+<%ARGS>
+$User
+</%ARGS>
diff --git a/share/static/css/base/assets.css b/share/static/css/base/assets.css
new file mode 100644
index 0000000..3526fa7
--- /dev/null
+++ b/share/static/css/base/assets.css
@@ -0,0 +1,229 @@
+#assets-accordion h3 {
+    position: relative;
+}
+
+#assets-accordion h3 a.unlink-asset {
+    position: absolute;
+    top: 0;
+    right: 0;
+    left: inherit;
+    padding: 0;
+}
+
+.ticket-assets .add-asset {
+    padding: 2em 0 0 0;
+    text-align: right;
+}
+
+body#comp-Asset-Search .collection-as-table td {
+    white-space: nowrap;
+}
+
+/* Colors */
+
+.asset-basics   .titlebox .titlebox-title .left,
+.asset-info-cfs .titlebox .titlebox-title .left { background-color: #b32    }
+.asset-people   .titlebox .titlebox-title .left { background-color: #48c    }
+.asset-dates    .titlebox .titlebox-title .left { background-color: #633063 }
+.asset-links    .titlebox .titlebox-title .left { background-color: #316531 }
+.ticket-assets  .titlebox .titlebox-title .left { background-color: #316531 }
+
+/* People display */
+
+#comp-Asset-Display .asset-people table {
+    width: 100%;
+}
+
+#comp-Asset-Display .asset-people td.label {
+    text-align: left;
+}
+
+#comp-Asset-Display .asset-people h3 {
+    margin: 0;
+    padding: 0;
+    line-height: 1.3;
+    font-size: 100%;
+}
+
+#comp-Asset-Display .asset-people .details {
+    padding: 0;
+}
+
+/* People editing */
+
+.asset-people .edit ul.role-members {
+    margin-top: 0;
+    padding-left: 0;
+    list-style: none;
+}
+
+.asset-people .edit td {
+    vertical-align: top;
+}
+
+.asset-people .edit .note {
+    font-size: 0.9em;
+}
+
+.asset-people .edit h3 {
+    margin-top: 0;
+    margin-bottom: 0;
+}
+
+.asset-people .edit .role h3 {
+    margin-bottom: 0.5em;
+}
+
+.asset-people .edit .role,
+.asset-people .edit .add-group,
+.asset-people .edit .add-user {
+    margin-bottom: 1em;
+}
+
+/* Asset summary */
+
+.ticket-assets form {
+    display: inherit;
+}
+
+.ticket-assets .related-tickets {
+    margin-top: 1em;
+}
+
+.ticket-assets .related-tickets .label a {
+    font-weight: bold;
+    color: black;
+}
+
+.asset-metadata>div {
+    vertical-align: top;
+    min-width: 30%;
+    max-width: 30%;
+    padding-right: 1.5em;
+    display: inline-block;
+}
+
+.asset-metadata {
+    padding-top: 2em; /* nav overflows this :( */;
+}
+
+ at media (max-width: 800px) {
+    .asset-metadata>div {
+        min-width: 45%;
+        width: 45%;
+    }
+}
+
+/* on a little screen, let's just use a single column */
+ at media (max-width: 600px) {
+    .asset-metadata {
+        padding-top: 6em;
+        /* nav overflows this: < */;
+    }
+
+    .asset-metadata>div {
+        min-width: 100%;
+        width: 100%;
+    }
+
+    #Asset-Create-basics>table,
+    #Asset-Create-basics>table>tbody>tr,
+    #Asset-Create-basics>table>tbody>tr>td {
+        display: block;
+    }
+}
+
+/* On a reasonable-width screen, make better use of whitespace */
+ at media (min-width: 601px) {
+    .asset-info-cfs .edit-custom-fields {
+        width: 100%;
+    }
+
+    .asset-info-cfs .edit-custom-fields tr td,
+    #ModifyAsset .asset-basics tr td {
+        display: inline-block;
+    }
+
+    .asset-info-cfs .edit-custom-fields tr,
+    #ModifyAsset .asset-basics tr {
+        display: inline-block;
+        width: 49%;
+    }
+
+    .asset-info-cfs .edit-custom-fields tr td.cflabel,
+    #ModifyAsset .asset-basics tr td.label,
+    #ModifyAsset .asset-basics tr td.cflabel {
+        width: 8em;
+    }
+
+    /* Asset creation */
+    #Asset-Create-basics>table {
+        width: 100%;
+        align: left;
+    }
+
+    #Asset-Create-basics>table>tbody>tr>td {
+        padding-right: 2em;
+    }
+
+    #Asset-Create-basics>table>tbody>tr {
+        vertical-align: top;
+    }
+}
+
+/* basic cleanups for the search UI's elements */
+.asset-search-grouping input.datepicker {
+    width: 7em;
+}
+
+.asset-search-grouping td * {
+    max-width: 11em;
+}
+
+.asset-search-grouping td.label.not {
+    min-width: 3em;
+    width: auto;
+    padding-left: 1em;
+}
+
+/* On a wide screen, use two columns for search/bulk criteria */
+ at media (min-width:1150px) {
+    .asset-bulk-grouping.asset-bulk-cfs,
+    .asset-search-grouping.asset-search-cfs {
+        display: inline-block;
+        width: 45%;
+        padding-right: 1em;
+        vertical-align: top;
+    }
+
+    .titlebox.asset-bulk-grouping.asset-bulk-cfs,
+    .titlebox.asset-search-grouping.asset-search-cfs {
+        display: block;
+        width: auto;
+        padding: inherit;
+    }
+
+    .asset-bulk-people tr.full-width,
+    .asset-search-people tr.ful-width,
+    .asset-bulk-basics tr.full-width,
+    .asset-search-basics tr.full-width {
+        width: 100%;
+    }
+
+    .asset-bulk-people tr,
+    .asset-search-people tr,
+    .asset-bulk-basics tr,
+    .asset-search-basics tr {
+        width: 49%;
+        display: inline-block;
+        white-space: nowrap;
+    }
+
+    .asset-bulk-people tr>td,
+    .asset-search-people tr>td,
+    .asset-bulk-basics tr>td,
+    .asset-search-basics tr>td {
+        display: inline-block;
+        width: 10em;
+    }
+}
diff --git a/share/static/js/assets.js b/share/static/js/assets.js
new file mode 100644
index 0000000..853ba86
--- /dev/null
+++ b/share/static/js/assets.js
@@ -0,0 +1,44 @@
+jQuery(function() {
+    var showModal = function(html) {
+        jQuery("<div class='modal'></div>")
+            .append(html).appendTo("body")
+            .bind('modal:close', function(ev,modal) { modal.elm.remove(); })
+            .modal();
+    };
+
+    var assets = jQuery("#assets-accordion");
+    assets.accordion({
+        // Open the accordion if there's only one fold, otherwise start with
+        // all assets collapsed.
+        active:         assets.find("h3").length == 1 ? 0 : false,
+        collapsible:    true,
+        heightStyle:    'content',
+        header: "h3"
+    }).find("h3 a.unlink-asset").click(function(ev){
+        ev.stopPropagation();
+        return true;
+    });
+    jQuery(".ticket-assets form").submit(function(){
+        var input = jQuery("[name*=RefersTo]", this);
+        if (input.val())
+            input.val(input.val().match(/\S+/g)
+                                 .map(function(x){return "asset:"+x})
+                                 .join(" "));
+    });
+    jQuery("#page-actions-create-linked-ticket").click(function(ev){
+        ev.preventDefault();
+        var url = this.href.replace(/\/Asset\/CreateLinkedTicket\.html\?/g,
+                                    '/Asset/Helpers/CreateLinkedTicket?');
+        jQuery.get(
+            url,
+            showModal
+        );
+    });
+    jQuery("#assets-create").click(function(ev){
+        ev.preventDefault();
+        jQuery.get(
+            RT.Config.WebHomePath + "/Asset/Helpers/CreateInCatalog",
+            showModal
+        );
+    });
+});
diff --git a/t/assets/api.t b/t/assets/api.t
new file mode 100644
index 0000000..df64eab
--- /dev/null
+++ b/t/assets/api.t
@@ -0,0 +1,179 @@
+use strict;
+use warnings;
+
+use RT::Test::Assets tests => undef;
+use Test::Warn;
+
+my $catalog;
+
+diag "Create a catalog";
+{
+    $catalog = create_catalog( Name => 'Test Catalog', Disabled => 1 );
+    ok $catalog && $catalog->id, "Created catalog";
+    is $catalog->Name, "Test Catalog", "Name is correct";
+    ok $catalog->Disabled, "Disabled";
+
+    my $asset;
+    warning_like {
+        $asset = create_asset( Name => "Test", Catalog => $catalog->id );
+    } qr/^Failed to create asset .* Invalid catalog/i;
+    ok !$asset, "Couldn't create asset in disabled catalog";
+
+    my ($ok, $msg) = $catalog->SetDisabled(0);
+    ok $ok, "Enabled catalog: $msg";
+    ok !$catalog->Disabled, "Enabled";
+}
+
+diag "Create basic asset (no CFs)";
+{
+    my $asset = RT::Asset->new( RT->SystemUser );
+    my ($id, $msg) = $asset->Create(
+        Name        => 'Thinkpad T420s',
+        Description => 'Laptop',
+        Catalog     => $catalog->Name,
+    );
+    ok $id, "Created: $msg";
+    is $asset->id, $id, "id matches";
+    is $asset->Name, "Thinkpad T420s", "Name matches";
+    is $asset->Description, "Laptop", "Description matches";
+
+    # Create txn
+    my @txns = @{$asset->Transactions->ItemsArrayRef};
+    is scalar @txns, 1, "One transaction";
+    is $txns[0]->Type, "Create", "... of type Create";
+
+    # Update
+    my ($txnid, $txnmsg) = $asset->SetName("Lenovo Thinkpad T420s");
+    ok $txnid, "Updated Name: $txnmsg";
+    is $asset->Name, "Lenovo Thinkpad T420s", "New Name matches";
+
+    # Set txn
+    @txns = @{$asset->Transactions->ItemsArrayRef};
+    is scalar @txns, 2, "Two transactions";
+    is $txns[1]->Type, "Set", "... the second of which is Set";
+    is $txns[1]->Field, "Name", "... Field is Name";
+    is $txns[1]->OldValue, "Thinkpad T420s", "... OldValue is correct";
+
+    # Delete
+    my ($ok, $err) = $asset->Delete;
+    ok !$ok, "Deletes are prevented: $err";
+    $asset->Load($id);
+    ok $asset->id, "Asset not deleted";
+}
+
+diag "Create with CFs";
+{
+    my $height = create_cf( Name => 'Height' );
+    ok $height->id, "Created CF";
+
+    my $material = create_cf( Name => 'Material' );
+    ok $material->id, "Created CF";
+
+    ok apply_cfs($height, $material), "Applied CFs";
+
+    my $asset = RT::Asset->new( RT->SystemUser );
+    my ($id, $msg) = $asset->Create(
+        Name                        => 'Standing desk',
+        "CustomField-".$height->id  => '46"',
+        "CustomField-Material"      => 'pine',
+        Catalog                     => $catalog->Name,
+    );
+    ok $id, "Created: $msg";
+    is $asset->FirstCustomFieldValue('Height'), '46"', "Found height";
+    is $asset->FirstCustomFieldValue('Material'), 'pine', "Found material";
+    is $asset->Transactions->Count, 1, "Only a single txn";
+}
+
+note "Create/update with Roles";
+{
+    my $root = RT::User->new( RT->SystemUser );
+    $root->Load("root");
+    ok $root->id, "Found root";
+
+    my $bps = RT::Test->load_or_create_user( Name => "BPS" );
+    ok $bps->id, "Created BPS user";
+
+    my $asset = RT::Asset->new( RT->SystemUser );
+    my ($id, $msg) = $asset->Create(
+        Name    => 'RT server',
+        HeldBy  => $root->PrincipalId,
+        Owner   => $bps->PrincipalId,
+        Contact => $bps->PrincipalId,
+        Catalog => $catalog->id,
+    );
+    ok $id, "Created: $msg";
+    is $asset->HeldBy->UserMembersObj->First->Name, "root", "root is Holder";
+    is $asset->Owner->Name, "BPS", "BPS is Owner";
+    is $asset->Contacts->UserMembersObj->First->Name, "BPS", "BPS is Contact";
+
+    my $sysadmins = RT::Group->new( RT->SystemUser );
+    $sysadmins->CreateUserDefinedGroup( Name => 'Sysadmins' );
+    ok $sysadmins->id, "Created group";
+    is $sysadmins->Name, "Sysadmins", "Got group name";
+
+    (my $ok, $msg) = $asset->AddRoleMember(
+        Type        => 'Contact',
+        Group       => 'Sysadmins',
+    );
+    ok $ok, "Added Sysadmins as Contact: $msg";
+    is $asset->Contacts->MembersObj->Count, 2, "Found two members";
+
+    my @txn = grep { $_->Type eq 'AddWatcher' } @{$asset->Transactions->ItemsArrayRef};
+    ok @txn == 1, "Found one AddWatcher txn";
+    is $txn[0]->Field, "Contact", "... of a Contact";
+    is $txn[0]->NewValue, $sysadmins->PrincipalId, "... for the right principal";
+
+    ($ok, $msg) = $asset->DeleteRoleMember(
+        Type        => 'Contact',
+        PrincipalId => $bps->PrincipalId,
+    );
+    ok $ok, "Removed BPS user as Contact: $msg";
+    is $asset->Contacts->MembersObj->Count, 1, "Now just one member";
+    is $asset->Contacts->GroupMembersObj(Recursively => 0)->First->Name, "Sysadmins", "... it's Sysadmins";
+
+    @txn = grep { $_->Type eq 'DelWatcher' } @{$asset->Transactions->ItemsArrayRef};
+    ok @txn == 1, "Found one DelWatcher txn";
+    is $txn[0]->Field, "Contact", "... of a Contact";
+    is $txn[0]->OldValue, $bps->PrincipalId, "... for the right principal";
+}
+
+diag "Custom Field handling";
+{
+    diag "Make sure we don't load queue CFs";
+    my $queue_cf = RT::CustomField->new( RT->SystemUser );
+    my ($ok, $msg) = $queue_cf->Create(
+        Name       => "Queue CF",
+        Type       => "Text",
+        LookupType => RT::Queue->CustomFieldLookupType,
+    );
+    ok( $queue_cf->Id, "Created test CF: " . $queue_cf->Id);
+
+    my $cf1 = RT::CustomField->new( RT->SystemUser );
+    $cf1->LoadByNameAndCatalog ( Name => "Queue CF" );
+
+    ok( (not $cf1->Id), "Queue CF not loaded with LoadByNameAndCatalog");
+
+    my $cf2 = RT::CustomField->new( RT->SystemUser );
+    $cf2->LoadByNameAndCatalog ( Name => "Height" );
+    ok( $cf2->Id, "Loaded CF id: " . $cf2->Id . " with name");
+    ok( $cf2->Name, "Loaded CF name: " . $cf2->Name . " with name");
+
+    my $cf3 = RT::CustomField->new( RT->SystemUser );
+    ($ok, $msg) = $cf3->LoadByNameAndCatalog ( Name => "Height", Catalog => $catalog->Name );
+    ok( (not $cf3->Id), "CF 'Height'"
+      . " not added to catalog: " . $catalog->Name);
+
+    my $color = create_cf( Name => 'Color'  );
+    ok $color->Id, "Created CF " . $color->Name;
+    ($ok, $msg) = $color->AddToObject( $catalog );
+
+    ($ok, $msg) = $color->LoadByNameAndCatalog ( Name => "Color", Catalog => $catalog->Name );
+    ok( $color->Id, "Loaded CF id: " . $color->Id
+      . " for catalog: " . $catalog->Name);
+    ok( $color->Name, "Loaded CF name: " . $color->Name
+    . " for catalog: " . $catalog->Name);
+
+}
+
+
+done_testing;
diff --git a/t/assets/collection.t b/t/assets/collection.t
new file mode 100644
index 0000000..f2c3019
--- /dev/null
+++ b/t/assets/collection.t
@@ -0,0 +1,69 @@
+use strict;
+use warnings;
+
+use RT::Test::Assets tests => undef;
+
+my $user = RT::Test->load_or_create_user( Name => 'testuser' );
+ok $user->id, "Created user";
+
+my $catalog  = create_catalog( Name => "BPS" );
+ok $catalog && $catalog->id, "Created catalog";
+
+my $location = create_cf( Name => 'Location' );
+ok $location->id, "Created CF";
+ok apply_cfs($location), "Applied CF";
+
+ok(
+    create_assets(
+        { Name => "Thinkpad T420s", Catalog => $catalog->id, "CustomField-Location" => "Home" },
+        { Name => "Standing desk",  Catalog => $catalog->id, "CustomField-Location" => "Office" },
+        { Name => "Chair",          Catalog => $catalog->id, "CustomField-Location" => "Office" },
+    ),
+    "Created assets"
+);
+
+diag "Mark chair as deleted";
+{
+    my $asset = RT::Asset->new( RT->SystemUser );
+    $asset->LoadByCols( Name => "Chair" );
+    my ($ok, $msg) = $asset->SetStatus( "deleted" );
+    ok($ok, "Deleted the chair: $msg");
+}
+
+diag "Basic types of limits";
+{
+    my $assets = RT::Assets->new( RT->SystemUser );
+    $assets->Limit( FIELD => 'Name', OPERATOR => 'LIKE', VALUE => 'thinkpad' );
+    is $assets->Count, 1, "Found 1 like thinkpad";
+    is $assets->First->Name, "Thinkpad T420s";
+
+    $assets = RT::Assets->new( RT->SystemUser );
+    $assets->UnLimit;
+    is $assets->Count, 2, "Found 2 total";
+    ok((!grep { $_->Name eq "Chair" } @{$assets->ItemsArrayRef}), "No chair (disabled)");
+
+    $assets = RT::Assets->new( RT->SystemUser );
+    $assets->Limit( FIELD => 'Status', VALUE => 'deleted' );
+    $assets->{allow_deleted_search} = 1;
+    is $assets->Count, 1, "Found 1 deleted";
+    is $assets->First->Name, "Chair", "Found chair";
+
+    $assets = RT::Assets->new( RT->SystemUser );
+    $assets->UnLimit;
+    $assets->LimitCustomField(
+        CUSTOMFIELD => $location->id,
+        VALUE       => "Office",
+    );
+    is $assets->Count, 1, "Found 1 in Office";
+    ok $assets->First, "Got record";
+    is $assets->First->Name, "Standing desk", "Found standing desk";
+}
+
+diag "Test ACLs";
+{
+    my $assets = RT::Assets->new( RT::CurrentUser->new($user) );
+    $assets->UnLimit;
+    is scalar @{$assets->ItemsArrayRef}, 0, "Found none";
+}
+
+done_testing;
diff --git a/t/assets/compile.t b/t/assets/compile.t
new file mode 100644
index 0000000..ea27bc5
--- /dev/null
+++ b/t/assets/compile.t
@@ -0,0 +1,10 @@
+use strict;
+use warnings;
+
+use Test::More;
+
+use_ok('RT::Test::Assets');
+use_ok('RT::Asset');
+use_ok('RT::Assets');
+use_ok('RT::Catalog');
+use_ok('RT::Catalogs');
diff --git a/t/assets/links.t b/t/assets/links.t
new file mode 100644
index 0000000..a9101fe
--- /dev/null
+++ b/t/assets/links.t
@@ -0,0 +1,129 @@
+use strict;
+use warnings;
+
+use RT::Test::Assets tests => undef;
+use Test::Warn;
+
+my $catalog = create_catalog( Name => "BPS" );
+ok $catalog && $catalog->id, "Created Catalog";
+
+ok(
+    create_assets(
+        { Name => "Thinkpad T420s", Catalog => $catalog->id },
+        { Name => "Standing desk", Catalog => $catalog->id },
+        { Name => "Chair", Catalog => $catalog->id },
+    ),
+    "Created assets"
+);
+
+my $ticket = RT::Test->create_ticket(
+    Queue   => 1,
+    Subject => 'a test ticket',
+);
+ok $ticket->id, "Created ticket";
+
+diag "RT::URI::asset";
+{
+    my %uris = (
+        # URI                   => Asset Name
+        "asset:1"               => { id => 1, Name => "Thinkpad T420s" },
+        "asset://example.com/2" => { id => 2, Name => "Standing desk" },
+        "asset:13"              => undef,
+    );
+
+    while (my ($url, $expected) = each %uris) {
+        my $uri = RT::URI->new( RT->SystemUser );
+        if ($expected) {
+            my $parsed = $uri->FromURI($url);
+            ok $parsed, "Parsed $url";
+
+            my $asset = $uri->Object;
+            ok $asset, "Got object";
+            is ref($asset), "RT::Asset", "... it's a RT::Asset";
+
+            while (my ($field, $value) = each %$expected) {
+                is $asset->$field, $value, "... $field is $value";
+            }
+        } else {
+            my $parsed;
+            warnings_like {
+                $parsed = $uri->FromURI($url);
+            } [qr/\Q$url\E/, qr/\Q$url\E/], "Caught warnings about unknown URI";
+            ok !$parsed, "Failed to parse $url, as expected";
+        }
+    }
+}
+
+diag "RT::Asset link support";
+{
+    my $chair = RT::Asset->new( RT->SystemUser );
+    $chair->LoadByCols( Name => "Chair" );
+    ok $chair->id, "Loaded asset";
+    is $chair->URI, "asset://example.com/".$chair->id, "->URI works";
+
+    my ($link_id, $msg) = $chair->AddLink( Type => 'MemberOf', Target => 'asset:2' );
+    ok $link_id, "Added link: $msg";
+
+    my $parents = $chair->MemberOf;
+    my $desk    = $parents->First->TargetObj;
+    is $parents->Count, 1, "1 parent";
+    is $desk->Name, "Standing desk", "Correct parent asset";
+
+    for my $asset ($chair, $desk) {
+        my $txns = $asset->Transactions;
+        $txns->Limit( FIELD => 'Type', VALUE => 'AddLink' );
+        is $txns->Count, 1, "1 AddLink txn on asset ".$asset->Name;
+    }
+
+    my ($ok, $err) = $chair->DeleteLink( Type => 'MemberOf', Target => 'asset:1' );
+    ok !$ok, "Delete link failed on non-existent: $err";
+
+    my ($deleted, $delete_msg) = $chair->DeleteLink( Type => 'MemberOf', Target => $parents->First->Target );
+    ok $deleted, "Deleted link: $delete_msg";
+
+    for my $asset ($chair, $desk) {
+        my $txns = $asset->Transactions;
+        $txns->Limit( FIELD => 'Type', VALUE => 'DeleteLink' );
+        is $txns->Count, 1, "1 DeleteLink txn on asset ".$asset->Name;
+    }
+};
+
+diag "Linking to tickets";
+{
+    my $laptop = RT::Asset->new( RT->SystemUser );
+    $laptop->LoadByCols( Name => "Thinkpad T420s" );
+
+    my ($ok, $msg) = $ticket->AddLink( Type => 'RefersTo', Target => $laptop->URI );
+    ok $ok, "Ticket refers to asset: $msg";
+
+    my $links = $laptop->ReferredToBy;
+    is $links->Count, 1, "Found a ReferredToBy link via asset";
+
+    ($ok, $msg) = $laptop->DeleteLink( Type => 'RefersTo', Base => $ticket->URI );
+    ok $ok, "Deleted link from opposite side: $msg";
+}
+
+diag "Links on ->Create";
+{
+    my $desk = RT::Asset->new( RT->SystemUser );
+    $desk->LoadByCols( Name => "Standing desk" );
+    ok $desk->id, "Loaded standing desk asset";
+
+    my $asset = create_asset(
+        Name            => "Anti-fatigue mat",
+        Catalog         => $catalog->id,
+        Parent          => $desk->URI,
+        ReferredToBy    => [$ticket->id],
+    );
+    ok $asset->id, "Created asset with Parent link";
+
+    my $parents = $asset->MemberOf;
+    is $parents->Count, 1, "Found one Parent";
+    is $parents->First->Target, $desk->URI, "... it's a desk!";
+
+    my $referrals = $asset->ReferredToBy;
+    is $referrals->Count, 1, "Found one ReferredToBy";
+    is $referrals->First->Base, $ticket->URI, "... it's the ticket!";
+}
+
+done_testing;
diff --git a/t/assets/pod.t b/t/assets/pod.t
new file mode 100644
index 0000000..1d2686c
--- /dev/null
+++ b/t/assets/pod.t
@@ -0,0 +1,6 @@
+use strict;
+use warnings;
+
+use Test::More;
+use Test::Pod;
+all_pod_files_ok( all_pod_files("lib","doc","etc"));
diff --git a/t/assets/rights.t b/t/assets/rights.t
new file mode 100644
index 0000000..b28b16b
--- /dev/null
+++ b/t/assets/rights.t
@@ -0,0 +1,124 @@
+use strict;
+use warnings;
+
+use RT::Test::Assets tests => undef;
+
+my $user = RT::Test->load_or_create_user( Name => 'testuser' );
+ok $user->id, "Created user";
+
+my $ticket = RT::Test->create_ticket(
+    Queue   => 1,
+    Subject => 'a test ticket',
+);
+ok $ticket->id, "Created ticket";
+
+my $catalog_one = create_catalog( Name => "One" );
+ok $catalog_one && $catalog_one->id, "Created catalog one";
+
+my $catalog_two = create_catalog( Name => "Two" );
+ok $catalog_two && $catalog_two->id, "Created catalog two";
+
+ok(RT::Test->add_rights({
+    Principal   => 'Privileged',
+    Right       => 'ShowCatalog',
+    Object      => $catalog_one,
+}), "Granted ShowCatalog");
+
+my $asset = RT::Asset->new( RT::CurrentUser->new($user) );
+
+diag "CreateAsset";
+{
+    my %create = (
+        Name    => 'Thinkpad T420s',
+        Contact => 'trs at example.com',
+        Catalog => $catalog_one->id,
+    );
+    my ($id, $msg) = $asset->Create(%create);
+    ok !$id, "Create denied: $msg";
+
+    ok(RT::Test->add_rights({
+        Principal   => 'Privileged',
+        Right       => 'CreateAsset',
+        Object      => $catalog_one,
+    }), "Granted CreateAsset");
+
+    ($id, $msg) = $asset->Create(%create);
+    ok $id, "Created: $msg";
+    is $asset->id, $id, "id matches";
+    is $asset->CatalogObj->Name, $catalog_one->Name, "Catalog matches";
+};
+
+diag "ShowAsset";
+{
+    is $asset->Name, undef, "Can't see Name without ShowAsset";
+    ok !$asset->Contacts->id, "Can't see Contacts role group";
+
+    ok(RT::Test->add_rights({
+        Principal   => 'Privileged',
+        Right       => 'ShowAsset',
+        Object      => $catalog_one,
+    }), "Granted ShowAsset");
+
+    is $asset->Name, "Thinkpad T420s", "Got Name";
+    is $asset->Contacts->UserMembersObj->First->EmailAddress, 'trs at example.com', "Got Contact";
+}
+
+diag "ModifyAsset";
+{
+    my ($txnid, $txnmsg) = $asset->SetName("Lenovo Thinkpad T420s");
+    ok !$txnid, "Update failed: $txnmsg";
+    is $asset->Name, "Thinkpad T420s", "Name didn't change";
+
+    my ($ok, $msg) = $asset->AddLink( Type => 'RefersTo', Target => 't:1' );
+    ok !$ok, "No rights to AddLink: $msg";
+
+    ($ok, $msg) = $asset->DeleteLink( Type => 'RefersTo', Target => 't:1' );
+    ok !$ok, "No rights to DeleteLink: $msg";
+
+    ok(RT::Test->add_rights({
+        Principal   => 'Privileged',
+        Right       => 'ModifyAsset',
+        Object      => $catalog_one,
+    }), "Granted ModifyAsset");
+    
+    ($txnid, $txnmsg) = $asset->SetName("Lenovo Thinkpad T420s");
+    ok $txnid, "Updated Name: $txnmsg";
+    is $asset->Name, "Lenovo Thinkpad T420s", "Name changed";
+}
+
+diag "Catalogs";
+{
+    my ($txnid, $txnmsg) = $asset->SetCatalog($catalog_two->id);
+    ok !$txnid, "Failed to update Catalog: $txnmsg";
+    is $asset->CatalogObj->Name, $catalog_one->Name, "Catalog unchanged";
+
+    ok(RT::Test->add_rights({
+        Principal   => 'Privileged',
+        Right       => 'CreateAsset',
+        Object      => $catalog_two,
+    }), "Granted CreateAsset in second catalog");
+
+    ($txnid, $txnmsg) = $asset->SetCatalog($catalog_two->id);
+    ok $txnid, "Updated Catalog: $txnmsg";
+    unlike $txnmsg, qr/Permission Denied/i, "Transaction message isn't Permission Denied";
+    ok !$asset->CurrentUserCanSee, "Can no longer see the asset";
+
+    ok(RT::Test->add_rights({
+        Principal   => 'Privileged',
+        Right       => 'ShowAsset',
+        Object      => $catalog_two,
+    }), "Granted ShowAsset");
+
+    ok $asset->CurrentUserCanSee, "Can see the asset now";
+    is $asset->CatalogObj->Name, undef, "Can't see the catalog name still";
+
+    ok(RT::Test->add_rights({
+        Principal   => 'Privileged',
+        Right       => 'ShowCatalog',
+        Object      => $catalog_two,
+    }), "Granted ShowCatalog");
+
+    is $asset->CatalogObj->Name, $catalog_two->Name, "Now we can see the catalog name";
+}
+
+done_testing;
diff --git a/t/assets/roles.t b/t/assets/roles.t
new file mode 100644
index 0000000..1d8a647
--- /dev/null
+++ b/t/assets/roles.t
@@ -0,0 +1,29 @@
+use strict;
+use warnings;
+
+use RT::Test::Assets tests => undef;
+
+my $catalog = create_catalog( Name => "A catalog" );
+my $asset = create_asset( Name => "Test asset", Catalog => $catalog->id );
+ok $asset && $asset->id, "Created asset";
+
+for my $object ($asset, $catalog, RT->System) {
+    for my $role (RT::Asset->Roles) {
+        my $group = $object->RoleGroup($role);
+        ok $group->id, "Loaded role group $role for " . ref($object);
+
+        my $principal = $group->PrincipalObj;
+        ok $principal && $principal->id, "Found PrincipalObj for role group"
+            or next;
+
+        if ($object->DOES("RT::Record::Role::Rights")) {
+            my ($ok, $msg) = $principal->GrantRight(
+                Object  => $object,
+                Right   => "ShowAsset",
+            );
+            ok $ok, "Granted right" or diag "Error: $msg";
+        }
+    }
+}
+
+done_testing;
diff --git a/t/assets/web.t b/t/assets/web.t
new file mode 100644
index 0000000..3595e26
--- /dev/null
+++ b/t/assets/web.t
@@ -0,0 +1,113 @@
+use strict;
+use warnings;
+
+use RT::Test::Assets tests => undef;
+
+RT->Config->Set("CustomFieldGroupings",
+    "RT::Asset" => {
+        Dates => [qw(Purchased)],
+    },
+);
+
+my $catalog = create_catalog( Name => "Office" );
+ok $catalog->id, "Created Catalog";
+
+my $purchased = create_cf( Name => 'Purchased', Pattern => '(?#Year)^(?:19|20)\d{2}$' );
+ok $purchased->id, "Created CF";
+
+my $height = create_cf( Name => 'Height', Pattern => '(?#Inches)^\d+"?$' );
+ok $height->id, "Created CF";
+
+my $material = create_cf( Name => 'Material' );
+ok $material->id, "Created CF";
+
+my %CF = (
+    Height      => ".CF-" . $height->id    . "-Edit",
+    Material    => ".CF-" . $material->id  . "-Edit",
+    Purchased   => ".CF-" . $purchased->id . "-Edit",
+);
+
+my ($base, $m) = RT::Test::Assets->started_ok;
+ok $m->login, "Logged in agent";
+
+diag "Create basic asset (no CFs)";
+{
+    $m->follow_link_ok({ id => "assets-create" }, "Asset create link");
+    $m->submit_form_ok({ with_fields => { Catalog => $catalog->id } }, "Picked a catalog");
+    $m->submit_form_ok({
+        with_fields => {
+            id          => 'new',
+            Name        => 'Thinkpad T420s',
+            Description => 'A laptop',
+        },
+    }, "submited create form");
+    $m->content_like(qr/Asset .* created/, "Found created message");
+    my ($id) = $m->uri =~ /id=(\d+)/;
+
+    my $asset = RT::Asset->new( RT->SystemUser );
+    $asset->Load($id);
+    is $asset->id, $id, "id matches";
+    is $asset->Name, "Thinkpad T420s", "Name matches";
+    is $asset->Description, "A laptop", "Description matches";
+}
+
+diag "Create with CFs";
+{
+    ok apply_cfs($height, $material), "Applied CFs";
+
+    $m->follow_link_ok({ id => "assets-create" }, "Asset create link");
+    $m->submit_form_ok({ with_fields => { Catalog => $catalog->id } }, "Picked a catalog");
+
+    ok $m->form_with_fields(qw(id Name Description)), "Found form";
+    $m->submit_form_ok({
+        fields => {
+            id              => 'new',
+            Name            => 'Standing desk',
+            $CF{Height}     => 'forty-six inches',
+            $CF{Material}   => 'pine',
+        },
+    }, "submited create form");
+    $m->content_unlike(qr/Asset .* created/, "Lacks created message");
+    $m->content_like(qr/must match .*?Inches/, "Found validation error");
+
+    # Intentionally fix only the invalid CF to test the other fields are
+    # preserved across errors
+    ok $m->form_with_fields(qw(id Name Description)), "Found form again";
+    $m->set_fields( $CF{Height} => '46"' );
+    $m->submit_form_ok({}, "resubmitted form");
+
+    $m->content_like(qr/Asset .* created/, "Found created message");
+    my ($id) = $m->uri =~ /id=(\d+)/;
+
+    my $asset = RT::Asset->new( RT->SystemUser );
+    $asset->Load($id);
+    is $asset->id, $id, "id matches";
+    is $asset->FirstCustomFieldValue('Height'), '46"', "Found height";
+    is $asset->FirstCustomFieldValue('Material'), 'pine', "Found material";
+}
+
+diag "Create with CFs in other groups";
+{
+    ok apply_cfs($purchased), "Applied CF";
+
+    $m->follow_link_ok({ id => "assets-create" }, "Asset create link");
+    $m->submit_form_ok({ with_fields => { Catalog => $catalog->id } }, "Picked a catalog");
+
+    ok $m->form_with_fields(qw(id Name Description)), "Found form";
+
+    $m->submit_form_ok({
+        fields => {
+            id          => 'new',
+            Name        => 'Chair',
+            $CF{Height} => '23',
+        },
+    }, "submited create form");
+
+    $m->content_like(qr/Asset .* created/, "Found created message");
+    $m->content_unlike(qr/Purchased.*?must match .*?Year/, "Lacks validation error for Purchased");
+}
+
+# XXX TODO: test other modify pages
+
+undef $m;
+done_testing;

-----------------------------------------------------------------------


More information about the rt-commit mailing list