Merge branch 'master' of git.freeside.biz:/home/git/freeside
authorIvan Kohler <ivan@freeside.biz>
Fri, 13 Nov 2015 21:06:00 +0000 (13:06 -0800)
committerIvan Kohler <ivan@freeside.biz>
Fri, 13 Nov 2015 21:06:00 +0000 (13:06 -0800)
32 files changed:
FS/FS/ClientAPI/MyAccount.pm
FS/FS/ClientAPI/Signup.pm
FS/FS/Conf.pm
FS/FS/Mason.pm
FS/FS/Password_Mixin.pm [new file with mode: 0644]
FS/FS/Schema.pm
FS/FS/TemplateItem_Mixin.pm
FS/FS/Template_Mixin.pm
FS/FS/cust_main/Billing.pm
FS/FS/cust_main/Billing_Discount.pm
FS/FS/msg_template.pm
FS/FS/password_history.pm [new file with mode: 0644]
FS/FS/quotation_pkg.pm
FS/FS/quotation_pkg_detail.pm
FS/FS/reason.pm
FS/FS/svc_acct.pm
FS/FS/svc_circuit.pm
FS/MANIFEST
FS/t/password_history.t [new file with mode: 0644]
debian/control
fs_selfservice/FS-SelfService/cgi/myaccount.html
httemplate/browse/reason.html
httemplate/browse/reason_type.html
httemplate/browse/router.cgi
httemplate/docs/license.html
httemplate/edit/msg_template/email.html
httemplate/edit/process/svc_acct.cgi
httemplate/edit/quotation_pkg_detail.html
httemplate/misc/process/change-password.html
httemplate/search/elements/checkbox-foot.html
ng_selfservice/main.php
torrus/doc/xmlconfig.pod.in

index f272cd4..53deaaa 100644 (file)
@@ -669,6 +669,11 @@ sub customer_info_short {
 
   }
 
