From: Ivan Kohler Date: Wed, 18 Nov 2015 01:00:06 +0000 (-0800) Subject: Merge branch 'master' of git.freeside.biz:/home/git/freeside X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=commitdiff_plain;h=f32ac83068c6211f829f1688a1a9cdec71bc6ec7;hp=2c112f32561f23f9c538ace00db46659ce16da32 Merge branch 'master' of git.freeside.biz:/home/git/freeside --- diff --git a/FS/FS/ClientAPI/MyAccount.pm b/FS/FS/ClientAPI/MyAccount.pm index 1a3e57ed8..7e1720da5 100644 --- a/FS/FS/ClientAPI/MyAccount.pm +++ b/FS/FS/ClientAPI/MyAccount.pm @@ -661,6 +661,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, @@ -2990,13 +2995,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. @@ -3275,8 +3282,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..c4549c727 --- /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; +# 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 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 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 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 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 + +=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/part_export/cardfortress.pm b/FS/FS/part_export/cardfortress.pm index 154f979b0..8c9413597 100644 --- a/FS/FS/part_export/cardfortress.pm +++ b/FS/FS/part_export/cardfortress.pm @@ -4,6 +4,7 @@ use strict; use base 'FS::part_export'; use vars qw( %info ); use String::ShellQuote; +use Net::OpenSSH; #tie my %options, 'Tie::IxHash'; #; @@ -21,8 +22,6 @@ sub rebless { shift; } sub _export_insert { my($self, $svc_acct) = (shift, shift); - eval "use Net::OpenSSH;"; - return $@ if $@; open my $def_in, '<', '/dev/null' or die "unable to open /dev/null"; my $ssh = Net::OpenSSH->new( $self->machine, @@ -61,9 +60,6 @@ sub _export_delete { #well, we're just going to disable them for now, but there you go - eval "use Net::OpenSSH;"; - return $@ if $@; - open my $def_in, '<', '/dev/null' or die "unable to open /dev/null"; my $ssh = Net::OpenSSH->new( $self->machine, default_stdin_fh => $def_in ); 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 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 + +=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, -actually the quotationpkgnum for the relevant L +for the relevant L =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..2764f706d 100644 --- a/debian/control +++ b/debian/control @@ -3,7 +3,7 @@ Section: misc Priority: extra Maintainer: Ivan Kohler Uploaders: Jeremy Davis -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 @@ -87,7 +87,7 @@ Depends: gnupg,ghostscript,gsfonts,gzip,latex-xcolor, libemail-address-list-perl, libsymbol-global-name-perl, libdate-extract-perl, librole-basic-perl, libhtml-formattext-withlinks-andtables-perl, libcrypt-x509-perl, - libdata-guid-perl + libdata-guid-perl, libparams-classify-perl (>= 0.013-5.1) Suggests: libbusiness-onlinepayment-perl Description: Libraries for Freeside billing and trouble ticketing Freeside is a web-based billing and trouble ticketing application. 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 %>!

} else { $OUT .= '

You have no outstanding invoices.

'; } - %> +<%= $announcement || '' %> + <%= if ( @support_services ) { $OUT .= ''. 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!!, + '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.

". +my $html_init = include('/elements/init_overlib.html'). +ucfirst($classname). " reasons $classpurpose.

". qq!!. "Add a $classname reason

"; @@ -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!!; +}; +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, +) . ''; 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!!, + 'html_foot' => $html_foot, &> <%init> @@ -44,7 +46,8 @@ my $html_init = 'Reasons: ' . } keys (%FS::reason_type::class_name) ); -$html_init .= '

' . +$html_init .= include('/elements/init_overlib.html'). + '

' . $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!!, + '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, +) . ''; + 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 7e5bb1e3e..3f80570d9 100644 --- a/httemplate/docs/license.html +++ b/httemplate/docs/license.html @@ -63,7 +63,7 @@ GmbH, licensed under the terms of the GNU GPL.

Latex invoice template based on a template from eBills by Mark Asplen-Taylor , -licensed under the terms fo the GNU GPL. +licensed under the terms of the GNU GPL.

Contains "JS Calendar" @@ -143,5 +143,11 @@ Contains icons from by Mark James, licensed under the terms of the Creative Commons Attribution 2.5 License. +

+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 +

+ 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/reason.html b/httemplate/edit/process/reason.html index cb79ed254..dfc18624e 100644 --- a/httemplate/edit/process/reason.html +++ b/httemplate/edit/process/reason.html @@ -1,6 +1,6 @@ <% include( 'elements/process.html', 'table' => 'reason', - 'redirect' => popurl(3) . 'browse/reason.html?class=' . + 'redirect' => popurl(3) . 'browse/reason_type.html?class=' . $cgi->param('class') . '&', ) %> 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 @@ - - - - % my $row = 0; % for ( @details ) { - + @@ -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/cust_credit-import.html b/httemplate/misc/cust_credit-import.html index 9a63a04c5..937010dd6 100644 --- a/httemplate/misc/cust_credit-import.html +++ b/httemplate/misc/cust_credit-import.html @@ -69,7 +69,7 @@ Field information:

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/misc/reason-merge.html b/httemplate/misc/reason-merge.html new file mode 100644 index 000000000..14f5ebb84 --- /dev/null +++ b/httemplate/misc/reason-merge.html @@ -0,0 +1,77 @@ +% if ($success) { +<% include('/elements/header-popup.html', 'Reason Merge Success') %> + +% } else { +<% include('/elements/header-popup.html', 'Merge Reasons') %> +% if ($error) { +

<% emt($error) %>

+% } +% if (@reasons > 1) { +

+The following reasons will be merged into one. +Please select one reason to merge the others into. +

+"> +

+% foreach my $reason (@reasons) { + + +<% $reason->reason %>
+% } +

+

Caution: merging reasons cannot be undone!

+

+ +% } else { + +% } +% } + +<%init> +my @reasonnums = $cgi->param('reasonnum'); +my $destreasonnum = $cgi->param('destreasonnum'); + +my $error; +my $class; +my @reasons; +my $destreason; +foreach my $reasonnum (@reasonnums) { + unless ($reasonnum =~ /^\d+$/) { + $error = "Invalid reasonnum $reasonnum."; + last; + } + my $reason = qsearchs('reason',{ 'reasonnum' => $reasonnum }); + unless ($reason) { + $error = "Reason $reasonnum could not be loaded."; + last; + } + my $reasontype = $reason->reasontype; + $class ||= $reasontype->class; + if ($class ne $reasontype->class) { + $error = "Selected reasons must have the same reason type class."; + last; + } + push(@reasons, $reason); + $destreason = $reason if $reasonnum eq $destreasonnum; +} + +unless ($error) { + $error = "No reasons selected." unless @reasons; + $error = "Select two or more reasons to merge." unless @reasons > 1; +} + +@reasons = () if $error; + +my $success = 0; +if ($cgi->param('process_merge') && !$error) { + if ($destreason) { + $error = $destreason->merge(\@reasons); + $success = 1 unless $error; + } else { + $error = "No destination reason selected."; + } +} + + 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

+

Hello

- Thank you for being a customer since

+

Thank you for being a customer since

-Your current balance is: $

+

Your current balance is: $

+ +
<% $part_pkg->comment |h %>
Detail:
<% $row ? '' : 'Detail' %>