angular-ui / ui-router

The de-facto solution to flexible routing with nested views in AngularJS
http://ui-router.github.io/
MIT License
13.53k stars 3k forks source link

Preferred method for determining validity of a given state? #170

Closed bcamp1973 closed 11 years ago

bcamp1973 commented 11 years ago

Is there a preferred method for checking the validity of a state? I'm looking to do something like the following...

if( ! $state.valid() ){
    $state.transitionTo('error.404')
}
laurelnaiad commented 11 years ago

What would make $state.valid() true or false? If you're looking to do a redirect when someone tries to visit a URL that isn't handled, you can do $urlRouterProvider.otherwise...

bcamp1973 commented 11 years ago

So, is it really as simple as...

$urlRouteProvider.otherwise(
    $state.transitionTo('error.404')
)

When I try injecting $urlRouteProvider i get the standard unknown provider error...

laurelnaiad commented 11 years ago

I think you need to inject $state but otherwise yes. I just pass the url of my falllback state as a string.

E.g. .otherwise('/404') and .state('error.404', {url: '/404', ....})

laurelnaiad commented 11 years ago

Using urlrouterprovider: https://github.com/angular-ui/ui-router/blob/master/sample/index.html

ksperling commented 11 years ago

Note it's urlRoute_r_Provider

bcamp1973 commented 11 years ago

ugh, thanks for correcting my spelling @ksperling. That made a difference ;) @stu-salsbury, i set up a state as you suggested and it's almost working. I'm actually getting different behavior in different browsers. I think my approach is wrong and I'm stepping on the routing. Just not sure how to sort it out. I've broken the app up into modules, each of which defines it's own states as outlined in the FAQ. My problem is I need to transition based on whether the user is authorized/unauthorized. Here's my basic approach. Am I way off?

/*
  Main application module
*/
angular
  .module('applicationModule', [
    'unauthorizedModule',
    'authorizedModule',
    'errorsModule',
    'dashboardModule',
    'loginModule',
    'sessionsService',
    'ui.state',
    ...
  ])
  .config(function($urlRouterProvider) {
    // Handle bad URLs
    $urlRouterProvider.otherwise('/errors/404');
  })
  .controller('applicationController', function($rootScope, $state, sessionsService) {
    // Redirect unauthorized users to login when they
    // attempt to access a secure page
    $rootScope.$on('$stateChangeStart', function() {
      if (!sessionsService.currentUser() && $state.includes('authorized')) {
        $state.transitionTo('unauthorized.login');
      }
    });
  })
  .run(function(sessionsService, $state, $rootScope) {
    // Send user to dashboard on app initialization
    // if authorized, otherwise to login
    if ($state.current.url === '^') {
      if (sessionsService.currentUser()) {
        $state.transitionTo('authorized.dashboard');
      } else {
        $state.transitionTo('unauthorized.login');
      }
    }
  });
/*
  Errors module
*/
angular
  .module('errorsModule', ['ui.state'])
  .config(function($stateProvider) {
    $stateProvider
      .state('errors', {
        abstract: true,
        templateUrl: '/templates/errors/errors.tpl.html'
      })
      .state('errors.404', {
        url: '/errors/404',
        templateUrl: '/templates/errors/404.tpl.html'
      });
    }
  );
laurelnaiad commented 11 years ago

Disclaimer: not a developer on this project, not a real Angular expert, so take me with a heavy dose of salt! :)

Could you set up the on $stateChangeStart listener in module.run instead of a controller?

