From 457581f1f9f8268d8c10d6ee18e4ecd0929903c4 Mon Sep 17 00:00:00 2001
From: "@mrubens" <2600+mrubens@users.noreply.github.com>
Date: Mon, 7 Sep 2026 02:55:38 +0000
Subject: [PATCH 1/2] fix: renew web sessions reliably and improve sign-in
recovery
---
apps/docs/users.mdx | 17 ++
.../auth-form.client.test.tsx | 105 +++++++-
.../src/app/(unauthenticated)/auth-form.tsx | 40 +--
.../(unauthenticated)/email-password-auth.tsx | 3 +-
.../reset-password/page.client.test.tsx | 56 +++++
.../reset-password/page.client.tsx | 17 +-
.../api/trpc/[trpc]/__tests__/route.test.ts | 8 +
apps/web/src/app/api/trpc/[trpc]/route.ts | 2 +-
apps/web/src/lib/auth-redirect.ts | 17 ++
apps/web/src/lib/server/auth-context.test.ts | 16 ++
apps/web/src/lib/server/auth-context.ts | 17 +-
.../server/auth-session.integration.test.ts | 238 ++++++++++++++++++
apps/web/src/lib/server/auth.test.ts | 13 +
apps/web/src/lib/server/auth.ts | 7 +-
.../__tests__/procedure-timing-wiring.test.ts | 16 +-
apps/web/src/trpc/init.ts | 6 +-
16 files changed, 542 insertions(+), 36 deletions(-)
create mode 100644 apps/web/src/lib/server/auth-session.integration.test.ts
diff --git a/apps/docs/users.mdx b/apps/docs/users.mdx
index 0a91fa485..8b7b9f8d3 100644
--- a/apps/docs/users.mdx
+++ b/apps/docs/users.mdx
@@ -60,6 +60,23 @@ Operators can also configure an email allowlist for a self-hosted deployment.
When that allowlist is active, a user still needs to pass the normal sign-in
rules and have an allowed email address.
+## Staying signed in
+
+Web sign-in sessions last 30 days from sign-in or the last renewal. While you
+use the web app, eligible browser requests renew both the session and its
+cookie, at most once every 24 hours. Simply leaving a sleeping or closed browser
+open does not renew a session. After the session expires, sign in again.
+
+Signing out, removal by an admin, and password resets still revoke sessions.
+Clearing browser cookies or rotating the deployment's session-signing secret
+also requires signing in again. Sign out when using a shared device.
+
+If you are signed out, use your existing email/password credential or a
+configured Slack or Microsoft sign-in option. You do not need a new invite for
+an existing account. On the email form, **Other sign-in options** returns to
+the provider choices without losing your destination. If you forgot a local
+password, ask an admin for a [password reset link](#password-reset-flow).
+
## License and seats
A Roomote deployment is free for up to 10 users. Every registered user
diff --git a/apps/web/src/app/(unauthenticated)/auth-form.client.test.tsx b/apps/web/src/app/(unauthenticated)/auth-form.client.test.tsx
index 110ab4428..a8ec97821 100644
--- a/apps/web/src/app/(unauthenticated)/auth-form.client.test.tsx
+++ b/apps/web/src/app/(unauthenticated)/auth-form.client.test.tsx
@@ -1,10 +1,12 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
-const { replaceMock, refreshMock, signInOauth2Mock } = vi.hoisted(() => ({
- replaceMock: vi.fn(),
- refreshMock: vi.fn(),
- signInOauth2Mock: vi.fn(),
-}));
+const { replaceMock, refreshMock, signInOauth2Mock, signInEmailMock } =
+ vi.hoisted(() => ({
+ replaceMock: vi.fn(),
+ refreshMock: vi.fn(),
+ signInOauth2Mock: vi.fn(),
+ signInEmailMock: vi.fn(),
+ }));
let searchParams = new URLSearchParams();
@@ -20,6 +22,7 @@ vi.mock('@/lib/auth-client', () => ({
authClient: {
signIn: {
oauth2: signInOauth2Mock,
+ email: signInEmailMock,
},
},
}));
@@ -33,7 +36,9 @@ import { AuthForm } from './auth-form';
describe('AuthForm', () => {
beforeEach(() => {
+ vi.clearAllMocks();
searchParams = new URLSearchParams();
+ signInEmailMock.mockResolvedValue({ data: {}, error: null });
signInOauth2Mock.mockResolvedValue({
data: { url: 'https://oauth.example.com' },
error: null,
@@ -75,8 +80,17 @@ describe('AuthForm', () => {
});
});
- it('falls back to setup for unsafe redirect URLs', async () => {
- searchParams = new URLSearchParams('redirect_url=https://example.com');
+ it.each([
+ 'https://example.com',
+ '//example.com',
+ '/\\example.com',
+ '/tasks\\mine',
+ '/\n/example.com',
+ '/tasks\t',
+ '/tasks\u0000',
+ '/tasks\u007f',
+ ])('falls back to setup for unsafe redirect URL %j', async (redirectUrl) => {
+ searchParams = new URLSearchParams({ redirect_url: redirectUrl });
render();
@@ -198,6 +212,82 @@ describe('AuthForm', () => {
).toBeVisible();
expect(screen.getByLabelText('Email')).toBeVisible();
expect(screen.getByLabelText('Password')).toBeVisible();
+ expect(
+ screen.queryByRole('button', { name: 'Other sign-in options' }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('returns from email to provider options without losing the redirect', async () => {
+ searchParams = new URLSearchParams({ redirect_url: '/tasks?view=mine' });
+ render();
+
+ fireEvent.click(
+ screen.getByRole('button', { name: 'Continue with email' }),
+ );
+ fireEvent.click(
+ screen.getByRole('button', { name: 'Other sign-in options' }),
+ );
+
+ expect(screen.queryByLabelText('Email')).not.toBeInTheDocument();
+ fireEvent.click(
+ screen.getByRole('button', { name: 'Continue with Microsoft Teams' }),
+ );
+ await waitFor(() => {
+ expect(signInOauth2Mock).toHaveBeenCalledWith({
+ providerId: 'microsoft-entra-id',
+ callbackURL: '/tasks?view=mine',
+ });
+ });
+ expect(searchParams.get('redirect_url')).toBe('/tasks?view=mine');
+ });
+
+ it('shows reset success and signs in with the new password at the return path', async () => {
+ searchParams = new URLSearchParams({
+ password_reset: '1',
+ invited: '1',
+ redirect_url: '/tasks?view=mine',
+ });
+ render();
+
+ expect(screen.getByRole('status')).toHaveTextContent(
+ 'Your password has been reset. Sign in with your new password.',
+ );
+ expect(screen.queryByLabelText('Name')).not.toBeInTheDocument();
+ fireEvent.change(screen.getByLabelText('Email'), {
+ target: { value: 'person@example.com' },
+ });
+ fireEvent.change(screen.getByLabelText('Password'), {
+ target: { value: 'new-password' },
+ });
+ fireEvent.click(screen.getByRole('button', { name: 'Sign in' }));
+ await waitFor(() => {
+ expect(signInEmailMock).toHaveBeenCalledWith({
+ email: 'person@example.com',
+ password: 'new-password',
+ callbackURL: '/tasks?view=mine',
+ });
+ expect(replaceMock).toHaveBeenCalledWith('/tasks?view=mine');
+ });
+ });
+
+ it('allows other sign-in options after a reset', () => {
+ searchParams = new URLSearchParams('password_reset=1');
+ render();
+ fireEvent.click(
+ screen.getByRole('button', { name: 'Other sign-in options' }),
+ );
+ expect(
+ screen.getByRole('button', { name: 'Continue with Slack' }),
+ ).toBeVisible();
+ });
+
+ it('does not show reset success for another marker value', () => {
+ searchParams = new URLSearchParams('password_reset=0');
+ render();
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Continue with email' }),
+ ).toBeVisible();
});
it('does not show Telegram as a sign-in provider', () => {
@@ -251,6 +341,7 @@ describe('AuthForm', () => {
screen.getByText(/Need an account\? Forgot your password\?/),
).toBeVisible();
expect(screen.getByText(/Ask your admin\./)).toBeVisible();
+ expect(screen.getByText(/Settings > Users/)).toBeVisible();
expect(screen.getByRole('button', { name: 'Talk to us' })).toBeVisible();
});
diff --git a/apps/web/src/app/(unauthenticated)/auth-form.tsx b/apps/web/src/app/(unauthenticated)/auth-form.tsx
index bc9273357..71c690f20 100644
--- a/apps/web/src/app/(unauthenticated)/auth-form.tsx
+++ b/apps/web/src/app/(unauthenticated)/auth-form.tsx
@@ -9,6 +9,7 @@ import {
} from '@roomote/types';
import { authClient } from '@/lib/auth-client';
+import { getSafeRedirectUrl } from '@/lib/auth-redirect';
import { getAuthProviderCallbackUrl } from '@/lib/auth-provider-callback';
import { cn } from '@/lib/utils';
import { OriginMismatchAlert } from '@/components/layout';
@@ -45,18 +46,6 @@ function AuthProviderIcon({ provider }: { provider: AuthProvider }) {
return ;
}
-function getSafeRedirectUrl(rawRedirectUrl: string | null): string {
- if (!rawRedirectUrl) {
- return '/setup';
- }
-
- if (!rawRedirectUrl.startsWith('/') || rawRedirectUrl.startsWith('//')) {
- return '/setup';
- }
-
- return rawRedirectUrl;
-}
-
function getAuthErrorMessage(
error: { message?: string } | null | undefined,
fallback: string,
@@ -93,6 +82,7 @@ export function AuthForm({
}) {
const router = useRouter();
const searchParams = useSearchParams();
+ const passwordReset = searchParams.get('password_reset') === '1';
const redirectUrl = useMemo(
() => getSafeRedirectUrl(searchParams.get('redirect_url')),
[searchParams],
@@ -107,7 +97,7 @@ export function AuthForm({
const hasVisibleProviders = visibleProviders.length > 0;
const [errorMessage, setErrorMessage] = useState(null);
- const [isEmailAuthVisible, setIsEmailAuthVisible] = useState(false);
+ const [isEmailAuthVisible, setIsEmailAuthVisible] = useState(passwordReset);
const [submittingProvider, setSubmittingProvider] =
useState(null);
const showEmailAuth = !hasVisibleProviders || isEmailAuthVisible;
@@ -161,6 +151,13 @@ export function AuthForm({
+ {passwordReset && (
+
+
+ Your password has been reset. Sign in with your new password.
+
+
+ )}
{noticeMessage && (
@@ -218,7 +215,7 @@ export function AuthForm({
) : null}
{showEmailAuth ? (
-
+
+ {hasVisibleProviders && (
+
+ )}
) : null}
diff --git a/apps/web/src/app/(unauthenticated)/email-password-auth.tsx b/apps/web/src/app/(unauthenticated)/email-password-auth.tsx
index 3c5fe5c95..143a3a5b7 100644
--- a/apps/web/src/app/(unauthenticated)/email-password-auth.tsx
+++ b/apps/web/src/app/(unauthenticated)/email-password-auth.tsx
@@ -201,7 +201,8 @@ export function EmailPasswordAuth({
Need an account? Forgot your password?
- Ask your admin.
+ Ask your admin. They can create a password reset link in Settings
+ > Users.
- {passwordReset && (
-
-
- Your password has been reset. Sign in with your new password.
-
-
- )}
{noticeMessage && (
@@ -215,7 +218,7 @@ export function AuthForm({
) : null}
{showEmailAuth ? (
-
+
- {hasVisibleProviders && (
-
- )}
) : null}
diff --git a/apps/web/src/app/(unauthenticated)/email-password-auth.tsx b/apps/web/src/app/(unauthenticated)/email-password-auth.tsx
index 143a3a5b7..3c5fe5c95 100644
--- a/apps/web/src/app/(unauthenticated)/email-password-auth.tsx
+++ b/apps/web/src/app/(unauthenticated)/email-password-auth.tsx
@@ -201,8 +201,7 @@ export function EmailPasswordAuth({
Need an account? Forgot your password?
- Ask your admin. They can create a password reset link in Settings
- > Users.
+ Ask your admin.