From a763a4def915a3f4347f013429d543489520d97a Mon Sep 17 00:00:00 2001
From: Peter Rotich <peter@osticket.com>
Date: Sun, 7 Dec 2014 22:28:46 +0000
Subject: [PATCH] Role-based Access

Introduce the concept of role-based access for agents.
---
 bootstrap.php                            |   1 +
 include/class.i18n.php                   |   4 +-
 include/class.nav.php                    |   1 +
 include/class.role.php                   | 279 +++++++++++++++++++++++
 include/i18n/en_US/role.yaml             |  60 +++++
 include/staff/role.inc.php               | 118 ++++++++++
 include/staff/roles.inc.php              | 125 ++++++++++
 scp/roles.php                            | 139 +++++++++++
 setup/inc/streams/core/install-mysql.sql |  12 +
 9 files changed, 738 insertions(+), 1 deletion(-)
 create mode 100644 include/class.role.php
 create mode 100644 include/i18n/en_US/role.yaml
 create mode 100644 include/staff/role.inc.php
 create mode 100644 include/staff/roles.inc.php
 create mode 100644 scp/roles.php

diff --git a/bootstrap.php b/bootstrap.php
index c5a03fe2a..bb9eede35 100644
--- a/bootstrap.php
+++ b/bootstrap.php
@@ -82,6 +82,7 @@ class Bootstrap {
         define('DEPT_TABLE',$prefix.'department');
         define('GROUP_TABLE',$prefix.'groups');
         define('GROUP_DEPT_TABLE', $prefix.'group_dept_access');
+        define('ROLE_TABLE', $prefix.'role');
 
         define('FAQ_TABLE',$prefix.'faq');
         define('FAQ_TOPIC_TABLE',$prefix.'faq_topic');
diff --git a/include/class.i18n.php b/include/class.i18n.php
index 915628893..4b764717a 100644
--- a/include/class.i18n.php
+++ b/include/class.i18n.php
@@ -61,7 +61,9 @@ class Internationalization {
             // Organization
             'organization.yaml' =>  'Organization::__create',
             // Ticket
-            'ticket_status.yaml' =>  'TicketStatus::__create',
+            'ticket_status.yaml' => 'TicketStatus::__create',
+            // Role
+            'role.yaml' =>          'Role::__create',
             // Note that group requires department
             'group.yaml' =>         'Group::__create',
             'file.yaml' =>          'AttachmentFile::create',
diff --git a/include/class.nav.php b/include/class.nav.php
index bc4956f08..30300468f 100644
--- a/include/class.nav.php
+++ b/include/class.nav.php
@@ -260,6 +260,7 @@ class AdminNav extends StaffNav{
                     $subnav[]=array('desc'=>__('Agents'),'href'=>'staff.php','iconclass'=>'users');
                     $subnav[]=array('desc'=>__('Teams'),'href'=>'teams.php','iconclass'=>'teams');
                     $subnav[]=array('desc'=>__('Groups'),'href'=>'groups.php','iconclass'=>'groups');
+                    $subnav[]=array('desc'=>__('Roles'),'href'=>'roles.php','iconclass'=>'lists');
                     $subnav[]=array('desc'=>__('Departments'),'href'=>'departments.php','iconclass'=>'departments');
                     break;
                 case 'apps':
diff --git a/include/class.role.php b/include/class.role.php
new file mode 100644
index 000000000..ff3a238e8
--- /dev/null
+++ b/include/class.role.php
@@ -0,0 +1,279 @@
+<?php
+/*********************************************************************
+    class.role.php
+
+    Role-based access
+
+    Peter Rotich <peter@osticket.com>
+    Copyright (c)  2014 osTicket
+    http://www.osticket.com
+
+    Released under the GNU General Public License WITHOUT ANY WARRANTY.
+    See LICENSE.TXT for details.
+
+    vim: expandtab sw=4 ts=4 sts=4:
+**********************************************************************/
+
+class RoleModel extends VerySimpleModel {
+    static $meta = array(
+        'table' => ROLE_TABLE,
+        'pk' => array('id'),
+        'joins' => array(
+            'groups' => array(
+                'null' => true,
+                'list' => true,
+                'reverse' => 'Group.role',
+            ),
+        ),
+    );
+
+    // Flags
+    const FLAG_ENABLED   = 0x0001;
+
+    protected function hasFlag($flag) {
+        return ($this->get('flags') & $flag) !== 0;
+    }
+
+    protected function clearFlag($flag) {
+        return $this->set('flags', $this->get('flags') & ~$flag);
+    }
+
+    protected function setFlag($flag) {
+        return $this->set('flags', $this->get('flags') | $flag);
+    }
+
+    function getId() {
+        return $this->id;
+    }
+
+    function getName() {
+        return $this->name;
+    }
+
+    function getCreateDate() {
+        return $this->created;
+    }
+
+    function getUpdateDate() {
+        return $this->updated;
+    }
+
+    function getInfo() {
+        return $this->ht;
+    }
+
+    function isEnabled() {
+        return $this->hasFlag(self::FLAG_ENABLED);
+    }
+
+    function isDeleteable() {
+        return ($this->groups->count() == 0);
+    }
+
+}
+
+class Role extends RoleModel {
+    var $form;
+    var $entry;
+
+    var $_perm;
+
+    function getPermission() {
+        if (!$this->_perm)
+            $this->_perm = new RolePermission('role.'.$this->getId());
+
+        return $this->_perm;
+    }
+
+    function getPermissionInfo() {
+        return $this->getPermission()->getInfo();
+    }
+
+    function to_json() {
+
+        $info = array(
+                'id'    => $this->getId(),
+                'name'  => $this->getName()
+                );
+
+        return JsonDataEncoder::encode($info);
+    }
+
+    function __toString() {
+        return (string) $this->getName();
+    }
+
+    private function updatePerms($vars, &$errors=array()) {
+
+        $config = array();
+        foreach (RolePermission::allPermissions() as $g => $perms) {
+            foreach($perms as $k => $v)
+                $config[$k] = in_array($k, $vars) ? 1 : 0;
+        }
+
+        $this->getPermission()->updateAll($config);
+        $this->getPermission()->load();
+    }
+
+    function update($vars, &$errors) {
+
+        if (!$vars['name'])
+            $errors['name'] = __('Name required');
+        elseif (($r=Role::lookup(array('name'=>$vars['name'])))
+                && $r->getId() != $vars['id'])
+            $errors['name'] = __('Name already in-use');
+        elseif (!$vars['perms'] || !count($vars['perms']))
+            $errors['err'] = __('Must check at least one permission for the role');
+
+        if ($errors)
+            return false;
+
+        $this->name = $vars['name'];
+        $this->notes = $vars['notes'];
+        if (!$this->save(true))
+            return false;
+
+        $this->updatePerms($vars['perms'], $errors);
+
+        return true;
+    }
+
+    function save($refetch=false) {
+        if (count($this->dirty))
+            $this->set('updated', new SqlFunction('NOW'));
+        if (isset($this->dirty['notes']))
+            $this->notes = Format::sanitize($this->notes);
+
+        return parent::save($refetch | $this->dirty);
+    }
+
+    function delete() {
+
+        if (!$this->isDeleteable())
+            return false;
+
+        if (!parent::delete())
+            return false;
+
+        // Remove dept access entries
+        GroupDeptAccess::objects()
+            ->filter(array('role_id'=>$this->getId()))
+            ->update(array('role_id' => 0));
+
+        // Delete permission settings
+         $this->getPermission()->destroy();
+
+        return true;
+    }
+
+    static function create($vars=false) {
+        $role = parent::create($vars);
+        $role->created = SqlFunction::NOW();
+        return $role;
+    }
+
+    static function __create($vars, &$errors) {
+        $role = self::create($vars);
+        $role->save();
+        if ($vars['permissions'])
+            $role->updatePerms($vars['permissions']);
+
+        return $role;
+    }
+
+    static function getRoles($criteria=null) {
+        static $roles = null;
+
+        if (!isset($roles) || $criteria) {
+
+            $filters = array();
+            if (isset($criteria['enabled'])) {
+                if ($criteria['enabled'])
+                    $filters += array(
+                            'flags__hasbit' => self::FLAG_ENABLED);
+                else
+                    $filters [] = Q::not(array(
+                                'flags__hasbit' => self::FLAG_ENABLED));
+            }
+            $query = self::objects()
+                ->order_by('name')
+                ->values_flat('id', 'name');
+
+            if ($filters)
+                $query->filter($filters);
+
+            $names = array();
+            foreach ($query as $row)
+                $names[$row[0]] = $row[1];
+
+            // TODO: Localize
+
+            if ($criteria) return $names;
+
+            $roles = $names;
+        }
+
+        return $roles;
+    }
+
+    static function getActiveRoles() {
+        static $roles = null;
+
+        if (!isset($roles))
+            $roles = self::getRoles(array('enabled' => true));
+
+        return $roles;
+    }
+}
+
+
+class RolePermission extends Config {
+
+    static $_permissions = array(
+            /* @trans */ 'Tickets' => array(
+                'ticket.create'  => array(
+                    /* @trans */ 'Create',
+                    /* @trans */ 'Ability to open tickets on behalf of users'),
+                'ticket.edit'   => array(
+                    /* @trans */ 'Edit',
+                    /* @trans */ 'Ability to edit tickets'),
+                'ticket.assign'   => array(
+                    /* @trans */ 'Assign',
+                    /* @trans */ 'Ability to assign tickets to agents or teams'),
+                'ticket.transfer'   => array(
+                    /* @trans */ 'Transfer',
+                    /* @trans */ 'Ability to transfer tickets between departments'),
+                'ticket.reply'  => array(
+                    /* @trans */ 'Post Reply',
+                    /* @trans */ 'Ability to post a ticket reply'),
+                'ticket.close'   => array(
+                    /* @trans */ 'Close',
+                    /* @trans */ 'Ability to close tickets'),
+                'ticket.delete'   => array(
+                    /* @trans */ 'Delete',
+                    /* @trans */ 'Ability to delete tickets'),
+                ),
+            /* @trans */ 'Knowledgebase' => array(
+                'kb.premade'   => array(
+                    /* @trans */ 'Premade',
+                    /* @trans */ 'Ability to add/update/disable/delete canned responses'),
+                'kb.faq'   => array(
+                    /* @trans */ 'FAQ',
+                    /* @trans */ 'Ability to add/update/disable/delete knowledgebase categories and FAQs'),
+                ),
+            /* @trans */ 'Misc.' => array(
+                'stats.agents'   => array(
+                    /* @trans */ 'Stats',
+                    /* @trans */ 'Ability to view stats of other agents in allowed departments'),
+                'emails.banlist'   => array(
+                    /* @trans */ 'Banlist',
+                    /* @trans */ 'Ability to add/remove emails from banlist via ticket interface'),
+                ),
+            );
+
+    static function allPermissions() {
+        return static::$_permissions;
+    }
+
+}
+?>
diff --git a/include/i18n/en_US/role.yaml b/include/i18n/en_US/role.yaml
new file mode 100644
index 000000000..f3def31e2
--- /dev/null
+++ b/include/i18n/en_US/role.yaml
@@ -0,0 +1,60 @@
+#
+# Default roles defined for the system
+#
+# Fields:
+# id - Primary id for the role
+# flags - (bit mask) role flags
+# name - (string) descriptive name for the role
+# notes - (string) internal notes
+# permissions: (list<keys>)
+#
+# NOTE: ------------------------------------
+# ---
+- id: 1
+  flags: 1
+  name: All Access
+  notes: |
+    Role with unlimited access
+
+  permissions: [
+    ticket.create,
+    ticket.edit,
+    ticket.assign,
+    ticket.transfer,
+    ticket.reply,
+    ticket.close,
+    ticket.delete,
+    kb.premade,
+    kb.faq,
+    stats.agents,
+    emails.banlist]
+
+- id: 2
+  flags: 1
+  name: Expanded Access
+  notes: |
+    Role with expanded access
+
+  permissions: [
+    ticket.create,
+    ticket.edit,
+    ticket.assign,
+    ticket.transfer,
+    ticket.reply,
+    ticket.close,
+    kb.premade,
+    kb.faq,
+    stats.agents,
+    emails.banlist]
+
+- id: 3
+  flags: 1
+  name: Limited Access
+  notes: |
+    Role with limited access
+
+  permissions: [
+    ticket.create,
+    ticket.assign,
+    ticket.transfer,
+    ticket.reply]
diff --git a/include/staff/role.inc.php b/include/staff/role.inc.php
new file mode 100644
index 000000000..49e1fff6a
--- /dev/null
+++ b/include/staff/role.inc.php
@@ -0,0 +1,118 @@
+<?php
+
+$info=array();
+if ($role) {
+    $title = __('Update Role');
+    $action = 'update';
+    $submit_text = __('Save Changes');
+    $info = $role->getInfo();
+    $newcount=2;
+} else {
+    $title = __('Add New Role');
+    $action = 'add';
+    $submit_text = __('Add Role');
+    $newcount=4;
+}
+
+$info = Format::htmlchars(($errors && $_POST) ? array_merge($info, $_POST) : $info);
+
+?>
+<form action="" method="post" id="save">
+    <?php csrf_token(); ?>
+    <input type="hidden" name="do" value="<?php echo $action; ?>">
+    <input type="hidden" name="a" value="<?php echo Format::htmlchars($_REQUEST['a']); ?>">
+    <input type="hidden" name="id" value="<?php echo $info['id']; ?>">
+<h2> <?php echo $role ?: __('New Role'); ?></h2>
+<br>
+<ul class="tabs">
+    <li class="active"><a href="#definition">
+        <i class="icon-file"></i> <?php echo __('Definition'); ?></a></li>
+    <li><a href="#permissions">
+        <i class="icon-lock"></i> <?php echo __('Permissions'); ?></a></li>
+</ul>
+<div id="definition" class="tab_content">
+    <table class="form_table" width="940" border="0" cellspacing="0" cellpadding="2">
+    <thead>
+        <tr>
+            <th colspan="2">
+                <h4><?php echo $title; ?></h4>
+                <em><?php echo __(
+                'Roles are used to define agents\' permissions'
+                ); ?>&nbsp;<i class="help-tip icon-question-sign"
+                href="#roles"></i></em>
+            </th>
+        </tr>
+    </thead>
+    <tbody>
+        <tr>
+            <td width="180" class="required"><?php echo __('Name'); ?>:</td>
+            <td>
+                <input size="50" type="text" name="name" value="<?php echo
+                $info['name']; ?>"/>
+                <span class="error">*&nbsp;<?php echo $errors['name']; ?></span>
+            </td>
+        </tr>
+    </tbody>
+    <tbody>
+        <tr>
+            <th colspan="7">
+                <em><strong><?php echo __('Internal Notes'); ?></strong> </em>
+            </th>
+        </tr>
+        <tr>
+            <td colspan="7"><textarea name="notes" class="richtext no-bar"
+                rows="6" cols="80"><?php
+                echo $info['notes']; ?></textarea>
+            </td>
+        </tr>
+    </tbody>
+    </table>
+</div>
+<div id="permissions" class="tab_content" style="display:none">
+   <table class="form_table" width="940" border="0" cellspacing="0" cellpadding="2">
+    <thead>
+        <tr>
+            <th>
+                <em><?php echo __('Check all permissions applicable to this role.') ?></em>
+            </th>
+        </tr>
+    </thead>
+    <tbody>
+        <?php
+
+        $setting = $role ? $role->getPermissionInfo() : array();
+        foreach (RolePermission::allPermissions() as $g => $perms) { ?>
+         <tr><th><?php
+             echo Format::htmlchars(__($g)); ?></th></tr>
+         <?php
+         foreach($perms as $k => $v)  { ?>
+          <tr>
+            <td>
+              <label>
+              <?php
+              echo sprintf('<input type="checkbox" name="perms[]" value="%s" %s />',
+                    $k,
+                    (isset($setting[$k]) && $setting[$k]) ?  'checked="checked"' : '');
+              ?>
+              &nbsp;&nbsp;
+              <?php
+                echo sprintf('%s - <em>%s</em>',
+                      Format::htmlchars(__($v[0])),
+                    Format::htmlchars(__($v[1])));
+              ?>
+             </label>
+            </td>
+          </tr>
+          <?php
+         }
+        } ?>
+    </tbody>
+   </table>
+</div>
+<p class="centered">
+    <input type="submit" name="submit" value="<?php echo $submit_text; ?>">
+    <input type="reset"  name="reset"  value="<?php echo __('Reset'); ?>">
+    <input type="button" name="cancel" value="<?php echo __('Cancel'); ?>"
+        onclick='window.location.href="?"'>
+</p>
+</form>
diff --git a/include/staff/roles.inc.php b/include/staff/roles.inc.php
new file mode 100644
index 000000000..17723f19d
--- /dev/null
+++ b/include/staff/roles.inc.php
@@ -0,0 +1,125 @@
+<div class="pull-left" style="width:700;padding-top:5px;">
+ <h2><?php echo __('Roles'); ?></h2>
+</div>
+<div class="pull-right flush-right" style="padding-top:5px;padding-right:5px;">
+ <b><a href="roles.php?a=add" class="Icon list-add"><?php
+ echo __('Add New Role'); ?></a></b></div>
+<div class="clear"></div>
+
+<?php
+$page = ($_GET['p'] && is_numeric($_GET['p'])) ? $_GET['p'] : 1;
+$count = Role::objects()->count();
+$pageNav = new Pagenate($count, $page, PAGE_LIMIT);
+$pageNav->setURL('roles.php');
+$showing=$pageNav->showing().' '._N('role', 'roles', $count);
+
+?>
+<form action="roles.php" method="POST" name="roles">
+<?php csrf_token(); ?>
+<input type="hidden" name="do" value="mass_process" >
+<input type="hidden" id="action" name="a" value="" >
+<table class="list" border="0" cellspacing="1" cellpadding="0" width="940">
+    <caption><?php echo $showing; ?></caption>
+    <thead>
+        <tr>
+            <th width="7">&nbsp;</th>
+            <th><?php echo __('Name'); ?></th>
+            <th width="100"><?php echo __('Status'); ?></th>
+            <th width="200"><?php echo __('Created On') ?></th>
+            <th width="250"><?php echo __('Last Updated'); ?></th>
+        </tr>
+    </thead>
+    <tbody>
+    <?php foreach (Role::objects()->order_by('name')
+                ->limit($pageNav->getLimit())
+                ->offset($pageNav->getStart()) as $role) {
+            $id = $role->getId();
+            $sel = false;
+            if ($ids && in_array($id, $ids))
+                $sel = true; ?>
+        <tr>
+            <td>
+                <?php
+                if ($role->isDeleteable()) { ?>
+                <input width="7" type="checkbox" class="ckb" name="ids[]"
+                value="<?php echo $id; ?>"
+                    <?php echo $sel?'checked="checked"':''; ?>>
+                <?php
+                } else {
+                    echo '&nbsp;';
+                }
+                ?>
+            </td>
+            <td><a href="?id=<?php echo $id; ?>"><?php echo
+            $role->getName(); ?></a></td>
+            <td>&nbsp;<?php echo $role->isEnabled() ? __('Active') :
+            '<b>'.__('Disabled').'</b>'; ?></td>
+            <td><?php echo Format::date($role->getCreateDate()); ?></td>
+            <td><?php echo Format::datetime($role->getUpdateDate()); ?></td>
+        </tr>
+    <?php }
+    ?>
+    </tbody>
+    <tfoot>
+     <tr>
+        <td colspan="4">
+            <?php if($count){ ?>
+            <?php echo __('Select'); ?>:&nbsp;
+            <a id="selectAll" href="#ckb"><?php echo __('All'); ?></a>&nbsp;&nbsp;
+            <a id="selectNone" href="#ckb"><?php echo __('None'); ?></a>&nbsp;&nbsp;
+            <a id="selectToggle" href="#ckb"><?php echo __('Toggle'); ?></a>&nbsp;&nbsp;
+            <?php } else {
+                echo sprintf(__('No roles defined yet &mdash; %s add one %s!'),
+                    '<a href="roles.php?a=add">','</a>');
+            } ?>
+        </td>
+     </tr>
+    </tfoot>
+</table>
+<?php
+if ($count) //Show options..
+    echo '<div>&nbsp;'.__('Page').':'.$pageNav->getPageLinks().'&nbsp;</div>';
+?>
+
+<p class="centered" id="actions">
+    <input class="button" type="submit" name="enable" value="<?php echo
+    __('Enable'); ?>">
+    &nbsp;&nbsp;
+    <input class="button" type="submit" name="disable" value="<?php echo
+    __('Disable'); ?>">
+    &nbsp;&nbsp;
+    <input class="button" type="submit" name="delete" value="<?php echo
+    __('Delete'); ?>">
+</p>
+</form>
+
+<div style="display:none;" class="dialog" id="confirm-action">
+    <h3><?php echo __('Please Confirm'); ?></h3>
+    <a class="close" href=""><i class="icon-remove-circle"></i></a>
+    <hr/>
+    <p class="confirm-action" style="display:none;" id="enable-confirm">
+        <?php echo sprintf(__('Are you sure want to <b>enable</b> %s?'),
+            _N('selected role', 'selected roles', 2));?>
+    </p>
+    <p class="confirm-action" style="display:none;" id="disable-confirm">
+        <?php echo sprintf(__('Are you sure want to <b>disable</b> %s?'),
+            _N('selected role', 'selected roles', 2));?>
+    </p>
+    <p class="confirm-action" style="display:none;" id="delete-confirm">
+        <font color="red"><strong><?php echo sprintf(
+        __('Are you sure you want to DELETE %s?'),
+        _N('selected role', 'selected roles', 2)); ?></strong></font>
+        <br><br><?php echo __('Deleted roles CANNOT be recovered.'); ?>
+    </p>
+    <div><?php echo __('Please confirm to continue.'); ?></div>
+    <hr style="margin-top:1em"/>
+    <p class="full-width">
+        <span class="buttons pull-left">
+            <input type="button" value="<?php echo __('No, Cancel'); ?>" class="close">
+        </span>
+        <span class="buttons pull-right">
+            <input type="button" value="<?php echo __('Yes, Do it!'); ?>" class="confirm">
+        </span>
+    </p>
+    <div class="clear"></div>
+</div>
diff --git a/scp/roles.php b/scp/roles.php
new file mode 100644
index 000000000..43c097f5c
--- /dev/null
+++ b/scp/roles.php
@@ -0,0 +1,139 @@
+<?php
+/*********************************************************************
+    roles.php
+
+    Agent's roles
+
+    Peter Rotich <peter@osticket.com>
+    Copyright (c)  2014 osTicket
+    http://www.osticket.com
+
+    Released under the GNU General Public License WITHOUT ANY WARRANTY.
+    See LICENSE.TXT for details.
+
+    vim: expandtab sw=4 ts=4 sts=4:
+**********************************************************************/
+
+require('admin.inc.php');
+
+$errors = array();
+$role=null;
+if ($_REQUEST['id'] && !($role = Role::lookup($_REQUEST['id'])))
+    $errors['err'] = sprintf(__('%s: Unknown or invalid ID.'),
+        __('Role'));
+
+if ($_POST) {
+    switch (strtolower($_POST['do'])) {
+    case 'update':
+        if (!$role) {
+            $errors['err'] = sprintf(__('%s: Unknown or invalid ID.'),
+                    __('Role'));
+        } elseif ($role->update($_POST, $errors)) {
+            $msg = __('Role updated successfully');
+        } elseif ($errors) {
+            $errors['err'] = $errors['err'] ?:
+                sprintf(__('Unable to update %s. Correct error(s) below and try again!'),
+                    __('this role'));
+        } else {
+            $errors['err'] = sprintf(__('Unable to update %s.'), __('this role'))
+                    .' '.__('Internal error occurred');
+        }
+        break;
+    case 'add':
+        $_role = Role::create();
+        if ($_role->update($_POST, $errors)) {
+            $msg = sprintf(__('Successfully added %s'),
+                    __('role'));
+        } elseif ($errors) {
+            $errors['err'] = $errors['err'] ?:
+                sprintf(__('Unable to add %s. Correct error(s) below and try again.'),
+                    __('role'));
+        } else {
+            $errors['err'] = sprintf(__('Unable to add %s.'), __('role'))
+                    .' '.__('Internal error occurred');
+        }
+        break;
+    case 'mass_process':
+        if (!$_POST['ids'] || !is_array($_POST['ids']) || !count($_POST['ids'])) {
+            $errors['err'] = sprintf(__('You must select at least %s'),
+                    __('one role'));
+        } else {
+            $count = count($_POST['ids']);
+            switch(strtolower($_POST['a'])) {
+            case 'enable':
+                $num = Role::objects()->filter(array(
+                    'id__in' => $_POST['ids']
+                ))->update(array(
+                    'flags'=> SqlExpression::bitor(
+                        new SqlField('flags'),
+                        Role::FLAG_ENABLED)
+                ));
+                if ($num) {
+                    if($num==$count)
+                        $msg = sprintf(__('Successfully enabled %s'),
+                            _N('selected role', 'selected roles', $count));
+                    else
+                        $warn = sprintf(__('%1$d of %2$d %3$s enabled'), $num, $count,
+                            _N('selected role', 'selected roles', $count));
+                } else {
+                    $errors['err'] = sprintf(__('Unable to enable %s'),
+                        _N('selected role', 'selected roles', $count));
+                }
+                break;
+            case 'disable':
+                $num = Role::objects()->filter(array(
+                    'id__in' => $_POST['ids']
+                ))->update(array(
+                    'flags'=> SqlExpression::bitand(
+                        new SqlField('flags'),
+                        (~Role::FLAG_ENABLED))
+                ));
+
+                if ($num) {
+                    if($num==$count)
+                        $msg = sprintf(__('Successfully disabled %s'),
+                            _N('selected role', 'selected roles', $count));
+                    else
+                        $warn = sprintf(__('%1$d of %2$d %3$s disabled'), $num, $count,
+                            _N('selected role', 'selected roles', $count));
+                } else {
+                    $errors['err'] = sprintf(__('Unable to disable %s'),
+                        _N('selected role', 'selected roles', $count));
+                }
+                break;
+            case 'delete':
+                $i=0;
+                foreach ($_POST['ids'] as $k=>$v) {
+                    if (($r=Role::lookup($v)) && $r->isDeleteable() && $r->delete())
+                        $i++;
+                }
+                if ($i && $i==$count)
+                    $msg = sprintf(__('Successfully deleted %s'),
+                            _N('selected role', 'selected roles', $count));
+                elseif ($i > 0)
+                    $warn = sprintf(__('%1$d of %2$d %3$s deleted'), $i, $count,
+                            _N('selected role', 'selected roles', $count));
+                elseif (!$errors['err'])
+                    $errors['err'] = sprintf(__('Unable to delete %s — they may be in use.'),
+                            _N('selected role', 'selected roles', $count));
+                break;
+            default:
+                $errors['err'] =  __('Unknown action');
+            }
+        }
+        break;
+    }
+}
+
+$page='roles.inc.php';
+if($role || ($_REQUEST['a'] && !strcasecmp($_REQUEST['a'], 'add'))) {
+    $page='role.inc.php';
+    $ost->addExtraHeader('<meta name="tip-namespace" content="agents.role" />',
+        "$('#content').data('tipNamespace', 'agents.role');");
+}
+
+$nav->setTabActive('staff');
+require(STAFFINC_DIR.'header.inc.php');
+require(STAFFINC_DIR.$page);
+include(STAFFINC_DIR.'footer.inc.php');
+?>
diff --git a/setup/inc/streams/core/install-mysql.sql b/setup/inc/streams/core/install-mysql.sql
index a23a71b65..900a1f47f 100644
--- a/setup/inc/streams/core/install-mysql.sql
+++ b/setup/inc/streams/core/install-mysql.sql
@@ -411,6 +411,18 @@ CREATE TABLE `%TABLE_PREFIX%groups` (
   KEY `group_active` (`group_enabled`)
 ) DEFAULT CHARSET=utf8;
 
+DROP TABLE IF EXISTS `%TABLE_PREFIX%role`;
+CREATE TABLE `%TABLE_PREFIX%role` (
+  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
+  `flags` int(10) unsigned NOT NULL DEFAULT '1',
+  `name` varchar(64) DEFAULT NULL,
+  `notes` text,
+  `created` datetime NOT NULL,
+  `updated` datetime NOT NULL,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `name` (`name`)
+) DEFAULT CHARSET=utf8;
+
 DROP TABLE IF EXISTS `%TABLE_PREFIX%group_dept_access`;
 CREATE TABLE `%TABLE_PREFIX%group_dept_access` (
   `group_id` int(10) unsigned NOT NULL default '0',
-- 
GitLab