Is the root state really an "authorized" page (i.e. do you really want to redirect to login from the home page if the user isn't logged in?).

You might want/need to do event.preventDefault() on $stateChangeStart if you want to redirect to the login page with $state.transitionTo. This is the $state code that deals with it:

if ($rootScope.$broadcast('$stateChangeStart', to.self, toParams, from.self, fromParams)
          .defaultPrevented) return TransitionPrevented;

I think you'll be wanting to examine the "to" state in the event, rather than using the $state service to determine where the user is trying to go.

To sum up, what if you did this in module.run instead of the stuff in the controller:

.run(function(sessionsService, $state, $rootScope) {
  $rootScope.$on('$stateChangeStart', function(evt, toState, toParams, fromState, fromParams) {
    if (!sessionsService.currentUser() && toState.includes('authorized')) {
      evt.preventDefault();
      $state.transitionTo('unauthorized.login');
    }
  });
});

Another disclaimer: I'm not sure this will work! There may be an issue with timing of cancelling the transition and then immediately trying to re-transition. Also don't know for sure that you'll get the event on the first page load (but I think so). It's conceivable that you'll need to inject your services into the listener callback function. I just haven't tried it.

One thought: it would be nice to find a way to keep the original state that the user wanted to visit hanging around somewhere, so that you can forward them there after logging in.... but first things first.

ksperling commented 11 years ago

The other option for doing login on demand is to define a function along the lines of

function authorized($q, $rootScope) {
  if (!$rootScope.login) {
    var deferred = $rootScope.loginPending = $q.defer();
    $rootScope.login = deferred.promise.then(function (result) {
      $rootScope.loginPending = null;
    }, function (reason) {
      $rootScope.loginPending = null;
      throw reason;
    });
  }
  return $rootScope.login;
}

and then reference the from the 'resolve' of all states that require the user to be logged in.

You'd need a controller in your top-level HTML that watches for the loginPending value and shows the login dialog when it exists. When the user logs in successfully, it calls $rootScope.loginPending.resolve(true), unblocking the state transition that was waiting for the user to become authorized.

laurelnaiad commented 11 years ago

Long aside:

I'm always troubled by the fact that this client-side "security" is only one debugger command away from gone. It's obviously useful for managing the workflow, though.

If your routes use a templateUrl or any other server-stored, on-demand resource (such as those lazy loaded in the resolve function) -- basically anything that is retrieved using $http, then this project is really handy: https://github.com/witoldsz/angular-http-auth

If resource(s) is (are) fetced with $http and your server can respond with 401s, then it's a neat way to bring the user to a login screen and then take up where they left off upon authentication/authorization. I'm hoping 403s, server payloads and cancelling will be supported soon, too. To be really thorough, it would be worth thinking about how/if to un-cache resources for authorized states when someone logs out... perhaps just dump the whole cache... but anyway....

So the reason for the aside: @ksperling: would it be possible to execute a transition within the context of another transition? In other words, to leave the original transition hanging waiting for resolution while the login process (and the routing that it needs to do) goes on, then take up where it left off when the "authorized" promise is resolved? Kind of an analog to the http-auth technique, but for the router?

laurelnaiad commented 11 years ago

I happened to be looking at the transitionTo() code... it looks like

            if ($state.transition !== transition) {
              return TransitionSuperseded;
            }

might get in the way of sneaking in transitions during resolve... but it does look like you could potentially grab the toState and toParams in $routeChangeStart, then prevent the transition, then re-invoke the transition after doing the login procedure -- maybe.

ksperling commented 11 years ago

@stu-salsbury yeah that code is explicitly there so that if you "change your mind" while a transition is in progress, the last call to $state.transitionTo() wins (rather than the one that took the longest to complete).

Grabbing the state and params from the $stateChangeStart event definitely sounds feasible.

bcamp1973 commented 11 years ago

Thanks @stu-salsbury, your approach seems to work. I'll take a look at the angular-http-auth project as well. I agree not having true security on the front end is a bit unsettling. Fortunately, this app is a rewrite and the back end has been thoroughly pen tested in it's previous incarnation, so we have a pretty secure foundation to work from. @ksperling, your solution is very intriguing. I'm having a little trouble envisioning it's implementation though. More an issue with my imagination than your description I'm sure ;) Our app is designed such that the login page is the initial destination (enterprise app) and the only thing you see before logging in so it's "page" of its own and not a widget within other views. Not sure if that matters?

laurelnaiad commented 11 years ago

@bcamp1973 : You say that the initial destination should be the login page... as in, "everyone who uses this app should really log in before they get going", if I understand correctly. So the only pages that shouldn't require a login to already have happened are the ones related to the login workflow.

If so, you might be able to reverse the logic to keep your state definitions cleaner. As in: mark only those states that do not require login as being transitionable-to without having a session. In this example, it would be a "supplemental" property on the states that are part of the login process called userNotRequired:

.run(function(sessionsService, $state, $rootScope) {
  $rootScope.$on('$stateChangeStart', function(evt, toState, toParams, fromState, fromParams) {
    if (!sessionsService.currentUser() && !toState.userNotRequired)) {
      evt.preventDefault();
      $state.transitionTo('unauthorized.login');
    }
  });
});