+  # this is here because this routine is called by both fs_ and ng_ main pages, where it appears
+  # it is not customer-specific, though it is only shown to authenticated customers
+  # it is not currently agent-specific, though at some point it might be
+  $return{'announcement'} = join(' ',$conf->config('selfservice-announcement')) || '';
+
   return { 'error'          => '',
            'custnum'        => $custnum,
            %return,
@@ -2977,13 +2982,15 @@ sub myaccount_passwd {
         )
     && ! $svc_acct->check_password($p->{'old_password'});
 
+    # should move password length checks into is_password_allowed
   $error = 'Password too short.'
     if length($p->{'new_password'}) < ($conf->config('passwordmin') || 6);
   $error = 'Password too long.'
     if length($p->{'new_password'}) > ($conf->config('passwordmax') || 8);
 
-  $svc_acct->set_password($p->{'new_password'});
-  $error ||= $svc_acct->replace();
+  $error ||= $svc_acct->is_password_allowed($p->{'new_password'})
+         ||  $svc_acct->set_password($p->{'new_password'})
+         ||  $svc_acct->replace();
 
   #regular pw change in self-service should change contact pw too, otherwise its
   #way too confusing.  hell its confusing they're separate at all, but alas.
@@ -3262,8 +3269,9 @@ sub process_reset_passwd {
 
   if ( $svc_acct ) {
 
-    $svc_acct->set_password($p->{'new_password'});
-    my $error = $svc_acct->replace();
+    my $error ||= $svc_acct->is_password_allowed($p->{'new_password'})
+              ||  $svc_acct->set_password($p->{'new_password'})
+              ||  $svc_acct->replace();
 
     return { %$info, 'error' => $error } if $error;
 
index 5d719c4..a178bec 100644 (file)
@@ -694,6 +694,9 @@ sub new_customer {
         map { $_ => $packet->{$_} }
           qw( username _password sec_phrase popnum domsvc ),
       };
+      
+      my $error = $svc->is_password_allowed($packet->{_password});
+      return { error => $error } if $error;
 
       my @acct_snarf;
       my $snarfnum = 1;
index 990f2a3..a4cc871 100644 (file)
@@ -4053,6 +4053,13 @@ and customer address. Include units.',
   },
 
   {
+    'key'         => 'password-no_reuse',
+    'section'     => 'password',
+    'description' => 'Minimum number of password changes before a password can be reused. By default, passwords can be reused without restriction.',
+    'type'        => 'text',
+  },
+
+  {
     'key'         => 'datavolume-forcemegabytes',
     'section'     => 'UI',
     'description' => 'All data volumes are expressed in megabytes',
@@ -5679,6 +5686,13 @@ and customer address. Include units.',
   },
 
   {
+    'key'         => 'selfservice-announcement',
+    'section'     => 'self-service',
+    'description' => 'HTML announcement to display to all authenticated users on account overview page',
+    'type'        => 'textarea',
+  },
+
+  {
     'key'         => 'logout-timeout',
     'section'     => 'UI',
     'description' => 'If set, automatically log users out of the backoffice after this many minutes.',
index 98a75c8..58b3da7 100644 (file)
@@ -409,6 +409,7 @@ if ( -e $addl_handler_use_file ) {
   use FS::report_batch;
   use FS::report_batch;
   use FS::report_batch;
+  use FS::password_history;
   # Sammath Naur
 
   if ( $FS::Mason::addl_handler_use ) {
diff --git a/FS/FS/Password_Mixin.pm b/FS/FS/Password_Mixin.pm
new file mode 100644 (file)
index 0000000..af4c5e2
--- /dev/null
@@ -0,0 +1,165 @@
+package FS::Password_Mixin;
+
+use FS::Record qw(qsearch);
+use FS::Conf;
+use FS::password_history;
+use Authen::Passphrase;
+# use Authen::Passphrase::BlowfishCrypt; # ha ha, no.
+# https://rt.cpan.org/Ticket/Display.html?id=72743
+
+our $DEBUG = 1;
+our $conf;
+FS::UID->install_callback( sub {
+    $conf = FS::Conf->new;
+    # this is safe
+    eval "use Authen::Passphrase::BlowfishCrypt;";
+});
+
+our $me = '[' . __PACKAGE__ . ']';
+
+our $BLOWFISH_COST = 10;
+
+=head1 NAME
+
+FS::Password_Mixin - Object methods for accounts that have passwords governed
+by the password policy.
+
+=head1 METHODS
+
+=over 4
+
+=item is_password_allowed PASSWORD
+
+Checks the password against the system password policy. Returns an error
+message on failure, an empty string on success.
+
+This MUST NOT be called from check(). It should be called by the office UI,
+self-service ClientAPI, or other I<user-interactive> code that processes a
+password change, and only if the user has taken some action with the intent
+of changing the password.
+
+=cut
+
+sub is_password_allowed {
+  my $self = shift;
+  my $password = shift;
+
+  # check length and complexity here
+
+  if ( $conf->config('password-no_reuse') =~ /^(\d+)$/ ) {
+
+    my $no_reuse = $1;
+
+    # "the last N" passwords includes the current password and the N-1
+    # passwords before that.
+    warn "$me checking password reuse limit of $no_reuse\n" if $DEBUG;
+    my @latest = qsearch({
+        'table'     => 'password_history',
+        'hashref'   => { $self->password_history_key => $self->get($self->primary_key) },
+        'order_by'  => " ORDER BY created DESC LIMIT $no_reuse",
+    });
+
+    # don't check the first one; reusing the current password is allowed.
+    shift @latest;
+
+    foreach my $history (@latest) {
+      warn "$me previous password created ".$history->created."\n" if $DEBUG;
+      if ( $history->password_equals($password) ) {
+        my $message;
+        if ( $no_reuse == 1 ) {
+          $message = "This password is the same as your previous password.";
+        } else {
+          $message = "This password was one of the last $no_reuse passwords on this account.";
+        }
+        return $message;
+      }
+    } #foreach $history
+
+  } # end of no_reuse checking
+
+  '';
+}
+
+=item password_history_key
+
+Returns the name of the field in L<FS::password_history> that's the foreign
+key to this table.
+
+=cut
+
+sub password_history_key {
+  my $self = shift;
+  $self->table . '__' . $self->primary_key;
+}
+
+=item insert_password_history
+
+Creates a L<FS::password_history> record linked to this object, with its
+current password.
+
+=cut
+
+sub insert_password_history {
+  my $self = shift;
+  my $encoding = $self->_password_encoding;
+  my $password = $self->_password;
+  my $auth;
+
+  if ( $encoding eq 'bcrypt' or $encoding eq 'crypt' ) {
+
+    # it's smart enough to figure this out
+    $auth = Authen::Passphrase->from_crypt($password);
+
+  } elsif ( $encoding eq 'ldap' ) {
+
+    $password =~ s/^{PLAIN}/{CLEARTEXT}/i; # normalize
+    $auth = Authen::Passphrase->from_rfc2307($password);
+    if ( $auth->isa('Authen::Passphrase::Clear') ) {
+      # then we've been given the password in cleartext
+      $auth = $self->_blowfishcrypt( $auth->passphrase );
+    }
+  
+  } elsif ( $encoding eq 'plain' ) {
+
+    $auth = $self->_blowfishcrypt( $password );
+
+  }
+
+  my $password_history = FS::password_history->new({
+      _password => $auth->as_rfc2307,
+      created   => time,
+      $self->password_history_key => $self->get($self->primary_key),
+  });
+
+  my $error = $password_history->insert;
+  return "recording password history: $error" if $error;
+  '';
+
+}
+
+=item _blowfishcrypt PASSWORD
+
+For internal use: takes PASSWORD and returns a new
+L<Authen::Passphrase::BlowfishCrypt> object representing it.
+
+=cut
+
+sub _blowfishcrypt {
+  my $class = shift;
+  my $passphrase = shift;
+  return Authen::Passphrase::BlowfishCrypt->new(
+    cost => $BLOWFISH_COST,
+    salt_random => 1,
+    passphrase => $passphrase,
+  );
+}
+
+=back
+
+=head1 SEE ALSO
+
+L<FS::password_history>
+
+=cut
+
+1;
index 6b5d658..5a2a9be 100644 (file)
@@ -1973,15 +1973,15 @@ sub tables_hashref {
     'quotation_pkg_detail' => {
       'columns' => [
         'detailnum', 'serial', '', '', '', '', 
-        'billpkgnum', 'int', '', '', '', '',        # actually links to quotationpkgnum
+        'quotationpkgnum', 'int', '', '', '', '',
         'format',  'char', 'NULL', 1, '', '',       # not used for anything
         'detail',  'varchar', '', 255, '', '',
       ],
       'primary_key'  => 'detailnum',
       'unique'       => [],
-      'index'        => [ [ 'billpkgnum' ] ],
+      'index'        => [ [ 'quotationpkgnum' ] ],
       'foreign_keys' => [
-                          { columns    => [ 'billpkgnum' ],
+                          { columns    => [ 'quotationpkgnum' ],
                             table      => 'quotation_pkg',
                             references => [ 'quotationpkgnum' ],
                           },
@@ -7206,6 +7206,52 @@ sub tables_hashref {
                         ],
     },
 
+    'password_history' => {
+      'columns' => [
+        'passwordnum',        'serial',  '',          '', '', '',
+        '_password',          'varchar', 'NULL', $char_d, '', '',
+        'encryption_method',  'varchar', 'NULL', $char_d, '', '',
+        'created',   @date_type,   '', '',
+        # each table that needs password history gets a column here, and
+        # an entry in foreign_keys.
+        'svc_acct__svcnum',     'int', 'NULL', '', '', '',
+        'svc_dsl__svcnum',      'int', 'NULL', '', '', '',
+        'svc_alarm__svcnum',    'int', 'NULL', '', '', '',
+        'agent__agentnum',      'int', 'NULL', '', '', '',
+        'contact__contactnum',  'int', 'NULL', '', '', '',
+        'access_user__usernum', 'int', 'NULL', '', '', '',
+      ],
+      'primary_key' => 'passwordnum',
+      'unique' => [],
+      'index'  => [],
+      'foreign_keys' => [
+                          { columns     => [ 'svc_acct__svcnum' ],
+                            table       => 'svc_acct',
+                            references  => [ 'svcnum' ],
+                          },
+                          { columns     => [ 'svc_dsl__svcnum' ],
+                            table       => 'svc_dsl',
+                            references  => [ 'svcnum' ],
+                          },
+                          { columns     => [ 'svc_alarm__svcnum' ],
+                            table       => 'svc_alarm',
+                            references  => [ 'svcnum' ],
+                          },
+                          { columns    => [ 'agent__agentnum' ],
+                            table      => 'agent',
+                            references => [ 'agentnum' ],
+                          },
+                          { columns    => [ 'contact__contactnum' ],
+                            table      => 'contact',
+                            references => [ 'contactnum' ],
+                          },
+                          { columns    => [ 'access_user__usernum' ],
+                            table      => 'access_user',
+                            references => [ 'usernum' ],
+                          },
+                        ],
+    },
+
     # name type nullability length default local
 
     #'new_table' => {
index dcd7ab3..248da3c 100644 (file)
@@ -175,6 +175,7 @@ sub details {
   my $escape_function = $opt{escape_function} || sub { shift };
 
   my $csv = new Text::CSV_XS;
+  my $key = $self->primary_key;
 
   if ( $opt{format_function} ) {
 
@@ -189,14 +190,14 @@ sub details {
           )
         }
       qsearch ({ 'table'    => $self->detail_table,
-                 'hashref'  => { 'billpkgnum' => $self->billpkgnum },
+                 'hashref'  => { $key => $self->get($key) },
                  'order_by' => 'ORDER BY detailnum',
               });
 
   } elsif ( $opt{'no_usage'} ) {
 
     my $sql = "SELECT detail FROM ". $self->detail_table.
-              "  WHERE billpkgnum = ". $self->billpkgnum.
+              "  WHERE " . $key . " = ". $self->get($key).
               "    AND ( format IS NULL OR format != 'C' ) ".
               "  ORDER BY detailnum";
     my $sth = dbh->prepare($sql) or die dbh->errstr;
@@ -251,7 +252,7 @@ sub details {
     }
 
     my $sql = "SELECT format, detail FROM ". $self->detail_table.
-              "  WHERE billpkgnum = ". $self->billpkgnum.
+              "  WHERE " . $key . " = ". $self->get($key).
               "  ORDER BY detailnum";
     my $sth = dbh->prepare($sql) or die dbh->errstr;
     $sth->execute or die $sth->errstr;
index e02aa1f..e889142 100644 (file)
@@ -3000,9 +3000,6 @@ location (whichever is defined).
 multisection: a flag indicating that this is a multisection invoice,
 which does something complicated.
 
-preref_callback: coderef run for each line item, code should return HTML to be
-displayed before that line item (quotations only)
-
 Returns a list of hashrefs, each of which may contain:
 
 pkgnum, description, amount, unit_amount, quantity, pkgpart, _is_setup, and 
@@ -3139,51 +3136,7 @@ sub _items_cust_bill_pkg {
                           'no_usage'        => $opt{'no_usage'},
                         );
 
-      if ( ref($cust_bill_pkg) eq 'FS::quotation_pkg' ) {
-        # XXX this should be pulled out into quotation_pkg
-
-        warn "$me _items_cust_bill_pkg cust_bill_pkg is quotation_pkg\n"
-          if $DEBUG > 1;
-        # quotation_pkgs are never fees, so don't worry about the case where
-        # part_pkg is undefined
-
-        # and I guess they're never bundled either?
-        if ( $cust_bill_pkg->setup != 0 ) {
-          my $description = $desc;
-          $description .= ' Setup'
-            if $cust_bill_pkg->recur != 0
-            || $discount_show_always
-            || $cust_bill_pkg->recur_show_zero;
-          #push @b, {
-          # keep it consistent, please
-          $s = {
-            'pkgnum'      => $cust_bill_pkg->pkgpart, #so it displays in Ref
-            'description' => $description,
-            'amount'      => sprintf("%.2f", $cust_bill_pkg->setup),
-            'unit_amount' => sprintf("%.2f", $cust_bill_pkg->unitsetup),
-            'quantity'    => $cust_bill_pkg->quantity,
-            'preref_html' => ( $opt{preref_callback}
-                                 ? &{ $opt{preref_callback} }( $cust_bill_pkg )
-                                 : ''
-                             ),
-          };
-        }
-        if ( $cust_bill_pkg->recur != 0 ) {
-          #push @b, {
-          $r = {
-            'pkgnum'      => $cust_bill_pkg->pkgpart, #so it displays in Ref
-            'description' => "$desc (". $cust_bill_pkg->part_pkg->freq_pretty.")",
-            'amount'      => sprintf("%.2f", $cust_bill_pkg->recur),
-            'unit_amount' => sprintf("%.2f", $cust_bill_pkg->unitrecur),
-            'quantity'    => $cust_bill_pkg->quantity,
-           'preref_html'  => ( $opt{preref_callback}
-                                 ? &{ $opt{preref_callback} }( $cust_bill_pkg )
-                                 : ''
-                             ),
-          };
-        }
-
-      } elsif ( $cust_bill_pkg->pkgnum > 0 ) {
+      if ( $cust_bill_pkg->pkgnum > 0 ) {
         # a "normal" package line item (not a quotation, not a fee, not a tax)
 
         warn "$me _items_cust_bill_pkg cust_bill_pkg is non-tax\n"
@@ -3483,7 +3436,7 @@ sub _items_cust_bill_pkg {
                                            + $cust_bill_pkg->recur)
         };
 
-      } # if quotation / package line item / other line item
+      } # if package line item / other line item
 
       # decide whether to show active discounts here
       if (
index eee0958..d3c618d 100644 (file)
@@ -1183,7 +1183,8 @@ sub _make_lines {
       } else {
       # the normal case, not a supplemental package
       $next_bill = $part_pkg->add_freq($sdate, $options{freq_override} || 0);
-      return "unparsable frequency: ". $part_pkg->freq
+      return "unparsable frequency: ".
+        ($options{freq_override} || $part_pkg->freq)
         if $next_bill == -1;
       }  
   
index 117bf31..ec2bf07 100644 (file)
@@ -92,8 +92,11 @@ sub discount_terms {
 
   my @discount_pkgs = $self->_discount_pkgs_and_bill;
   shift @discount_pkgs; #discard bill;
-  
-  map { $terms{$_->months} = 1 }
+
+  # convert @discount_pkgs (the list of packages that have available discounts)
+  # to a list of distinct term lengths in months, and strip any decimal places
+  # from the number of months, not that it should have any 
+  map { $terms{sprintf('%.0f', $_->months)} = 1 }
     grep { $_->months && $_->months > 1 }
     map { $_->discount }
     map { $_->part_pkg->part_pkg_discount }
index 01a6563..7d9750c 100644 (file)
@@ -462,6 +462,17 @@ my $usage_warning = sub {
 # If you add anything, be sure to add a description in 
 # httemplate/edit/msg_template.html.
 sub substitutions {
+  my $payinfo_sub = sub { 
+    my $obj = shift;
+    ($obj->payby eq 'CARD' || $obj->payby eq 'CHEK')
+    ? $obj->paymask 
+    : $obj->decrypt($obj->payinfo)
+  };
+  my $payinfo_end = sub {
+    my $obj = shift;
+    my $payinfo = &$payinfo_sub($obj);
+    substr($payinfo, -4);
+  };
   { 'cust_main' => [qw(
       display_custnum agentnum agent_name
 
@@ -608,11 +619,8 @@ sub substitutions {
       # overrides the one in cust_main in cases where a cust_pay is passed
       [ payby             => sub { FS::payby->shortname(shift->payby) } ],
       [ date              => sub { time2str("%a %B %o, %Y", shift->_date) } ],
-      [ payinfo           => sub { 
-          my $cust_pay = shift;
-          ($cust_pay->payby eq 'CARD' || $cust_pay->payby eq 'CHEK') ?
-            $cust_pay->paymask : $cust_pay->decrypt($cust_pay->payinfo)
-        } ],
+      [ 'payinfo' => $payinfo_sub ],
+      [ 'payinfo_end' => $payinfo_end ],
     ],
     # for refund receipts
     'cust_refund' => [
@@ -620,11 +628,8 @@ sub substitutions {
       [ refund            => sub { sprintf("%.2f", shift->refund) } ],
       [ payby             => sub { FS::payby->shortname(shift->payby) } ],
       [ date              => sub { time2str("%a %B %o, %Y", shift->_date) } ],
-      [ payinfo           => sub { 
-          my $cust_refund = shift;
-          ($cust_refund->payby eq 'CARD' || $cust_refund->payby eq 'CHEK') ?
-            $cust_refund->paymask : $cust_refund->decrypt($cust_refund->payinfo)
-        } ],
+      [ 'payinfo' => $payinfo_sub ],
+      [ 'payinfo_end' => $payinfo_end ],
     ],
     # for payment decline messages
     # try to support all cust_pay fields
@@ -636,11 +641,8 @@ sub substitutions {
       [ paid              => sub { sprintf("%.2f", shift->paid) } ],
       [ payby             => sub { FS::payby->shortname(shift->payby) } ],
       [ date              => sub { time2str("%a %B %o, %Y", shift->_date) } ],
-      [ payinfo           => sub {
-          my $pending = shift;
-          ($pending->payby eq 'CARD' || $pending->payby eq 'CHEK') ?
-            $pending->paymask : $pending->decrypt($pending->payinfo)
-        } ],
+      [ 'payinfo' => $payinfo_sub ],
+      [ 'payinfo_end' => $payinfo_end ],
     ],
   };
 }
diff --git a/FS/FS/password_history.pm b/FS/FS/password_history.pm
new file mode 100644 (file)
index 0000000..dd527b9
--- /dev/null
@@ -0,0 +1,174 @@
+package FS::password_history;
+use base qw( FS::Record );
+
+use strict;
+use FS::Record qw( qsearch qsearchs );
+use Authen::Passphrase;
+
+# the only bit of autogenerated magic in here
+our @foreign_keys;
+FS::UID->install_callback(sub {
+    @foreign_keys = grep /__/, __PACKAGE__->dbdef_table->columns;
+});
+
+=head1 NAME
+
+FS::password_history - Object methods for password_history records
+
+=head1 SYNOPSIS
+
+  use FS::password_history;
+
+  $record = new FS::password_history \%hash;
+  $record = new FS::password_history { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::password_history object represents a current or past password used
+by a login account, employee, or other account managed within Freeside.  
+FS::password_history inherits from FS::Record.  The following fields are
+currently supported:
+
+=over 4
+
+=item passwordnum - primary key
+
+=item _password - the encrypted password, as an RFC2307-style string
+("{CRYPT}$2a$08$..." or "{MD5}1ab201f..." or similar). This is a serialized
+L<Authen::Passphrase> object.
+
+=item created - the date the password was set to this value. The record with
+the most recent created time is the current password.
+
+=back
+
+Plus one of the following foreign keys:
+
+=over 4
+
+=item svc_acct__svcnum
+
+=item svc_dsl__svcnum
+
+=item svc_alarm__svcnum
+
+=item agent__agentnum
+
+=item contact__contactnum
+
+=item access_user__usernum
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new password history record.  To add the record to the database,
+see L<"insert">.
+
+=cut
+
+sub table { 'password_history'; }
+
+=item insert
+
+=item delete
+
+=item replace OLD_RECORD
+
+=item check
+
+Checks all fields to make sure this is a valid password history record.  If
+there is an error, returns the error, otherwise returns false.  Called by the
+insert and replace methods.
+
+=cut
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_numbern('passwordnum')
+    || $self->ut_anything('_password')
+    || $self->ut_numbern('create')
+    || $self->ut_numbern('create')
+  ;
+  return $error if $error;
+
+  # FKs are mutually exclusive
+  my $fk_in_use;
+  foreach my $fk ( @foreign_keys ) {
+    if ( $self->get($fk) ) {
+      $self->ut_numbern($fk);
+      return "multiple records linked to this password_history" if $fk_in_use;
+      $fk_in_use = $fk;
+    }
+  }
+
+  $self->SUPER::check;
+}
+
+=item linked_acct
+
+Returns the object that's using this password.
+
+=cut
+
+sub linked_acct {
+  my $self = shift;
+
+  foreach my $fk ( @foreign_keys ) {
+    if ( my $val = $self->get($fk) ) {
+      my ($table, $key) = split(/__/, $fk);
+      return qsearchs($table, { $key => $val });
+    }
+  }
+}
+
+=item password_equals PASSWORD
+
+Returns true if PASSWORD (plaintext) is the same as the one stored in the 
+history record, false if not.
+
+=cut
+
+sub password_equals {
+
+  my ($self, $check_password) = @_;
+
+  # _password here is always LDAP-style.
+  try {
+    my $auth = Authen::Passphrase->from_rfc2307($self->_password);
+    return $auth->match($check_password);
+  } catch {
+    # if there's somehow bad data in the _password field, then it doesn't
+    # match anything. much better than having it match _everything_.
+    warn "password_history #" . $self->passwordnum . ": $_";
+    return '';
+  }
+
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::Record>
+
+=cut
+
+1;
+
index 49d0d9a..e264209 100644 (file)
@@ -101,21 +101,8 @@ sub display_table         { 'quotation_pkg'; }
 #                                                 #  (for invoice display order)
 
 sub discount_table        { 'quotation_pkg_discount'; }
-
-# detail table uses non-quotation fieldnames, see billpkgnum below
 sub detail_table          { 'quotation_pkg_detail'; }
 
-=item billpkgnum
-
-Sets/returns quotationpkgnum, for ease of integration with TemplateItem_Mixin::details
-
-=cut
-
-sub billpkgnum {
-  my $self = shift;
-  $self->quotationpkgnum(@_);
-}
-
 =item insert
 
 Adds this record to the database.  If there is an error, returns the error,
@@ -380,7 +367,7 @@ sub delete_details {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
-  foreach my $detail ( qsearch('quotation_pkg_detail',{ 'billpkgnum' => $self->quotationpkgnum }) ) {
+  foreach my $detail ( qsearch('quotation_pkg_detail',{ 'quotationpkgnum' => $self->quotationpkgnum }) ) {
     my $error = $detail->delete;
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
@@ -416,8 +403,8 @@ sub set_details {
 
   foreach my $detail ( @details ) {
     my $quotation_pkg_detail = new FS::quotation_pkg_detail {
-      'billpkgnum' => $self->quotationpkgnum,
-      'detail'     => $detail,
+      'quotationpkgnum' => $self->quotationpkgnum,
+      'detail' => $detail,
     };
     $error = $quotation_pkg_detail->insert;
     if ( $error ) {
@@ -431,6 +418,10 @@ sub set_details {
 
 }
 
+sub details_header {
+  return ();
+}
+
 =item cust_bill_pkg_display [ type => TYPE ]
 
 =cut
index be3d815..ce13589 100644 (file)
@@ -34,10 +34,9 @@ currently supported:
 
 primary key
 
-=item billpkgnum
+=item quotationpkgnum
 
-named thusly for quick compatability with L<FS::TemplateItem_Mixin>,
-actually the quotationpkgnum for the relevant L<FS::quotation_pkg>
+for the relevant L<FS::quotation_pkg>
 
 =item detail
 
@@ -108,7 +107,7 @@ sub check {
 
   my $error = 
     $self->ut_numbern('detailnum')
-    || $self->ut_foreign_key('billpkgnum', 'quotation_pkg', 'quotationpkgnum')
+    || $self->ut_foreign_key('quotationpkgnum', 'quotation_pkg', 'quotationpkgnum')
     || $self->ut_text('detail')
   ;
   return $error if $error;
index 6f4bf62..e62bf34 100644 (file)
@@ -155,6 +155,80 @@ sub reasontype {
   qsearchs( 'reason_type', { 'typenum' => shift->reason_type } );
 }
 
+=item merge
+
+Accepts an arrayref of reason objects, to be merged into this reason.
+Reasons must all have the same reason_type class as this one.
+Matching reasonnums will be replaced in the following tables:
+
+  cust_bill_void
+  cust_bill_pkg_void
+  cust_credit
+  cust_credit_void
+  cust_pay_void
+  cust_pkg_reason
+  cust_refund
+
+=cut
+
+sub merge {
+  my ($self,$reasons) = @_;
+  return "Bad input for merge" unless ref($reasons) eq 'ARRAY';
+
+  my $class = $self->reasontype->class;
+
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  my $error;
+  foreach my $reason (@$reasons) {
+    last if $error;
+    next if $reason->reasonnum eq $self->reasonnum;
+    $error = "Mismatched reason type class"    
+      unless $reason->reasontype->class eq $class;
+    foreach my $table ( qw(
+      cust_bill_void
+      cust_bill_pkg_void
+      cust_credit
+      cust_credit_void
+      cust_pay_void
+      cust_pkg_reason
+      cust_refund
+    )) {
+      last if $error;
+      my @fields = ('reasonnum');
+      push(@fields, 'void_reasonnum') if $table eq 'cust_credit_void';
+      foreach my $field (@fields) {
+        last if $error;
+        foreach my $obj ( qsearch($table,{ $field => $reason->reasonnum }) ) {
+          last if $error;
+          $obj->set($field,$self->reasonnum);
+          $error = $obj->replace;
+        }
+      }
+    }
+    $error ||= $reason->delete;
+  }
+
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  '';
+
+}
+
 =back
 
 =head1 CLASS METHODS
index f307033..d3e23f2 100644 (file)
@@ -4,6 +4,7 @@ use base qw( FS::svc_Domain_Mixin FS::svc_PBX_Mixin
              FS::svc_Radius_Mixin
              FS::svc_Tower_Mixin
              FS::svc_IP_Mixin
+             FS::Password_Mixin
              FS::svc_Common
            );
 
@@ -684,6 +685,9 @@ sub insert {
     'child_objects' => $self->child_objects,
     %options,
   );
+
+  $error ||= $self->insert_password_history;
+
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return $error;
@@ -893,6 +897,12 @@ sub replace {
   my $dbh = dbh;
 
   $error = $new->SUPER::replace($old, @_); # usergroup here
+
+  # don't need to record this unless the password was changed
+  if ( $old->_password ne $new->_password ) {
+    $error ||= $new->insert_password_history;
+  }
+
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return $error if $error;
index 408bd79..1a42efa 100644 (file)
@@ -6,7 +6,7 @@ use base qw(
   FS::MAC_Mixin
   FS::svc_Common
 );
-use FS::Record qw( qsearch qsearchs );
+use FS::Record qw( dbh qsearch qsearchs );
 use FS::circuit_provider;
 use FS::circuit_type;
 use FS::circuit_termination;
@@ -221,9 +221,9 @@ sub label {
 sub search_sql {
   my ($class, $string) = @_;
   my @where = ();
-  push @where, 'LOWER(svc_circuit.circuit_id) = \''.lc($string).'\'';
-  push @where, 'LOWER(circuit_provider.provider) = \''.lc($string).'\'';
-  push @where, 'LOWER(circuit_type.typename) = \''.lc($string).'\'';
+  push @where, 'LOWER(svc_circuit.circuit_id) = ' . dbh->quote($string);
+  push @where, 'LOWER(circuit_provider.provider) = ' . dbh->quote($string);
+  push @where, 'LOWER(circuit_type.typename) = ' . dbh->quote($string);
   '(' . join(' OR ', @where) . ')';
 }
 
index 5041ccd..f1195ac 100644 (file)
@@ -858,3 +858,5 @@ FS/report_batch.pm
 t/report_batch.t
 FS/report_batch.pm
 t/report_batch.t
+FS/password_history.pm
+t/password_history.t
diff --git a/FS/t/password_history.t b/FS/t/password_history.t
new file mode 100644 (file)
index 0000000..b7a05fd
--- /dev/null
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::password_history;
+$loaded=1;
+print "ok 1\n";
index 9d9577c..11c71ea 100644 (file)
@@ -3,7 +3,7 @@ Section: misc
 Priority: extra
 Maintainer: Ivan Kohler <ivan-debian@420.am>
 Uploaders: Jeremy Davis <jeremyd@freeside.biz>
-Build-Depends: debhelper (>= 5), perl (>= 5.8)
+Build-Depends: debhelper (>= 5), perl (>= 5.8), torrus-common
 Standards-Version: 3.7.2
 Homepage: http://www.freeside.biz/freeside
 
index 309021a..524be1f 100644 (file)
@@ -55,9 +55,10 @@ Hello <%= $name %>!<BR><BR>
   } else {
     $OUT .= '<P>You have no outstanding invoices.</P>';
   }
-
 %>
 
+<%= $announcement || '' %>
+
 <%=
   if ( @support_services ) {
     $OUT .= '<TABLE BORDER=0 CELLSPACING=0 CELLPADDING=2 BGCOLOR="#eeeeee">'.
index 8af88a9..bdbcf37 100644 (file)
@@ -18,6 +18,8 @@
                  'fields'      => \@fields,
                  'links'       => \@links,
                  'align'       => $align,
+                 'html_form'   => qq!<FORM ACTION="${p}misc/reason_merge.html" METHOD="POST">!,
+                 'html_foot'   => $html_foot,
              )
 %>
 <%init>
@@ -31,7 +33,8 @@ my $class = $1;
 my $classname = $FS::reason_type::class_name{$class};
 my $classpurpose = $FS::reason_type::class_purpose{$class};
 
-my $html_init = ucfirst($classname).  " reasons $classpurpose.<BR><BR>".
+my $html_init = include('/elements/init_overlib.html').
+ucfirst($classname).  " reasons $classpurpose.<BR><BR>".
 qq!<A HREF="${p}edit/reason.html?class=$class">!.
 "<I>Add a $classname reason</I></A><BR><BR>";
 
@@ -107,5 +110,22 @@ if ( $class eq 'S' ) {
   $align .= 'cl';
 }
 
+# reason merge handling
+push @header, '';
+push @fields, sub {
+  my $reason = shift;
+  my $reasonnum = $reason->reasonnum;
+  qq!<INPUT TYPE="checkbox" NAME="reasonnum" VALUE="$reasonnum">!;
+};
+push @links, '';
+$align .= 'l';
+my $html_foot = include('/search/elements/checkbox-foot.html',
+  onclick  => include( '/elements/popup_link_onclick.html',
+                js_action   => q!'! . "${p}misc/reason-merge.html?" . q!' + toCGIString()!,
+                actionlabel => 'Merge reasons',
+              ),
+  label    => 'merge selected reasons',
+  minboxes => 2,
+) . '</FORM>';
 
 </%init>
index 0cb6e7a..e5f42e8 100644 (file)
@@ -21,6 +21,8 @@
                     '',
                   ],
   'disable_total' => 1,
+  'html_form'   => qq!<FORM ACTION="${p}misc/reason_merge.html" METHOD="POST">!,
+  'html_foot'   => $html_foot,
 &>
 <%init>
 
@@ -44,7 +46,8 @@ my $html_init = 'Reasons: ' .
     } keys (%FS::reason_type::class_name)
   );
 
-$html_init .= '<BR><P>' .
+$html_init .= include('/elements/init_overlib.html').
+  '<BR><P>' .
   $classname . ' reasons ' .
   $FS::reason_type::class_purpose{$class} .
   '. Reason types allow reasons to be grouped for reporting purposes.' .
@@ -64,6 +67,10 @@ my $reasons_sub = sub {
               'link'  => $p. "edit/reason.html?class=$class&reasonnum=".
                              $_->reasonnum,
             },
+            {
+              'data'  => q!<INPUT TYPE="checkbox" NAME="reasonnum" VALUE="! . $_->reasonnum . q!">!,
+              'align' => 'right',
+            },
           ];
         }
     $reason_type->enabled_reasons ),
@@ -73,7 +80,8 @@ my $reasons_sub = sub {
         'align' => 'left',
         'link'  => $p. "edit/reason.html?class=$class",
         'data_style' => 'i',
-      }
+      },
+      { 'data'  => '' },
     ]
 
   ];
@@ -86,4 +94,13 @@ $count_query .= $where_clause;
 
 my $link = [ $p.'edit/reason_type.html?class='.$class.'&typenum=', 'typenum' ];
 
+my $html_foot = include('/search/elements/checkbox-foot.html',
+  onclick  => include( '/elements/popup_link_onclick.html',
+                js_action   => q!'! . "${p}misc/reason-merge.html?" . q!' + toCGIString()!,
+                actionlabel => 'Merge reasons',
+              ),
+  label    => 'merge selected reasons',
+  minboxes => 2,
+) . '</FORM>';
+
 </%init>
index ef8ad31..85512f8 100644 (file)
@@ -43,11 +43,13 @@ my @menubar = ( 'Add a new router', "${p2}edit/router.cgi" );
 
 if ($cgi->param('hidecustomerrouters') eq '1') {
   $extra_sql = 'WHERE svcnum > 0';
-  $cgi->param('hidecustomerrouters', 0);
+  $cgi->delete('hidecustomerrouters');
   push @menubar, 'Show customer routers', $cgi->self_url();
+  $cgi->param('hidecustomerrouters', 1);
 } else {
   $cgi->param('hidecustomerrouters', 1);
   push @menubar, 'Hide customer routers', $cgi->self_url();
+  $cgi->delete('hidecustomerrouters');
 }
 
 my $count_sql = $extra_sql.  ( $extra_sql =~ /WHERE/ ? ' AND' : 'WHERE' ).
index 18a84e8..3f80570 100644 (file)
@@ -143,5 +143,11 @@ Contains icons from
 by Mark James, licensed under the terms of the Creative Commons Attribution
 2.5 License.
 
+<P>
+Includes icon from 
+http://www.iconarchive.com/show/oxygen-icons-by-oxygen-icons.org/Actions-document-edit-icon.html
+licensed under GNU Lesser General Public License
+</P>
+
 </BODY>
 </HTML>
index 12a4a6f..53f538b 100644 (file)
@@ -300,6 +300,7 @@ my %substitutions = (
     '$payby'          => 'Payment method',
     '$date'           => 'Payment date',
     '$payinfo'        => 'Card/account# (masked)',
+    '$payinfo_end'    => 'Card/account last 4 digits',
     '$error'          => 'Decline reason',
   ],
   'cust_refund'  => [
@@ -308,6 +309,7 @@ my %substitutions = (
     '$payby'          => 'Refund method',
     '$date'           => 'Refund date',
     '$payinfo'        => 'Card/account# (masked)',
+    '$payinfo_end'    => 'Card/account last 4 digits',
   ],
   'system_log' => [
     '$logmessage'     => 'Log entry message',
index 9cac2c5..d75ff92 100755 (executable)
@@ -81,7 +81,12 @@ if (     $cgi->param('clear_password') eq '*HIDDEN*'
       || $cgi->param('clear_password') =~ /^\(.* encrypted\)$/ ) {
   die "fatal: no previous account to recall hidden password from!" unless $old;
 } else {
-  $error ||= $new->set_password($cgi->param('clear_password'));
+  my $newpass = $cgi->param('clear_password');
+  if ( ! $old->check_password($newpass) ) {
+    # then the password is being changed
+    $error ||= $new->is_password_allowed($newpass)
+           ||  $new->set_password($newpass);
+  }
 }
 
 if ( ! $error ) {
index b8f589a..80a9044 100644 (file)
     <TD BGCOLOR="#ffffff"><% $part_pkg->comment |h %></TD>
   </TR>
 
-  <TR>
-    <TD COLSPAN=2>Detail: </TD>
-  </TR>
-
 % my $row = 0;
 % for ( @details ) { 
 
     <TR>
-      <TD></TD>
+      <TD ALIGN="right"><% $row ? '' : 'Detail' %></TD>
       <TD>
         <INPUT TYPE="text" NAME="detail<% $row %>" SIZE="60" MAXLENGTH="65" VALUE="<% $_ |h %>" rownum="<% $row++ %>" onkeyup="possiblyAddRow" onchange="possiblyAddrow">
       </TD>
     var row = document.createElement('TR');
 
     var empty_cell = document.createElement('TD');
+    if (!rownum) {
+      empty_cell.innerHTML = 'Detail:'
+      empty_cell.style.textAlign = 'right';
+    }
     row.appendChild(empty_cell);
 
     var detail_cell = document.createElement('TD');
index 7cab9c4..d58ce54 100644 (file)
@@ -11,7 +11,9 @@ die "access denied" unless (
   ( $curuser->access_right('Edit password') and 
     ! $part_svc->restrict_edit_password )
   );
-my $error = $svc_acct->set_password($cgi->param('password'))
+my $newpass = $cgi->param('password');
+my $error = $svc_acct->is_password_allowed($newpass)
+        ||  $svc_acct->set_password($newpass)
         ||  $svc_acct->replace;
 
 # annoyingly specific to view/svc_acct.cgi, for now...
index c470094..ae8b794 100644 (file)
@@ -11,6 +11,7 @@
                     },
                   ],
                   filter        => '.name = "pkgpart"', # see below
+                  minboxes      => 2, #will remove checkboxes if there aren't at least this many
                ),
 &>
 
@@ -67,6 +68,14 @@ for (var i = 0; i < inputs.length; i++) {
   }
 }
 %# avoid the need for "$areboxes" late-evaluation hackery
+% if ($opt{'minboxes'}) {
+if ( checkboxes.length < <% $opt{'minboxes'} %> ) {
+  for (i = 0; i < checkboxes.length; i++) {
+    checkboxes[i].parentNode.removeChild(checkboxes[i]);
+  }
+  checkboxes = [];
+}
+% }
 if ( checkboxes.length == 0 ) {
   document.getElementById('checkbox_footer').style.display = 'none';
 }
index 792c961..6c12d52 100644 (file)
@@ -17,13 +17,15 @@ extract($customer_info);
 
 ?>
 
-Hello <? echo htmlspecialchars($name); ?><BR><BR>
+<P>Hello <? echo htmlspecialchars($name); ?></P>
 
 <? if ( $signupdate_pretty ) { ?>
-  Thank you for being a customer since <? echo $signupdate_pretty; ?><BR><BR>
+  <P>Thank you for being a customer since <? echo $signupdate_pretty; ?></P>
 <? } ?>
 
-Your current balance is: <B>$<? echo $balance ?></B><BR><BR>
+<P>Your current balance is: <B>$<? echo $balance ?></B></P>
+
+<? echo $announcement ?>
 
 <!--
 your open invoices if you have any & payment link if you have one.  more insistant if you're late?
index f1ae435..74411ec 100644 (file)
@@ -1305,13 +1305,11 @@ C<print-cf> specifies oe or more consolidation functions, separated by comma.
 The result of the rendering is the text line with the output values
 separated by colon (:).
 
-=back
-
 =item * C<disable-legend>, C<disable-title>, C<disable-vertical-label>
 
 When set to C<yes>, the corresponding elements of the graph are not displayed.
 
-
+=back
 
 =head3 Styling Profiles