AngularJS で、よくあるログインのようなフローを作ってみました。
- 未ログインならログインページを表示する
- 他の任意のURLはログインページへリダイレクトする
- ログイン済みでログインページを表示しようとしたときはトップページにリダイレクトする
- ナビゲーションの表示をログイン状態に応じて変化させる
index.html
最低限の見た目のために Bootstrap を使っていますが、Bootstrap の CSS で !important
な display:block
が指定されているところがあり、ngCloak や ngShow が効かなくなることがあったのでそれらを上書きするための CSS を html 文中に書いています。
<!doctype html>
<html ng-app="App">
<head>
<meta charset="utf-8">
<title>AngularJS Example</title>
<link rel="stylesheet" href="lib/bootstrap/dist/css/bootstrap.css"/>
<style>
#body [ng-cloak],
#body .ng-hide {
display: none !important;
}
</style>
</head>
<body id="body">
<nav class="navbar navbar-default" ng-controller="NavCtrl">
<div class="container-fluid">
<div class="navbar-header">
<a class="navbar-brand" href="#/">AngularJS Example</a>
</div>
<div class="collapse navbar-collapse" ng-cloak ng-show="user">
<ul class="nav navbar-nav">
<li><a href="#/">Home</a></li>
<li><a href="#/page1">Page1</a></li>
<li><a href="#/page2">Page2</a></li>
</ul>
<form class="navbar-form navbar-right">
<button type="button" class="btn btn-default" ng-click="logout()">Logout</button>
</form>
<div class="navbar-text navbar-right">
{{user.username}}
</div>
</div>
</div>
</nav>
<div class="container-fluid">
<div ng-view></div>
</div>
<script src="lib/jquery/dist/jquery.js"></script>
<script src="lib/angular/angular.js"></script>
<script src="lib/angular-route/angular-route.js"></script>
<script src="js/app.js"></script>
</body>
</html>
body にはまず最初にナビゲーションがあります。
ナビゲーションはログインの前後で表示する内容が異なるため NavCtrl コントローラーで制御します。
ログイン後であれば、各ページヘのリンク、ユーザー名、ログアウトボタン、などを表示します。
未ログインであればそれらが表示されないように ng-show
を利用しています。
ナビゲーションの次に ngView があります。
ここには未ログインであればログインページを表示し、ログイン済みならなにかしらのコンテンツを表示します。
login.html
ログインページのテンプレートです。
<form class="form-inline" ng-submit="login()">
<input type="text" class="form-control" placeholder="username" ng-model="username">
<input type="password" class="form-control" placeholder="password" ng-model="password">
<button type="submit" class="btn btn-default" ng-disabled="disabled">Login</button>
<span class="text-danger" ng-show="alert">{{alert.msg}}</span>
</form>
ルーティング
js でルーティングを定義します。otherwise を除いて全部で4つのルートを定義しています。
/login がログインページのルート、その他の3つはログイン後のページです。
ログインページ以外のルートはコントローラーやテンプレートを別に用意するのが面倒なので手抜きです。
var app = angular.module('App', ['ngRoute']);
app.config(function($routeProvider){
$routeProvider.when('/', {template: '<h1>home</h1>', controller: function(){}});
$routeProvider.when('/page1', {template: '<h1>page1</h1>', controller: function(){}});
$routeProvider.when('/page2', {template: '<h1>page2</h1>', controller: function(){}});
$routeProvider.when('/login', {templateUrl: 'view/login.html', controller: 'LoginCtrl'});
$routeProvider.otherwise({redirectTo: '/'});
});
認証
認証のためのサービスを定義します。
認証はユーザー名とパスワードに同じ値を入れれば通ります。というか、なにも入力しなくても通ります。
本当に実装するときは非同期の問合せになるので、500 ミリ秒遅延する Promise を返しています。
app.factory('AuthService', function($q, $timeout){
var _user = null;
return {
isLogged: function(){ return !!_user; },
getUser: function(){ return _user; },
login: function(username, password){
var deferred = $q.defer();
$timeout(function(){
if (username == password)
{
_user = {username: username};
deferred.resolve();
}
else
{
deferred.reject();
}
}, 500);
return deferred.promise;
},
logout: function(){
_user = null;
return $q.all();
}
};
});
$routeChangeStart イベント
次にモジュールの run で $rootScope
に $routeChangeStart
イベントをバインドします。
このイベントは ngRoute のルーティングの開始時に呼ばれます。
これから表示しようとしているルートのコントローラーが LoginCtrl であるかどうか、ログイン済みであるかどうか、を元に必要に応じて適切なルートにリダイレクトします。
app.run(function($rootScope, $location, $route, AuthService){
$rootScope.$on('$routeChangeStart', function(ev, next, current){
if (next.controller == 'LoginCtrl')
{
if (AuthService.isLogged())
{
$location.path('/');
$route.reload();
}
}
else
{
if (AuthService.isLogged() == false)
{
$location.path('/login');
$route.reload();
}
}
});
});
ログインコントローラー
ログインコントローラーを作ります。
AuthService.login は Promise を返すので、成功時はトップにリダイレクト、失敗時はメッセージを表示します。
さらに失敗/成功に関わらずフォームをクリアしていますが、よく考えると成功時はこの処理必要ありませんでした。
app.controller('LoginCtrl', function($scope, $location, AuthService){
$scope.login = function(){
$scope.disabled = true;
AuthService.login($scope.username, $scope.password)
.then(function(){
$location.path('/');
})
.catch(function(){
$scope.alert = {msg: "Login failed"};
})
.finally(function(){
$scope.username = "";
$scope.password = "";
$scope.disabled = false;
})
;
};
});
ナビコントローラー
最後にナビコントローラーを作ります。
ログイン状態は AuthService サービスが持っているので $watch
で監視してナビコントローラーのスコープに設定します。
また、ログアウトでログインページにリダイレクトします。
app.controller('NavCtrl', function($scope, $location, AuthService) {
$scope.$watch(
function(){ return AuthService.getUser() },
function(newVal, oldVal){ $scope.user = newVal }
);
$scope.logout = function () {
AuthService.logout().finally(function(){ $location.path('/login') });
};
});
問題点?
"/" のルートに home.html などのテンプレートを使うと判るのですが、一番最初にサイトに訪れてログインページが表示されるとき、home.html がダウンロードされています。
$routeChangeStart
イベントが呼ばれる時点ではまだテンプレートのダウンロードは開始されていないのですが、そのタイミングで $location.path()
で遷移させてもテンプレートのダウンロードは止められないようです。
特に何が困るわけでもありませんが、なんとなく気持ち悪いです。
$routeProvider の resolve での実装
$routeChangeStart イベントは使わずに(app.run をごっそり削除)して、ルーティングを次のようにする方法も考えられます。
app.config(function($routeProvider){
var requireAuth = {
login: function($q, $location, AuthService){
if (AuthService.isLogged() == false)
{
$location.path('/login');
return $q.reject();
}
}
};
var skipLogin = {
login: function($q, $location, AuthService){
if (AuthService.isLogged())
{
$location.path('/');
return $q.reject();
}
}
};
$routeProvider.when('/', {
template: '<h1>home</h1>',
controller: function(){},
resolve: requireAuth
});
$routeProvider.when('/page1', {
template: '<h1>page1</h1>',
controller: function(){},
resolve: requireAuth
});
$routeProvider.when('/page2', {
template: '<h1>page2</h1>',
controller: function(){},
resolve: requireAuth
});
$routeProvider.when('/login', {
templateUrl: 'view/login.html',
controller: 'LoginCtrl',
resolve: skipLogin
});
$routeProvider.otherwise({
redirectTo: '/'
});
});
ログイン時にルーティングを再設定する実装
あるいは、ログアウトは実際にブラウザをリロードするという前提で、ログイン後にルーティングを再設定する方法も考えられます。
app.config(function($routeProvider){
// 最初は otherwise な LoginCtrl のルートしかない
$routeProvider.otherwise({
templateUrl: 'view/login.html',
controller: 'LoginCtrl',
resolve: {
login: function($route, AuthService){
if (AuthService.isLogged())
{
// ログインに成功したらルートを追加&上書きする
$routeProvider.when('/', {
template: '<h1>home</h1>', controller: function(){}
});
$routeProvider.when('/page1', {
template: '<h1>page1</h1>', controller: function(){}
});
$routeProvider.when('/page2', {
template: '<h1>page2</h1>', controller: function(){}
});
$routeProvider.otherwise({
redirectTo: '/'
});
$route.reload();
}
}
}
});
});
// 不要
// app.run();
app.controller('LoginCtrl', function($scope, $route, AuthService){
$scope.login = function(){
$scope.disabled = true;
AuthService.login($scope.username, $scope.password)
.then(function(){
// ログインに成功したらルーティングを呼ぶ
$route.reload();
})
.catch(function(){
$scope.alert = {msg: "Login failed"};
})
.finally(function(){
$scope.username = "";
$scope.password = "";
$scope.disabled = false;
})
;
};
});
app.controller('NavCtrl', function($scope, $window, AuthService) {
$scope.$watch(
function(){ return AuthService.getUser() },
function(newVal, oldVal){ $scope.user = newVal }
);
$scope.logout = function () {
AuthService.logout().finally(function(){
// ログアウトはブラウザのリロード
// ($routeProvider に追加したルートをクリアする他の方法がわからない)
$window.location.reload();
});
};
});
この方法なら /page1
とかをダイレクトに開いた場合でも、ログインページを挟んだあとに /page1
を表示することが出来ます。