If that code is in force, then any state which doesn't explicitly have the userNotRequired property as true-ish would force the login -- it might clean up the code a bit.

Switching gears: the method I was poking at above -- grabbing toState and toParams from the $routeChangeStart event -- would be a different way to accomplish the goal of bringing the user to the page they initially requested after the login process interrupts that request.

What angular-http-auth does is to store the requests that aren't fullfillable prior to login in a buffer, and then retry them if login succeeds.

What I was thinking you (and I, when I get to it) could do, is something similar on the routing level -- trap the state and stateParams that someone is trying to access. It's an alternative to @ksperling's idea which avoids having a watching controller in favor of wrapping the goal into a promise that will be resolved if the user successfully logs in.

I can't say one approach or the other is better. The one I'm envisioning has a promise hanging out in "midair" during the login process, the other has a watcher at a high scope level. Without digging into the code more closely, it's hard to say whether my idea would fly, whereas I'm pretty sure @ksperling 's would, if for no other reason, he wrote ui-router and clearly knows what he's doing! :)

All that said, it seems like this would be a really common use case. I wonder what, if anything, could be done at the ui-router level to make this easy (or easier)?

laurelnaiad commented 11 years ago

Here's what I'm thinking....really off the cuff... shoot me down at will... just a gist :)

.state('someConditionallyRoutableState', {
    conditions: [
      ['$someService', function($someService) { return $someService.itsOkayNow(); } ],
      [$noIMeanReallyOk, function($noIMeanReallyOk) { return $noIMeanReallyOk.itsReallyOk(); }]
    }.
    url: 'blahBlah'
    //etc.
  }
)

Prior to fulfilling the routing request, the $state service would check the conditions, each of which is wrapped in a promise. If they happen to all return true, then the routing would proceed.

In most cases, the conditions would actually do some state transitioning in order to get the user logged in, but there may be other conditions that don't require state changes.

If any one of the conditions throws an error or its promise is rejected, the $state provider abandons the request. If they all return true-ish, the stateTransition moves forward.

