Commit 395826f8 authored by Christophe Maudoux's avatar Christophe Maudoux

Rename U2F manager module to SFA

parent f808292b
......@@ -20,7 +20,7 @@ lib/Lemonldap/NG/Manager/Conf/Tests.pm
lib/Lemonldap/NG/Manager/Conf/Zero.pm
lib/Lemonldap/NG/Manager/Notifications.pm
lib/Lemonldap/NG/Manager/Sessions.pm
lib/Lemonldap/NG/Manager/U2F.pm
lib/Lemonldap/NG/Manager/SFA.pm
Makefile.PL
MANIFEST This list of files
META.yml
......@@ -34,7 +34,7 @@ site/coffee/llApp.coffee
site/coffee/manager.coffee
site/coffee/notifications.coffee
site/coffee/sessions.coffee
site/coffee/u2f.coffee
site/coffee/sfa.coffee
site/htdocs/manager.fcgi
site/htdocs/manager.psgi
site/htdocs/static/bwr/angular-animate/angular-animate.js
......@@ -150,8 +150,8 @@ site/htdocs/static/js/notifications.js
site/htdocs/static/js/notifications.min.js
site/htdocs/static/js/sessions.js
site/htdocs/static/js/sessions.min.js
site/htdocs/static/js/u2f.js
site/htdocs/static/js/u2f.min.js
site/htdocs/static/js/sfa.js
site/htdocs/static/js/sfa.min.js
site/htdocs/static/languages/ar.json
site/htdocs/static/languages/en.json
site/htdocs/static/languages/fr.json
......
......@@ -91,7 +91,7 @@ sub init {
# Find out more glyphicones at https://www.w3schools.com/icons/bootstrap_icons_glyphicons.asp
my $linksIcons =
{ 'conf' => 'cog', 'sessions' => 'duplicate', 'notifications' => 'bell', 'U2F' => 'wrench' };
{ 'conf' => 'cog', 'sessions' => 'duplicate', 'notifications' => 'bell', 'SFA' => 'wrench' };
$self->links( [] );
for ( my $i = 0 ; $i < @links ; $i++ ) {
......
package Lemonldap::NG::Manager::U2F;
use 5.10.0;
use utf8;
use strict;
use Mouse;
use MIME::Base64 qw(encode_base64 decode_base64);
#use Crypt::U2F::Server::Simple;
use Lemonldap::NG::Common::Session;
use Lemonldap::NG::Common::Conf::Constants;
use Lemonldap::NG::Common::PSGI::Constants;
use Lemonldap::NG::Common::Conf::ReConstants;
use feature 'state';
extends 'Lemonldap::NG::Common::Conf::AccessLib',
'Lemonldap::NG::Common::Session::REST';
our $VERSION = '2.0.0';
#############################
# I. INITIALIZATION METHODS #
#############################
use constant defaultRoute => 'u2f.html#/persistent';
sub addRoutes {
my ( $self, $conf ) = @_;
# Remote Procedure Call are defined in Lemonldap::NG::Common::Session::REST
# HTML template
$self->addRoute( 'u2f.html', undef, ['GET'] )
# READ
->addRoute(
u2f => { ':sessionType' => 'u2f' },
['GET']
)
# DELETE U2F KEY
->addRoute(
u2f => { ':sessionType' => { ':sessionId' => 'deleteU2FKey' } },
['DELETE']
)
# ADD U2F KEY
->addRoute(
u2f => { ':sessionType' => { ':sessionId' => 'registerU2FKey' } },
['PUT']
)
# VERIFY U2F KEY
->addRoute(
u2f => { ':sessionType' => { ':sessionId' => 'verifyU2FKey' } },
['POST']
);
$self->setTypes($conf);
$self->{ipField} ||= 'ipAddr';
$self->{multiValuesSeparator} ||= '; ';
$self->{hiddenAttributes} //= "_password";
}
############################
# II. REGISTRATION METHODS #
############################
sub registerU2FKey {
my ( $self, $req, $session, $skey ) = @_;
eval 'use Crypt::U2F::Server::Simple';
if ($@) {
$self->error("Can't load U2F library: $@");
return 0;
}
return $self->addU2FKey( $req, $session, $skey );
}
########################
# III. DISPLAY METHODS #
########################
sub u2f {
my ( $self, $req, $session, $skey ) = @_;
# Case 1: only one session is required
if ($session) {
return $self->session( $req, $session, $skey );
}
my $mod = $self->getMod($req)
or return $self->sendError( $req, undef, 400 );
my $params = $req->parameters();
my $type = delete $params->{sessionType};
$type = ucfirst($type);
my $res;
# Case 2: list of sessions
my $whatToTrace = Lemonldap::NG::Handler::PSGI::Main->tsv->{whatToTrace};
# 2.1 Get fields to require
my @fields = ( '_httpSessionType', $self->{ipField}, $whatToTrace, '_u2fKeyHandle' );
if ( my $groupBy = $params->{groupBy} ) {
$groupBy =~ s/^substr\((\w+)(?:,\d+(?:,\d+)?)?\)$/$1/;
$groupBy =~ s/^_whatToTrace$/$whatToTrace/o
or push @fields, $groupBy;
}
else {
push @fields, '_utime';
}
# 2.2 Restrict query if possible: search for filters (any query arg that is
# not a keyword)
my $moduleOptions = $mod->{options};
$moduleOptions->{backend} = $mod->{module};
my %filters = map {
my $s = $_;
$s =~ s/\b_whatToTrace\b/$whatToTrace/o;
/^(?:(?:group|order)By|doubleIp)$/
? ()
: ( $s => $params->{$_} );
} keys %$params;
$filters{_session_kind} = $type;
push @fields, keys(%filters);
{
my %seen;
@fields = grep { !$seen{$_}++ } @fields;
}
# For now, only one argument can be passed to
# Lemonldap::NG::Common::Apache::Session so just the first filter is
# used
my ($firstFilter) = sort {
$filters{$a} =~ m#^[\w:]+/\d+\*?$# ? 1
: $filters{$b} =~ m#^[\w:]+/\d+\*?$# ? -1
: $a eq '_session_kind' ? 1
: $b eq '_session_kind' ? -1
: $a cmp $b
} keys %filters;
# Check if a '*' is required
my $function = 'searchOn';
$function = 'searchOnExpr'
if ( grep { /\*/ and not m#^[\w:]+/\d+\*?$# }
( $filters{$firstFilter} ) );
$self->logger->debug(
"First filter: $firstFilter = $filters{$firstFilter} ($function)");
$res =
Lemonldap::NG::Common::Apache::Session->$function( $moduleOptions,
$firstFilter, $filters{$firstFilter}, @fields );
return $self->sendJSONresponse(
$req,
{
result => 1,
count => 0,
total => 0,
values => []
}
) unless ( $res and %$res );
delete $filters{$firstFilter}
unless ( grep { /\*/ and not m#^[\w:]+/\d+\*?$# }
( $filters{$firstFilter} ) );
foreach my $k ( keys %filters ) {
$self->logger->debug("Removing unless $k =~ /^$filters{$k}\$/");
if ( $filters{$k} =~ m#^([\w:]+)/(\d+)\*?$# ) {
my ( $net, $bits ) = ( $1, $2 );
foreach my $session ( keys %$res ) {
delete $res->{$session}
unless ( net6( $res->{$session}->{$k}, $bits ) eq $net );
}
}
else {
$filters{$k} =~ s/\./\\./g;
$filters{$k} =~ s/\*/\.\*/g;
foreach my $session ( keys %$res ) {
if ( $res->{$session}->{$k} ) {
delete $res->{$session}
unless ( $res->{$session}->{$k} =~ /^$filters{$k}$/ );
}
}
}
}
# Display sessions with registered U2F key only
foreach my $session ( keys %$res ) {
delete $res->{$session}
unless ( defined $res->{$session}->{_u2fKeyHandle} and length $res->{$session}->{_u2fKeyHandle} )
}
my $total = ( keys %$res );
if ( my $group = $req->params('groupBy') ) {
my $r;
$group =~ s/\b_whatToTrace\b/$whatToTrace/o;
# Substrings
if ( $group =~ /^substr\((\w+)(?:,(\d+)(?:,(\d+))?)?\)$/ ) {
my ( $field, $length, $start ) = ( $1, $2, $3 );
$start ||= 0;
$length = 1 if ( $length < 1 );
foreach my $k ( keys %$res ) {
$r->{ substr $res->{$k}->{$field}, $start, $length }++
if ( $res->{$k}->{$field} );
}
$group = $field;
}
# Simple field groupBy query
elsif ( $group =~ /^\w+$/ ) {
eval {
foreach my $k ( keys %$res ) {
$r->{ $res->{$k}->{$group} }++;
}
};
return $self->sendError(
$req,
qq{Use of an uninitialized attribute "$group" to group sessions},
400
) if ($@);
}
else {
return $self->sendError( $req, 'Syntax error in groupBy', 400 );
}
# Build result
$res = [
sort {
my @a = ( $a->{value} =~ /^(\d+)(?:\.(\d+))*$/ );
my @b = ( $b->{value} =~ /^(\d+)(?:\.(\d+))*$/ );
( @a and @b )
? ( $a[0] <=> $b[0]
or $a[1] <=> $b[1]
or $a[2] <=> $b[2]
or $a[3] <=> $b[3] )
: $a->{value} cmp $b->{value}
}
map { { value => $_, count => $r->{$_} } } keys %$r
];
}
# Else, $res elements will be like:
# { session => <sessionId>, date => <timestamp> }
else {
$res = [
sort { $a->{date} <=> $b->{date} }
map { { session => $_, date => $res->{$_}->{_utime} } }
keys %$res
];
}
return $self->sendJSONresponse(
$req,
{
result => 1,
count => scalar(@$res),
total => $total,
values => $res
}
);
}
1;
This diff is collapsed.
This diff is collapsed.
(function(){var c,e,d,b,g,f,a;b=25;a={_whatToTrace:[function(i,h){return"groupBy=substr("+i+",1)"},function(i,h){return i+"="+h+"*&groupBy="+i},function(i,h){return i+"="+h}],ipAddr:[function(i,h){return"groupBy=net("+i+",16,1)"},function(i,h){if(!h.match(/:/)){h=h+"."}return i+"="+h+"*&groupBy=net("+i+",32,2)"},function(i,h){if(!h.match(/:/)){h=h+"."}return i+"="+h+"*&groupBy=net("+i+",48,3)"},function(i,h){if(!h.match(/:/)){h=h+"."}return i+"="+h+"*&groupBy=net("+i+",128,4)"},function(i,h){return i+"="+h+"&groupBy=_whatToTrace"},function(i,h,j){return j.replace(/\&groupBy.*$/,"")+("&_whatToTrace="+h)}],_startTime:[function(i,h){return"groupBy=substr("+i+",8)"},function(i,h){return i+"="+h+"*&groupBy=substr("+i+",10)"},function(i,h){return i+"="+h+"*&groupBy=substr("+i+",11)"},function(i,h){return i+"="+h+"*&groupBy=substr("+i+",12)"},function(i,h){return i+"="+h+"*&groupBy=_whatToTrace"},function(i,h,j){console.log(i);console.log(h);console.log(j);return j.replace(/\&groupBy.*$/,"")+("&_whatToTrace="+h)}],doubleIp:[function(i,h){return i},function(i,h){return"_whatToTrace="+h+"&groupBy=ipAddr"},function(i,h,j){return j.replace(/\&groupBy.*$/,"")+("&ipAddr="+h)}]};f={_whatToTrace:function(i,h,k,j){if(k===1){return i+"="+h+"*&groupBy=substr("+i+","+(k+j+1)+")"}else{return null}},ipAddr:function(i,h,k,j){if(k>0&&k<4){return i+"="+h+"*&groupBy=net("+i+","+(16*k+4*(j+1))+",2)"}else{return null}}};e="_password";c={dateTitle:["_utime","_startTime","_updateTime","_lastAuthnUTime","_lastSeen"],connectionTitle:["ipAddr","_timezone","_url"],authenticationTitle:["_session_id","_user","_password","authenticationLevel"],modulesTitle:["_auth","_userDB","_passwordDB","_issuerDB","_authChoice","_authMulti","_userDBMulti"],saml:["_idp","_idpConfKey","_samlToken","_lassoSessionDump","_lassoIdentityDump"],groups:["groups","hGroups"],ldap:["dn"],BrowserID:["_browserIdAnswer","_browserIdAnswerRaw"],OpenIDConnect:["_oidc_id_token","_oidc_OP","_oidc_access_token"]};g={delU2FKey:[{title:"deleteU2FKey",icon:"trash"}],addU2FKey:[{title:"addU2FKey",icon:"plus"}],verifyU2FKey:[{title:"verifyU2FKey",icon:"check"}],home:[]};d=angular.module("llngSessionsExplorer",["ui.tree","ui.bootstrap","llApp"]);d.controller("SessionsExplorerCtrl",["$scope","$translator","$location","$q","$http",function(p,h,i,j,m){var n,l,k,o;p.links=links;p.menulinks=menulinks;p.staticPrefix=staticPrefix;p.scriptname=scriptname;p.formPrefix=formPrefix;p.availableLanguages=availableLanguages;p.waiting=true;p.showM=false;p.showT=true;p.data=[];p.currentScope=null;p.currentSession=null;p.menu=g;p.translateP=h.translateP;p.translate=h.translate;p.translateTitle=function(q){return h.translateField(q,"title")};o="global";p.menuClick=function(q){if(q.popup){window.open(q.popup)}else{if(!q.action){q.action=q.title}switch(typeof q.action){case"function":q.action(p.currentNode,p);break;case"string":p[q.action]();break;default:console.log(typeof q.action)}}return p.showM=false};p.deleteU2FKey=function(){p.waiting=true;m["delete"](scriptname+"u2f/"+o+"/"+p.currentSession.id).then(function(q){p.currentSession=null;p.currentScope.remove();return p.waiting=false},function(q){p.currentSession=null;p.currentScope.remove();return p.waiting=false});return p.showT=false};p.addU2FKey=function(){p.waiting=true;m.put(scriptname+"u2f/"+o+"/"+p.currentSession.id).then(function(q){return p.waiting=false},function(q){return p.waiting=false});p.showT=false;m.get(scriptname+"u2f/"+o+"/"+p.currentSession.id).then(function(q){return p.currentSession=transformSession(q.data)});return p.showT=false};p.verifyU2FKey=function(){p.waiting=true;m.post(scriptname+"u2f/"+o+"/"+p.currentSession.id).then(function(q){return p.waiting=false},function(q){return p.waiting=false});p.showT=true;m.get(scriptname+"u2f/"+o+"/"+p.currentSession.id).then(function(q){return p.currentSession=transformSession(q.data)});return p.showT=false};p.stoggle=function(q){var r;r=q.$modelValue;if(r.nodes.length===0){p.updateTree(r.value,r.nodes,r.level,r.over,r.query,r.count)}return q.toggle()};p.displaySession=function(r){var s,q;q=function(t){var y,A,E,C,G,J,B,I,H,O,F,K,x,w,u,z,N,M,v,L,D;A=function(P){return P};y=function(S,U){var Q,R,P,T;P=[];R=new RegExp(S);for(Q in t){T=t[Q];if(Q.match(R)&&T){P.push({title:Q,value:T});delete t[Q]}}if(P.length>0){return N.push({title:U,nodes:P})}};v=t._utime;B=t._session_id;for(O in t){D=t[O];if(!D){delete t[O]}else{if(typeof t==="string"&&D.match(/; /)){t[O]=D.split("; ")}if(typeof t[O]!=="object"){if(e.match(new RegExp("\b"+O+"\b"))){t[O]="********"}else{if(O.match(/^(_utime|_lastAuthnUTime|_lastSeen|notification)$/)){t[O]=p.localeDate(D)}else{if(O.match(/^(_startTime|_updateTime)$/)){t[O]=A(D)}}}}}}N=[];for(G in c){C=c[G];M=[];for(J=0,K=C.length;J<K;J++){E=C[J];if(t[E]){M.push({title:E,value:t[E]});delete t[E]}}if(M.length>0){N.push({title:"__"+G+"__",nodes:M})}}y("^openid","OpenID");y("^notification_(.+)","__notificationsDone__");if(t._loginHistory){L=[];if(t._loginHistory.successLogin){u=t._loginHistory.successLogin;for(I=0,x=u.length;I<x;I++){F=u[I];L.push({t:F._utime,title:p.localeDate(F._utime),value:"Success (IP "+F.ipAddr+")"})}}if(t._loginHistory.failedLogin){z=t._loginHistory.failedLogin;for(H=0,w=z.length;H<w;H++){F=z[H];L.push({t:F._utime,title:p.localeDate(F._utime),value:F.error+" (IP "+F.ipAddr+")"})}}delete t._loginHistory;L.sort(function(Q,P){return Q.t-P.t});N.push({title:"__loginHistory__",nodes:L})}L=[];for(O in t){D=t[O];L.push({title:O,value:D})}L.sort(function(Q,P){if(Q.title>P.title){return 1}else{if(Q.title<P.title){return -1}else{return 0}}});N.push({title:"__attributesAndMacros__",nodes:L});return{_utime:v,id:B,nodes:N}};p.currentScope=r;s=r.$modelValue.session;m.get(scriptname+"u2f/"+o+"/"+s).then(function(t){return p.currentSession=q(t.data)});return p.showT=false};p.localeDate=function(q){var r;r=new Date(q*1000);return r.toLocaleString()};p.getLanguage=function(q){p.lang=q;p.form="white";p.init();return p.showM=false};k=function(r,q,s){var t;t=q.match(/#\/(\w+)/);o="global";if(t===null){p.type="_whatToTrace"}else{if(t[1].match(/^(persistent)$/)){o=RegExp.$1;p.type="_session_uid"}else{p.type=t[1]}}return p.init()};p.$on("$locationChangeSuccess",k);n=0;p.updateTree=function(y,s,q,v,x,u){var w,r,t;p.waiting=true;r=a[p.type]?a[p.type]:p.type==="_updateTime"?a._startTime:a._whatToTrace;w=r[q](p.type,y,x);if(u>b&&f[p.type]){if(t=f[p.type](p.type,y,q,v,x)){v++;w=t;q=q-1}else{v=0}}else{v=0}return m.get(scriptname+"u2f/"+o+"?"+w).then(function(A){var D,B,z,E,C;D=A.data;if(D.result){C=D.values;for(B=0,z=C.length;B<z;B++){E=C[B];n++;E.id="node"+n;if(q<r.length-1){E.nodes=[];E.level=q+1;E.query=w;E.over=v;if(p.type.match(/^(?:start|update)Time$/)){E.title=E.value.replace(/^(\d{8})(\d{2})(\d{2})$/,"$2:$3").replace(/^(\d{8})(\d{2})(\d)$/,"$2:$30").replace(/^(\d{8})(\d{2})$/,"$2h").replace(/^(\d{4})(\d{2})(\d{2})/,"$1-$2-$3")}}s.push(E)}if(y===""){p.total=D.total}}return p.waiting=false},function(z){return p.waiting=false})};p.init=function(){p.waiting=true;p.data=[];return j.all([h.init(p.lang),p.updateTree("",p.data,0,0)]).then(function(){return p.waiting=false},function(q){return p.waiting=false})};l=i.path().match(/^\/(\w+)/);return p.type=l?l[1]:"_whatToTrace"}])}).call(this);
\ No newline at end of file
<TMPL_INCLUDE NAME="header.tpl">
<title>LemonLDAP::NG sessions explorer</title>
</head>
<body ng-app="llngSessionsExplorer" ng-controller="SessionsExplorerCtrl" ng-csp>
<TMPL_INCLUDE NAME="menubar.tpl">
<div id="content" class="row container-fluid">
<div id="pleaseWait" ng-show="waiting"><span trspan="waitingForDatas"></span></div>
<!-- Tree -->
<aside id="left" class="col-lg-4 col-md-4 col-sm-5 col-xs-12 scrollable " ng-class="{'hidden-xs':!showT}" role="complementary">
<div class="navbar navbar-default">
<div class="navbar-collapse">
<ul class="nav navbar-nav" role="grid">
<li><a id="a-persistent" role="row"><i class="glyphicon glyphicon-exclamation-sign"></i> {{translate('u2fSessions')}}</a></li>
</ul>
</div>
</div>
<div class="text-center"><p class="badge">{{total}} <span trspan="session_s"></span></p></div>
<div class="region region-sidebar-first">
<section id="block-superfish-1" class="block block-superfish clearfix">
<div ui-tree data-drag-enabled="false" id="tree-root">
<div ng-show="data.length==0" class="center">
<span class="label label-warning" trspan="noDatas"></span>
</div>
<ol ui-tree-nodes="" ng-model="data">
<li ng-repeat="node in data track by node.id" ui-tree-node ng-include="'nodes_renderer.html'" collapsed="true"></li>
</ol>
</div>
</section>
</div>
<div class="hresizer hidden-xs" resizer="vertical" resizer-left="#left" resizer-right="#right"></div>
</aside>
<!-- Right(main) div -->
<div id="right" class="col-lg-8 col-md-8 col-sm-7 col-xs-12 scrollable" ng-class="{'hidden-xs':showT&&!showM}">
<!-- Menu buttons -->
<div class="lmmenu navbar navbar-default" ng-class="{'hidden-xs':!showM}">
<div class="navbar-collapse" ng-class="{'collapse':!showM}" id="formmenu">
<ul class="nav navbar-nav">
<li ng-if="currentSession" ng-repeat="button in menu.addU2FKey" ng-include="'menubutton.html'"></li>
<li ng-if="currentSession" ng-repeat="button in menu.verifyU2FKey" ng-include="'menubutton.html'"></li>
<li ng-if="currentSession" ng-repeat="button in menu.delU2FKey" ng-include="'menubutton.html'"></li>
<li ng-if="currentSession===null" ng-repeat="button in menu.home" ng-include="'menubutton.html'"></li>
<li uib-dropdown class="visible-xs">
<a id="langmenu" name="menu" uib-dropdown-toggle data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Menu <span class="caret"></span></a>
<ul uib-dropdown-menu aria-labelled-by="langmenu" role="grid">
<li ng-repeat="link in links"><a href="{{link.target}}" role="row"><i ng-if="link.icon" class="glyphicon glyphicon-{{link.icon}}"></i> {{translate(link.title)}}</a></li>
<li ng-repeat="menulink in menulinks"><a href="{{menulink.target}}" role="row"><i ng-if="menulink.icon" class="glyphicon glyphicon-{{menulink.icon}}"></i> {{translate(menulink.title)}}</a></li>
<li ng-include="'languages.html'"></li>
</ul>
</li>
</ul>
</div>
</div>
<div class="panel panel-default" ng-hide="currentSession===null">
<div class="panel-heading">
<h1 class="panel-title text-center">{{translate("sessionTitle")}} {{currentSession.id}}</h1>
</div>
<div class="panel-body">
<div class="alert alert-info">
<strong>{{translate("sessionStartedAt")}}</strong>
{{localeDate(currentSession._utime)}}
</div>
<div ng-model="currentSession.nodes">
<div ng-repeat="node in currentSession.nodes" ng-include="'session_attr.html'"></div>
</div>
</div>
</div>
</div>
</div>
<script type="text/ng-template" id="session_attr.html">
<div class="panel panel-default" ng-if="node.nodes">
<div class="panel-heading">
<h2 class="panel-title text-center">{{translateP(node.title)}}</h2>
</div>
<table class="table table-striped" ng-model="node.nodes">
<tr ng-repeat="node in node.nodes" ng-include="'session_attr.html'"></tr>
</table>
</div>
<div ng-if="!node.nodes">
<th>{{translate(node.title)}}</th>
<td><tt>${{node.title}}</tt></td>
<td><span id="v-{{node.title}}">{{node.value}}</td>
</div>
</script>
<script type="text/ng-template" id="nodes_renderer.html">
<div ui-tree-handle class="tree-node tree-node-content panel-info" ng-class="{'bg-info':this.$modelValue===currentScope.$modelValue,'tree-node-default':this.$modelValue!==currentScope.$modelValue}">
<span ng-if="node.value">
<a id="a-{{node.value}}" class="btn btn-node btn-sm" ng-click="stoggle(this)">
<span class="glyphicon" ng-class="{'glyphicon-chevron-right': collapsed,'glyphicon-chevron-down': !collapsed}"></span>
</a>
<span id="s-{{node.value}}" ng-click="stoggle(this)">{{node.title || node.value}} <span class="badge">{{node.count}}</span></span>
</span>
<span ng-if="node.session">
<a class="btn btn-node btn-sm" ng-click="displaySession(this)">
<span class="glyphicon glyphicon-eye-open"></span>
</a>
<span id="s-{{node.session}}" ng-click="displaySession(this)">{{localeDate(node.date)}}</span>
</span>
</div>
<ol ui-tree-nodes="" ng-model="node.nodes" ng-class="{hidden: collapsed}">
<li ng-repeat="node in node.nodes track by node.id" ui-tree-node ng-include="'nodes_renderer.html'" collapsed="true"></li>
</ol>
</script>
<TMPL_INCLUDE NAME="scripts.tpl">
<!-- //if:jsminified
<script type="text/javascript" src="<TMPL_VAR NAME="STATIC_PREFIX">js/u2f.min.js"></script>
//else -->
<script type="text/javascript" src="<TMPL_VAR NAME="STATIC_PREFIX">js/u2f.js"></script>
<!-- //endif -->
<TMPL_INCLUDE NAME="footer.tpl">
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment