summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorIvan Kohler <ivan@freeside.biz>2015-11-13 13:06:00 -0800
committerIvan Kohler <ivan@freeside.biz>2015-11-13 13:06:00 -0800
commit2d3ab45cd0b35cb929198e2ad36a1ec9a3b4d93d (patch)
tree97bc37e32960f89b299f4b7efa49694e06e95243
parent156ce177ef4fb53cfcf688b6127a3c4cff9fb83d (diff)
parentfb5becb1a877994b897e737e983fae2c8da6a5ac (diff)
Merge branch 'master' of git.freeside.biz:/home/git/freeside
-rw-r--r--FS/FS/ClientAPI/MyAccount.pm16
-rw-r--r--FS/FS/ClientAPI/Signup.pm3
-rw-r--r--FS/FS/Conf.pm14
-rw-r--r--FS/FS/Mason.pm1
-rw-r--r--FS/FS/Password_Mixin.pm165
-rw-r--r--FS/FS/Schema.pm52
-rw-r--r--FS/FS/TemplateItem_Mixin.pm7
-rw-r--r--FS/FS/Template_Mixin.pm51
-rw-r--r--FS/FS/cust_main/Billing.pm3
-rw-r--r--FS/FS/cust_main/Billing_Discount.pm7
-rw-r--r--FS/FS/msg_template.pm32
-rw-r--r--FS/FS/password_history.pm174
-rw-r--r--FS/FS/quotation_pkg.pm23
-rw-r--r--FS/FS/quotation_pkg_detail.pm7
-rw-r--r--FS/FS/reason.pm74
-rw-r--r--FS/FS/svc_acct.pm10
-rw-r--r--FS/FS/svc_circuit.pm8
-rw-r--r--FS/MANIFEST2
-rw-r--r--FS/t/password_history.t5
-rw-r--r--debian/control2
-rw-r--r--fs_selfservice/FS-SelfService/cgi/myaccount.html3
-rw-r--r--httemplate/browse/reason.html22
-rw-r--r--httemplate/browse/reason_type.html21
-rw-r--r--httemplate/browse/router.cgi4
-rw-r--r--httemplate/docs/license.html6
-rw-r--r--httemplate/edit/msg_template/email.html2
-rwxr-xr-xhttemplate/edit/process/svc_acct.cgi7
-rw-r--r--httemplate/edit/quotation_pkg_detail.html10
-rw-r--r--httemplate/misc/process/change-password.html4
-rw-r--r--httemplate/search/elements/checkbox-foot.html9
-rw-r--r--ng_selfservice/main.php8
-rw-r--r--torrus/doc/xmlconfig.pod.in4
32 files changed, 636 insertions, 120 deletions
diff --git a/FS/FS/ClientAPI/MyAccount.pm b/FS/FS/ClientAPI/MyAccount.pm
index f272cd490..53deaaadc 100644
--- a/FS/FS/ClientAPI/MyAccount.pm
+++ b/FS/FS/ClientAPI/MyAccount.pm
@@ -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;
diff --git a/FS/FS/ClientAPI/Signup.pm b/FS/FS/ClientAPI/Signup.pm
index 5d719c490..a178bec9c 100644
--- a/FS/FS/ClientAPI/Signup.pm
+++ b/FS/FS/ClientAPI/Signup.pm
@@ -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;
diff --git a/FS/FS/Conf.pm b/FS/FS/Conf.pm
index 990f2a3be..a4cc87199 100644
--- a/FS/FS/Conf.pm
+++ b/FS/FS/Conf.pm
@@ -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.',
diff --git a/FS/FS/Mason.pm b/FS/FS/Mason.pm
index 98a75c8df..58b3da7db 100644
--- a/FS/FS/Mason.pm
+++ b/FS/FS/Mason.pm
@@ -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
index 000000000..af4c5e2b7
--- /dev/null
+++ b/FS/FS/Password_Mixin.pm
@@ -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;
diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm
index 6b5d6586c..5a2a9bead 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -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' => {
diff --git a/FS/FS/TemplateItem_Mixin.pm b/FS/FS/TemplateItem_Mixin.pm
index dcd7ab3fb..248da3cae 100644
--- a/FS/FS/TemplateItem_Mixin.pm
+++ b/FS/FS/TemplateItem_Mixin.pm
@@ -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;
diff --git a/FS/FS/Template_Mixin.pm b/FS/FS/Template_Mixin.pm
index e02aa1f87..e889142a5 100644
--- a/FS/FS/Template_Mixin.pm
+++ b/FS/FS/Template_Mixin.pm
@@ -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 (
diff --git a/FS/FS/cust_main/Billing.pm b/FS/FS/cust_main/Billing.pm
index eee0958e0..d3c618d55 100644
--- a/FS/FS/cust_main/Billing.pm
+++ b/FS/FS/cust_main/Billing.pm
@@ -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;
}
diff --git a/FS/FS/cust_main/Billing_Discount.pm b/FS/FS/cust_main/Billing_Discount.pm
index 117bf311a..ec2bf077e 100644
--- a/FS/FS/cust_main/Billing_Discount.pm
+++ b/FS/FS/cust_main/Billing_Discount.pm
@@ -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 }
diff --git a/FS/FS/msg_template.pm b/FS/FS/msg_template.pm
index 01a656366..7d9750cc2 100644
--- a/FS/FS/msg_template.pm
+++ b/FS/FS/msg_template.pm
@@ -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
index 000000000..dd527b980
--- /dev/null
+++ b/FS/FS/password_history.pm
@@ -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;
+
diff --git a/FS/FS/quotation_pkg.pm b/FS/FS/quotation_pkg.pm
index 49d0d9a5c..e264209ef 100644
--- a/FS/FS/quotation_pkg.pm
+++ b/FS/FS/quotation_pkg.pm
@@ -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
diff --git a/FS/FS/quotation_pkg_detail.pm b/FS/FS/quotation_pkg_detail.pm
index be3d81529..ce13589f0 100644
--- a/FS/FS/quotation_pkg_detail.pm
+++ b/FS/FS/quotation_pkg_detail.pm
@@ -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;
diff --git a/FS/FS/reason.pm b/FS/FS/reason.pm
index 6f4bf62d9..e62bf342b 100644
--- a/FS/FS/reason.pm
+++ b/FS/FS/reason.pm
@@ -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
diff --git a/FS/FS/svc_acct.pm b/FS/FS/svc_acct.pm
index f3070338b..d3e23f237 100644
--- a/FS/FS/svc_acct.pm
+++ b/FS/FS/svc_acct.pm
@@ -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;
diff --git a/FS/FS/svc_circuit.pm b/FS/FS/svc_circuit.pm
index 408bd79e4..1a42efadd 100644
--- a/FS/FS/svc_circuit.pm
+++ b/FS/FS/svc_circuit.pm
@@ -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) . ')';
}
diff --git a/FS/MANIFEST b/FS/MANIFEST
index 5041ccd68..f1195acc7 100644
--- a/FS/MANIFEST
+++ b/FS/MANIFEST
@@ -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
index 000000000..b7a05fd9f
--- /dev/null
+++ b/FS/t/password_history.t
@@ -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";
diff --git a/debian/control b/debian/control
index 9d9577cb8..11c71ea8c 100644
--- a/debian/control
+++ b/debian/control
@@ -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
diff --git a/fs_selfservice/FS-SelfService/cgi/myaccount.html b/fs_selfservice/FS-SelfService/cgi/myaccount.html
index 309021a87..524be1f6a 100644
--- a/fs_selfservice/FS-SelfService/cgi/myaccount.html
+++ b/fs_selfservice/FS-SelfService/cgi/myaccount.html
@@ -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">'.
diff --git a/httemplate/browse/reason.html b/httemplate/browse/reason.html
index 8af88a950..bdbcf3704 100644
--- a/httemplate/browse/reason.html
+++ b/httemplate/browse/reason.html
@@ -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>
diff --git a/httemplate/browse/reason_type.html b/httemplate/browse/reason_type.html
index 0cb6e7a39..e5f42e839 100644
--- a/httemplate/browse/reason_type.html
+++ b/httemplate/browse/reason_type.html
@@ -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>
diff --git a/httemplate/browse/router.cgi b/httemplate/browse/router.cgi
index ef8ad3160..85512f8df 100644
--- a/httemplate/browse/router.cgi
+++ b/httemplate/browse/router.cgi
@@ -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' ).
diff --git a/httemplate/docs/license.html b/httemplate/docs/license.html
index 18a84e8c6..3f80570d9 100644
--- a/httemplate/docs/license.html
+++ b/httemplate/docs/license.html
@@ -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>
diff --git a/httemplate/edit/msg_template/email.html b/httemplate/edit/msg_template/email.html
index 12a4a6f56..53f538b11 100644
--- a/httemplate/edit/msg_template/email.html
+++ b/httemplate/edit/msg_template/email.html
@@ -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',
diff --git a/httemplate/edit/process/svc_acct.cgi b/httemplate/edit/process/svc_acct.cgi
index 9cac2c568..d75ff92c1 100755
--- a/httemplate/edit/process/svc_acct.cgi
+++ b/httemplate/edit/process/svc_acct.cgi
@@ -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 ) {
diff --git a/httemplate/edit/quotation_pkg_detail.html b/httemplate/edit/quotation_pkg_detail.html
index b8f589a9a..80a904420 100644
--- a/httemplate/edit/quotation_pkg_detail.html
+++ b/httemplate/edit/quotation_pkg_detail.html
@@ -21,15 +21,11 @@
<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>
@@ -63,6 +59,10 @@
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');
diff --git a/httemplate/misc/process/change-password.html b/httemplate/misc/process/change-password.html
index 7cab9c4e3..d58ce544d 100644
--- a/httemplate/misc/process/change-password.html
+++ b/httemplate/misc/process/change-password.html
@@ -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...
diff --git a/httemplate/search/elements/checkbox-foot.html b/httemplate/search/elements/checkbox-foot.html
index c47009425..ae8b79470 100644
--- a/httemplate/search/elements/checkbox-foot.html
+++ b/httemplate/search/elements/checkbox-foot.html
@@ -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';
}
diff --git a/ng_selfservice/main.php b/ng_selfservice/main.php
index 792c961af..6c12d5260 100644
--- a/ng_selfservice/main.php
+++ b/ng_selfservice/main.php
@@ -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?
diff --git a/torrus/doc/xmlconfig.pod.in b/torrus/doc/xmlconfig.pod.in
index f1ae4350c..74411ec91 100644
--- a/torrus/doc/xmlconfig.pod.in
+++ b/torrus/doc/xmlconfig.pod.in
@@ -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