feat(auth): sign in through a trusted proxy and let those users set a password

On load, a signed-out visitor first asks the backend to sign them in as the
user an authenticating proxy vouches for, before guest mode or a login
redirect takes over; an explicit logout skips this for the rest of the tab.
Settings gains a password card for signed-in users, where a proxy-signed-in
user can set a password without the current one to log in without the
proxy.
This commit is contained in:
2026-10-12 01:05:40 -07:00
parent 057192be16
commit a6adc61627
11 changed files with 273 additions and 7 deletions
+10 -1
View File
@@ -21,6 +21,7 @@ import { useAuthStore } from './store/auth';
import { useServerStatusStore } from './store/serverStatus';
import { authSettingsApi, authApi } from './lib/api';
import { usePublicSettings } from './hooks/usePublicSettings';
import { useTrustedLogin } from './hooks/useTrustedLogin';
import { Toaster } from '@/components/ui/sonner';
import { TooltipProvider } from '@/components/ui/tooltip';
@@ -99,6 +100,8 @@ function App() {
const location = useLocation();
const { data: publicSettings } = usePublicSettings();
const trustedLoginChecked = useTrustedLogin();
// Check if system needs initial setup (no users exist)
const { data: setupStatus } = useQuery({
queryKey: ['setupStatus'],
@@ -137,6 +140,8 @@ function App() {
location.pathname === '/register' ||
location.pathname === '/setup';
if (!trustedLoginChecked) return;
if (isAuthPage && isGuest && !isAuthenticated) {
// Clear guest mode when navigating to auth pages
clearAuth();
@@ -144,7 +149,7 @@ function App() {
// Initialize guest mode for non-auth pages
setGuestMode();
}
}, [isAuthenticated, isGuest, location.pathname, setGuestMode, clearAuth]);
}, [isAuthenticated, isGuest, location.pathname, setGuestMode, clearAuth, trustedLoginChecked]);
// Update browser title from site name in public settings
useEffect(() => {
@@ -190,6 +195,10 @@ function App() {
);
}
if (!trustedLoginChecked) {
return <RouteLoadingFallback />;
}
return (
<ErrorBoundary>
<TooltipProvider delayDuration={300}>
@@ -0,0 +1,39 @@
import { useState } from 'react';
import { KeyRound } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ChangePasswordDialog } from '@/components/users/ChangePasswordDialog';
import { useAuthStore } from '@/store/auth';
/** Lets a signed-in user change their password, or set one to sign in without single sign-on. */
export function AccountPasswordCard() {
const user = useAuthStore((state) => state.user);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const isTrustedSession = useAuthStore((state) => state.isTrustedSession);
const [isOpen, setIsOpen] = useState(false);
if (!user || !isAuthenticated) {
return null;
}
return (
<div className="bg-card rounded-lg p-6 border border-border shadow-md">
<h2 className="text-xl font-semibold mb-2">Password</h2>
<p className="text-sm text-muted-foreground mb-4">
{isTrustedSession
? "You're signed in through single sign-on. Set a password to also sign in without it, such as on your local network."
: 'Change the password you sign in with.'}
</p>
<Button onClick={() => setIsOpen(true)}>
<KeyRound size={16} aria-hidden="true" />
{isTrustedSession ? 'Set Password' : 'Change Password'}
</Button>
<ChangePasswordDialog
isOpen={isOpen}
user={user}
currentPasswordOptional={isTrustedSession}
onClose={() => setIsOpen(false)}
onSuccess={() => setIsOpen(false)}
/>
</div>
);
}
@@ -0,0 +1,54 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ChangePasswordDialog } from './ChangePasswordDialog';
const mutateAsync = vi.hoisted(() => vi.fn());
vi.mock('@/hooks/useUsers', () => ({
useChangePassword: () => ({ mutateAsync, isError: false, isPending: false, error: null }),
}));
const user = { id: 7, username: 'brad' };
async function submit(newPassword: string) {
await userEvent.type(screen.getByLabelText('New Password'), newPassword);
await userEvent.type(screen.getByLabelText('Confirm New Password'), newPassword);
await userEvent.click(screen.getByRole('button', { name: 'Change Password' }));
}
describe('ChangePasswordDialog', () => {
beforeEach(() => {
mutateAsync.mockReset().mockResolvedValue(undefined);
});
it('requires the current password by default', async () => {
render(<ChangePasswordDialog isOpen user={user} onClose={vi.fn()} onSuccess={vi.fn()} />);
await submit('new-password');
expect(await screen.findByText('Current password is required')).toBeInTheDocument();
expect(mutateAsync).not.toHaveBeenCalled();
});
it('lets a single sign-on session set a password without the current one', async () => {
render(
<ChangePasswordDialog
isOpen
user={user}
currentPasswordOptional
onClose={vi.fn()}
onSuccess={vi.fn()}
/>
);
expect(screen.getByLabelText('Current Password (optional)')).toBeInTheDocument();
await submit('new-password');
await waitFor(() =>
expect(mutateAsync).toHaveBeenCalledWith({
id: 7,
passwordData: { currentPassword: undefined, newPassword: 'new-password' },
})
);
});
});
@@ -10,6 +10,7 @@ import {
FormControl,
FormField,
FormItem,
FormDescription,
FormLabel,
FormMessage,
} from '@/components/ui/form';
@@ -45,8 +46,10 @@ type ChangePasswordFormValues = z.infer<typeof changePasswordSchema>;
interface ChangePasswordDialogProps {
isOpen: boolean;
user: UserDetails;
user: Pick<UserDetails, 'id' | 'username'>;
isAdminReset?: boolean;
/** Signed in through a trusted proxy, which vouches for the user in place of the current password */
currentPasswordOptional?: boolean;
onClose: () => void;
onSuccess: () => void;
}
@@ -55,6 +58,7 @@ export function ChangePasswordDialog({
isOpen,
user,
isAdminReset,
currentPasswordOptional,
onClose,
onSuccess,
}: ChangePasswordDialogProps) {
@@ -70,7 +74,7 @@ export function ChangePasswordDialog({
});
const onSubmit = async (values: ChangePasswordFormValues) => {
if (!isAdminReset && !values.currentPassword) {
if (!isAdminReset && !currentPasswordOptional && !values.currentPassword) {
form.setError('currentPassword', { message: 'Current password is required' });
return;
}
@@ -78,7 +82,8 @@ export function ChangePasswordDialog({
await changePasswordMutation.mutateAsync({
id: user.id,
passwordData: {
currentPassword: isAdminReset ? undefined : values.currentPassword,
currentPassword:
isAdminReset || !values.currentPassword ? undefined : values.currentPassword,
newPassword: values.newPassword,
},
});
@@ -113,10 +118,17 @@ export function ChangePasswordDialog({
name="currentPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Current Password</FormLabel>
<FormLabel>
Current Password{currentPasswordOptional ? ' (optional)' : ''}
</FormLabel>
<FormControl>
<Input type="password" placeholder="******************" {...field} />
</FormControl>
{currentPasswordOptional ? (
<FormDescription>
Not needed while you&apos;re signed in through single sign-on.
</FormDescription>
) : null}
<FormMessage />
</FormItem>
)}
+3
View File
@@ -7,6 +7,7 @@ import { apiClient } from '../lib/api/client';
import { LoginCredentials, RegisterData } from '../types/auth';
import { PublicSettings } from '../types/settings';
import { logger } from '../lib/logger';
import { SKIP_TRUSTED_LOGIN_KEY } from '../lib/trustedLogin';
/** Validate redirect path to prevent open redirects */
function sanitizeRedirectPath(path: string | undefined): string {
@@ -64,6 +65,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
async (credentials: LoginCredentials, redirectPath?: string) => {
try {
const result = await authApi.login(credentials);
sessionStorage.removeItem(SKIP_TRUSTED_LOGIN_KEY);
// Clear user-specific cached queries, but keep public settings (not user-specific)
queryClient.removeQueries({
@@ -114,6 +116,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
);
const logout = useCallback(async () => {
sessionStorage.setItem(SKIP_TRUSTED_LOGIN_KEY, '1');
try {
await authApi.logout();
} catch (error) {
@@ -0,0 +1,76 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { MemoryRouter, useLocation } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { useTrustedLogin } from './useTrustedLogin';
import { useAuthStore } from '@/store/auth';
import { SKIP_TRUSTED_LOGIN_KEY } from '@/lib/trustedLogin';
const trustedLogin = vi.hoisted(() => vi.fn());
vi.mock('@/lib/api', () => ({ authApi: { trustedLogin } }));
vi.mock('@/store/theme', () => ({
useThemeStore: { getState: () => ({ loadThemeFromServer: () => Promise.resolve() }) },
}));
const user = {
id: 7,
username: 'brad',
email: 'brad@example.com',
role: 'user',
permissions: ['games.read'],
};
function render(path: string) {
const queryClient = new QueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<MemoryRouter initialEntries={[path]}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</MemoryRouter>
);
return renderHook(() => ({ checked: useTrustedLogin(), location: useLocation() }), { wrapper });
}
describe('useTrustedLogin', () => {
beforeEach(() => {
vi.clearAllMocks();
sessionStorage.clear();
useAuthStore.getState().clearAuth();
});
it('signs in the user the proxy vouches for and leaves the login page', async () => {
trustedLogin.mockResolvedValue({ user, tokens: { expiresIn: 3600 } });
const { result } = render('/login');
expect(result.current.checked).toBe(false);
await waitFor(() => expect(result.current.checked).toBe(true));
expect(useAuthStore.getState()).toMatchObject({
user,
isAuthenticated: true,
isTrustedSession: true,
});
expect(result.current.location.pathname).toBe('/');
});
it('continues signed out when there is no trusted identity', async () => {
trustedLogin.mockRejectedValue(new Error('403'));
const { result } = render('/games/abc');
await waitFor(() => expect(result.current.checked).toBe(true));
expect(useAuthStore.getState().isAuthenticated).toBe(false);
expect(result.current.location.pathname).toBe('/games/abc');
});
it('does not ask after an explicit logout or when already signed in', () => {
sessionStorage.setItem(SKIP_TRUSTED_LOGIN_KEY, '1');
expect(render('/').result.current.checked).toBe(true);
sessionStorage.clear();
useAuthStore.getState().setAuth(user);
expect(render('/').result.current.checked).toBe(true);
expect(trustedLogin).not.toHaveBeenCalled();
});
});
+53
View File
@@ -0,0 +1,53 @@
import { useEffect, useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { authApi } from '@/lib/api';
import { logger } from '@/lib/logger';
import { SKIP_TRUSTED_LOGIN_KEY } from '@/lib/trustedLogin';
import { useAuthStore } from '@/store/auth';
const AUTH_PAGES = ['/login', '/register', '/setup'];
/**
* A signed-out visitor may be vouched for by an authenticating reverse proxy.
* Asks once on load, before guest mode or a login redirect takes over, and
* returns whether that check is done.
*/
export function useTrustedLogin(): boolean {
const navigate = useNavigate();
const location = useLocation();
const queryClient = useQueryClient();
const [checked, setChecked] = useState(
() =>
useAuthStore.getState().isAuthenticated ||
sessionStorage.getItem(SKIP_TRUSTED_LOGIN_KEY) !== null
);
useEffect(() => {
if (checked) return;
let cancelled = false;
authApi
.trustedLogin()
.then((result) => {
if (cancelled) return;
useAuthStore.getState().setAuth(result.user, true);
queryClient.invalidateQueries({ queryKey: ['setupStatus'] });
if (AUTH_PAGES.includes(location.pathname)) {
navigate('/', { replace: true });
}
})
.catch(() => {
logger.debug('[useTrustedLogin] No trusted proxy identity; continuing signed out');
})
.finally(() => {
if (!cancelled) setChecked(true);
});
return () => {
cancelled = true;
};
// Runs once on load; later navigation must not repeat it
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return checked;
}
+6
View File
@@ -18,6 +18,12 @@ export const authApi = {
return data;
},
/** Sign in as the user an authenticating reverse proxy vouches for, if any */
trustedLogin: async (): Promise<LoginResponse> => {
const { data } = await apiClient.post<LoginResponse>('/auth/trusted-login');
return data;
},
logout: async (): Promise<{ success: boolean; message: string }> => {
const { data } = await apiClient.post('/auth/logout');
return data;
+5
View File
@@ -0,0 +1,5 @@
/**
* Set for the rest of the tab's session after an explicit logout, so the app
* doesn't sign straight back in through the trusted proxy header.
*/
export const SKIP_TRUSTED_LOGIN_KEY = 'flashpoint-skip-trusted-login';
+8 -2
View File
@@ -9,8 +9,10 @@ interface AuthState {
isAuthenticated: boolean;
isGuest: boolean;
isMaintenanceMode: boolean;
/** Signed in through a trusted proxy header rather than a password */
isTrustedSession: boolean;
setAuth: (user: User) => void;
setAuth: (user: User, isTrustedSession?: boolean) => void;
setGuestMode: () => void;
clearAuth: () => void;
updateUser: (user: User) => void;
@@ -29,12 +31,14 @@ export const useAuthStore = create<AuthState>()(
isAuthenticated: false,
isGuest: false,
isMaintenanceMode: false,
isTrustedSession: false,
setAuth: (user: User) => {
setAuth: (user: User, isTrustedSession = false) => {
set({
user,
isAuthenticated: true,
isGuest: false,
isTrustedSession,
});
if (user.role !== 'guest') {
@@ -66,6 +70,7 @@ export const useAuthStore = create<AuthState>()(
isAuthenticated: false,
isGuest: false,
isMaintenanceMode: false,
isTrustedSession: false,
});
},
@@ -141,6 +146,7 @@ export const useAuthStore = create<AuthState>()(
user: state.user,
isAuthenticated: state.isAuthenticated,
isGuest: state.isGuest,
isTrustedSession: state.isTrustedSession,
}),
}
)
+3
View File
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { Settings } from 'lucide-react';
import { AnimatePresence } from 'framer-motion';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { AccountPasswordCard } from '@/components/settings/AccountPasswordCard';
import { useAuthStore } from '@/store/auth';
import { useQuery } from '@tanstack/react-query';
import { ruffleApi } from '@/lib/api';
@@ -62,6 +63,8 @@ export function SettingsView() {
<h1 className="text-3xl font-bold">Settings</h1>
</div>
<AccountPasswordCard />
{isAdmin ? (
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-4 border-2 border-primary/60 h-full">