fix: Bug - No login screen mobile phone on startup

Signed-out mobile visitors landing on / or /dashboard were intentionally
kept on a logged-out /dashboard, so they were never greeted with a login
screen on startup. Replace the imperative startup-redirect logic in
App.ngOnInit with a platform-agnostic pure rule
resolveUnauthenticatedStartupRedirect: non-public routes redirect to
/login (with a safe returnUrl), public routes (/login, /register,
/invite/...) are left alone. Mobile is no longer special-cased.

- Unit: auth-navigation.rules.spec.ts
- E2E: e2e/tests/mobile/mobile-login-on-startup.spec.ts (mobile viewport
  set before navigation; /dashboard and / both land on /login)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-11 03:38:16 +02:00
parent 182828bb1e
commit cb386394d0
6 changed files with 123 additions and 23 deletions

View File

@@ -58,7 +58,7 @@ import { RoomsActions } from './store/rooms/rooms.actions';
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
import { ROOM_URL_PATTERN } from './core/constants';
import { clearStoredCurrentUserId, getStoredCurrentUserId } from './core/storage/current-user-storage';
import { buildLoginReturnQueryParams } from './domains/authentication/domain/logic/auth-navigation.rules';
import { resolveUnauthenticatedStartupRedirect } from './domains/authentication/domain/logic/auth-navigation.rules';
import { runWhenIdle } from './shared/rxjs';
import {
ThemeNodeDirective,
@@ -308,21 +308,16 @@ export class App implements OnInit, OnDestroy {
const currentUrl = this.getCurrentRouteUrl();
if (!currentUserId) {
if (!this.isPublicRoute(currentUrl)) {
// On mobile, new/unauthenticated visitors landing on the app root or
// /dashboard should stay on /dashboard (which already exposes a login
// CTA). The login form has no mobile chrome / back button, so dropping
// new users straight onto it leaves them with no way to navigate away.
const currentPath = this.getRoutePath(currentUrl);
const isSearchLanding = currentPath === '/' || currentPath === '/dashboard';
// Signed-out visitors are greeted with the login screen on every platform.
// Mobile is intentionally not special-cased here: previously mobile users
// landing on the root or /dashboard were left on a logged-out dashboard,
// which read as "no login screen on startup".
const startupRedirect = resolveUnauthenticatedStartupRedirect(currentUrl);
if (this.isMobile() && isSearchLanding) {
this.router.navigate(['/dashboard'], { replaceUrl: true }).catch(() => {});
} else {
this.router.navigate(['/login'], {
queryParams: buildLoginReturnQueryParams(currentUrl)
}).catch(() => {});
}
if (startupRedirect) {
this.router.navigate([startupRedirect.path], {
queryParams: startupRedirect.queryParams
}).catch(() => {});
}
} else {
this.store.dispatch(UsersActions.loadCurrentUser());
@@ -483,14 +478,6 @@ export class App implements OnInit, OnDestroy {
});
}
private isPublicRoute(url: string): boolean {
const path = this.getRoutePath(url);
return path === '/login' ||
path === '/register' ||
path.startsWith('/invite/');
}
private getCurrentRouteUrl(): string {
if (typeof window === 'undefined') {
return this.router.url;

View File

@@ -9,6 +9,7 @@ import { UsersActions } from '../../../../store/users/users.actions';
import {
buildLoginReturnQueryParams,
resolveSafeReturnUrl,
resolveUnauthenticatedStartupRedirect,
waitForAuthenticationOutcome
} from './auth-navigation.rules';
@@ -55,6 +56,39 @@ describe('buildLoginReturnQueryParams', () => {
});
});
describe('resolveUnauthenticatedStartupRedirect', () => {
it('sends signed-out visitors on the dashboard/root to login (no mobile exception)', () => {
expect(resolveUnauthenticatedStartupRedirect('/dashboard')).toEqual({
path: '/login',
queryParams: {}
});
expect(resolveUnauthenticatedStartupRedirect('/')).toEqual({
path: '/login',
queryParams: { returnUrl: '/' }
});
});
it('preserves a safe returnUrl when redirecting from a protected route', () => {
expect(resolveUnauthenticatedStartupRedirect('/servers')).toEqual({
path: '/login',
queryParams: { returnUrl: '/servers' }
});
expect(resolveUnauthenticatedStartupRedirect('/room/abc')).toEqual({
path: '/login',
queryParams: { returnUrl: '/room/abc' }
});
});
it('leaves public auth/invite routes untouched', () => {
expect(resolveUnauthenticatedStartupRedirect('/login')).toBeNull();
expect(resolveUnauthenticatedStartupRedirect('/login?returnUrl=%2Fservers')).toBeNull();
expect(resolveUnauthenticatedStartupRedirect('/register')).toBeNull();
expect(resolveUnauthenticatedStartupRedirect('/invite/abc123')).toBeNull();
});
});
describe('waitForAuthenticationOutcome', () => {
it('resolves when authentication storage preparation succeeds', async () => {
const user = {

View File

@@ -18,10 +18,41 @@ export type AuthenticationOutcome =
| { kind: 'success'; user: User }
| { kind: 'failure'; error: string };
export interface UnauthenticatedStartupRedirect {
path: string;
queryParams: Record<string, string>;
}
export function isAuthRoutePath(path: string): boolean {
return AUTH_ROUTE_PATHS.has(path);
}
/** Routes a signed-out visitor may stay on without being bounced to login. */
export function isPublicStartupUrl(url: string): boolean {
const path = getRoutePathFromUrl(url);
return isAuthRoutePath(path) || path.startsWith('/invite/');
}
/**
* Decides where an unauthenticated visitor should land at startup. Signed-out
* users are always sent to `/login` (regardless of viewport) so mobile users are
* greeted with the login screen instead of a logged-out dashboard. Public routes
* (`/login`, `/register`, `/invite/...`) are left untouched (return `null`).
*/
export function resolveUnauthenticatedStartupRedirect(
currentUrl: string
): UnauthenticatedStartupRedirect | null {
if (isPublicStartupUrl(currentUrl)) {
return null;
}
return {
path: '/login',
queryParams: buildLoginReturnQueryParams(currentUrl)
};
}
export function getRoutePathFromUrl(url: string): string {
if (!url) {
return '/';