This logic would need to be isolated from the TransitionSuperceded test -- the service would pretend the sub-routing of doing the login never happened (i.e. the from and to states and their parameters would still be valid by the time the actual requested state was transition-to.

bcamp1973 commented 11 years ago

I really like that concept. I don't have the chops or familiarity with ui-router to contribute much, but I would definitely find use cases for it!

bcamp1973 commented 11 years ago

@stu-salsbury, I took your advice and turned my approach on it's head. Now I use a noAuthRequired parameter on my login $state and it works great! However, I'm still struggling with my initial problem routing bad urls to an error $state. When I go to the root url (http://example.com). I'm immediately redirected to my error $state (http://example.com/#/error), instead of being redirected to my login or dashboard state (both http://example.com/#/). I'm assuming that the $urlRouterProvider.otherwise() in the config is executing before the $state.transitionTo() method in the run method? I created a plunk to demonstrate the problem, but there it works great!?

angular
    .module('app',[...])
    .controller('appController',function($rootScope){...})
    .config(function($stateProvider,$urlRouterProvider){
        // 404 for bad URLs
        // immediately fires on root access (http://example.com)
        $urlRouterProvider.otherwise('/404');
    })
    .run(function($rootScope,$state){
        // Redirect from root to proper state
        // ignored on root access??
        if($state.current.url === '^'){
            $state.transitionTo($rootScope.authorized ? 'authorized' : 'login');
        }
        // Protect "secure pages"
        $rootScope.$on('$stateChangeStart', function(evt, toState, toParams, fromState, fromParams) {
            if( !toState.authNotRequired && !$rootScope.authorized ){
                evt.preventDefault();
                $state.transitionTo('login');
            }
        });
    });
laurelnaiad commented 11 years ago

The first thing I notice in your plunker is that you're defining two states with the same URL: authorized and login. You probably don't want to do that -- I changed the url for login to '/login'.

Also, I don't think you need or want this code, so I commented it out (the $stateChangeStart event listener takes care of this for you:

        // Redirect from root to proper state
        if($state.current.url === '^'){
            $state.transitionTo($rootScope.authorized ? 'authorized' : 'login');
        }

Finally, I've found that this helps with the root state (empty url -> url with just a slash):

        $urlRouterProvider.when('', '/');

Here's a new plunker with those changes: http://plnkr.co/edit/51AOWo14uCnggjhKMzA7?p=preview . Does that work the way you hoped?

bcamp1973 commented 11 years ago

This is killing me. Your plunk works perfectly. Exactly what I'm looking for. However, when I mirror the very same settings in my local development environment, accessing the root URL still transitions to my error page. I've double checked and I don't have transitions or redirects anywhere else that would override these settings.

laurelnaiad commented 11 years ago

Might be time for the debugger of your choice and stepping through what's happening if you can't plunk the problem! :)

ksperling commented 11 years ago

@bcamp1973 try dropping some breakpoints into the URLRouter code inside ui-router. You should see it iterating through all the url matching rules until one matches. $stateProvider adds one for you automatically when a state has a .url property (these rules just call transitionTo()), and of course any .when calls on $urlRouterProvider add that rule as well.

Note that rule order matters: rules are tested in the order they are defined in, and the first match wins.

I'm pretty sure $location fires off a locationChanged event when the app starts up, so the URL router will kick in at startup -- so trying to do a transition inside run() is probably not a good idea.

I wonder if UrlRouter should do some magic to treat the empty URL as '/' internally -- it's really somewhat counter intuitive that these aren't the same thing in legacy/hash mode. I don't think you should ever see an empty URL in html5mode.

timkindberg commented 11 years ago

@ksperling I was just thinking about the magic. It's not very clear how to set the initial state of an application.

timkindberg commented 11 years ago

Wanted to mention that when I used "/" in a state in the quick start plunker, it did not work as the "initial state". I had to use "" (empty string).

bcamp1973 commented 11 years ago

I did some poking around in Safari/Chrome dev tools. The root URL is being transitioned directly to the error $state, bypassing my $stateChangeStart handler altogether. @ksperling, you mention that doing the transition inside run() isn't the best approach. I initially tried to accomplish this in in config() but didn't have any luck. I remember something about the configuration stage not having access to certain assets, but I'm not having any luck finding those details in the Angular docs at the moment. Might be time to rethink my approach :P

laurelnaiad commented 11 years ago

The root URL is being transitioned directly to the error $state

I don't think the $state service does anything until module.run has been run. In the plunker http://plnkr.co/edit/51AOWo14uCnggjhKMzA7?p=preview the listener is set up in run, but doesn't fire until the state change begins. When your $state service is starting up, it's seeing a URL it doesn't know what to do with.

Not to be a pest, but are you sure you have something like this defined?

        $urlRouterProvider.when('', '/');

in addition to a valid state at url "/"?

What is the URL that you're trying to visit? http://myhost.com ? or http://myhost.com/ ? Or something else?

bcamp1973 commented 11 years ago

Thanks @stu-salsbury! You're not being a pest at all. You've been extremely helpful. You're suggestion to recheck the "/" state lead me to a typo. It's working now...finally! I feel like an idiot for not seeing it myself, but sometimes that comes from staring at something too long ;) Sorry to drag everyone along for so long as I worked through it :-(

jcumminsr1 commented 11 years ago

Thanks @stu-salsbury, just came across this same problem and your comment helped me out. Much appreciated!

yevcher commented 11 years ago

Thanks @stu-salsbury for plunker with a nice example. It runs well. But I am getting into a trouble when changing ui-router version. In your code you used 0.0.2 version. When I change it to later 0.2.0 version, the code does not run. To demostrate the issue, I cloned your code and introduced just one change - link to newer ui-router. Could you please advise? Thanks. http://plnkr.co/edit/Kb5GI1VOIr8848zLR7wh?p=preview

laurelnaiad commented 11 years ago

@yevcher what's not working?

yevcher commented 11 years ago

@stu-salsbury I just did not save the change I was talking about in my previous post. Sorry about that. Now I have saved it and you can see the error in my plunker. Thanks.

laurelnaiad commented 11 years ago

If you look in your browser's debugger, you should see uncaught Error: No module: ui.state.

This was a breaking change in v0.2.0. The module is now named ui.router.

This plunk just updates the module name in your dependencies: http://plnkr.co/edit/zA4JXyTsS1oKfrPrYH7u?p=preview

yevcher commented 11 years ago

@stu-salsbury Jesus! I should notice this myself. Thanks a lot.

rvanbaalen commented 10 years ago

@ksperling Life saver. I was going over my code over and over again and then I read your comment: urlRoute_r_Provider. I missed the R.