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:
+10
-1
@@ -21,6 +21,7 @@ import { useAuthStore } from './store/auth';
|
|||||||
import { useServerStatusStore } from './store/serverStatus';
|
import { useServerStatusStore } from './store/serverStatus';
|
||||||
import { authSettingsApi, authApi } from './lib/api';
|
import { authSettingsApi, authApi } from './lib/api';
|
||||||
import { usePublicSettings } from './hooks/usePublicSettings';
|
import { usePublicSettings } from './hooks/usePublicSettings';
|
||||||
|
import { useTrustedLogin } from './hooks/useTrustedLogin';
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||||
|
|
||||||
@@ -99,6 +100,8 @@ function App() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { data: publicSettings } = usePublicSettings();
|
const { data: publicSettings } = usePublicSettings();
|
||||||
|
|
||||||
|
const trustedLoginChecked = useTrustedLogin();
|
||||||
|
|
||||||
// Check if system needs initial setup (no users exist)
|
// Check if system needs initial setup (no users exist)
|
||||||
const { data: setupStatus } = useQuery({
|
const { data: setupStatus } = useQuery({
|
||||||
queryKey: ['setupStatus'],
|
queryKey: ['setupStatus'],
|
||||||
@@ -137,6 +140,8 @@ function App() {
|
|||||||
location.pathname === '/register' ||
|
location.pathname === '/register' ||
|
||||||
location.pathname === '/setup';
|
location.pathname === '/setup';
|
||||||
|
|
||||||
|
if (!trustedLoginChecked) return;
|
||||||
|
|
||||||
if (isAuthPage && isGuest && !isAuthenticated) {
|
if (isAuthPage && isGuest && !isAuthenticated) {
|
||||||
// Clear guest mode when navigating to auth pages
|
// Clear guest mode when navigating to auth pages
|
||||||
clearAuth();
|
clearAuth();
|
||||||
@@ -144,7 +149,7 @@ function App() {
|
|||||||
// Initialize guest mode for non-auth pages
|
// Initialize guest mode for non-auth pages
|
||||||
setGuestMode();
|
setGuestMode();
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, isGuest, location.pathname, setGuestMode, clearAuth]);
|
}, [isAuthenticated, isGuest, location.pathname, setGuestMode, clearAuth, trustedLoginChecked]);
|
||||||
|
|
||||||
// Update browser title from site name in public settings
|
// Update browser title from site name in public settings
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -190,6 +195,10 @@ function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!trustedLoginChecked) {
|
||||||
|
return <RouteLoadingFallback />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<TooltipProvider delayDuration={300}>
|
<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,
|
FormControl,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
|
FormDescription,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@/components/ui/form';
|
} from '@/components/ui/form';
|
||||||
@@ -45,8 +46,10 @@ type ChangePasswordFormValues = z.infer<typeof changePasswordSchema>;
|
|||||||
|
|
||||||
interface ChangePasswordDialogProps {
|
interface ChangePasswordDialogProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
user: UserDetails;
|
user: Pick<UserDetails, 'id' | 'username'>;
|
||||||
isAdminReset?: boolean;
|
isAdminReset?: boolean;
|
||||||
|
/** Signed in through a trusted proxy, which vouches for the user in place of the current password */
|
||||||
|
currentPasswordOptional?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
}
|
}
|
||||||
@@ -55,6 +58,7 @@ export function ChangePasswordDialog({
|
|||||||
isOpen,
|
isOpen,
|
||||||
user,
|
user,
|
||||||
isAdminReset,
|
isAdminReset,
|
||||||
|
currentPasswordOptional,
|
||||||
onClose,
|
onClose,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}: ChangePasswordDialogProps) {
|
}: ChangePasswordDialogProps) {
|
||||||
@@ -70,7 +74,7 @@ export function ChangePasswordDialog({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = async (values: ChangePasswordFormValues) => {
|
const onSubmit = async (values: ChangePasswordFormValues) => {
|
||||||
if (!isAdminReset && !values.currentPassword) {
|
if (!isAdminReset && !currentPasswordOptional && !values.currentPassword) {
|
||||||
form.setError('currentPassword', { message: 'Current password is required' });
|
form.setError('currentPassword', { message: 'Current password is required' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -78,7 +82,8 @@ export function ChangePasswordDialog({
|
|||||||
await changePasswordMutation.mutateAsync({
|
await changePasswordMutation.mutateAsync({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
passwordData: {
|
passwordData: {
|
||||||
currentPassword: isAdminReset ? undefined : values.currentPassword,
|
currentPassword:
|
||||||
|
isAdminReset || !values.currentPassword ? undefined : values.currentPassword,
|
||||||
newPassword: values.newPassword,
|
newPassword: values.newPassword,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -113,10 +118,17 @@ export function ChangePasswordDialog({
|
|||||||
name="currentPassword"
|
name="currentPassword"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Current Password</FormLabel>
|
<FormLabel>
|
||||||
|
Current Password{currentPasswordOptional ? ' (optional)' : ''}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="password" placeholder="******************" {...field} />
|
<Input type="password" placeholder="******************" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
{currentPasswordOptional ? (
|
||||||
|
<FormDescription>
|
||||||
|
Not needed while you're signed in through single sign-on.
|
||||||
|
</FormDescription>
|
||||||
|
) : null}
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { apiClient } from '../lib/api/client';
|
|||||||
import { LoginCredentials, RegisterData } from '../types/auth';
|
import { LoginCredentials, RegisterData } from '../types/auth';
|
||||||
import { PublicSettings } from '../types/settings';
|
import { PublicSettings } from '../types/settings';
|
||||||
import { logger } from '../lib/logger';
|
import { logger } from '../lib/logger';
|
||||||
|
import { SKIP_TRUSTED_LOGIN_KEY } from '../lib/trustedLogin';
|
||||||
|
|
||||||
/** Validate redirect path to prevent open redirects */
|
/** Validate redirect path to prevent open redirects */
|
||||||
function sanitizeRedirectPath(path: string | undefined): string {
|
function sanitizeRedirectPath(path: string | undefined): string {
|
||||||
@@ -64,6 +65,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
|||||||
async (credentials: LoginCredentials, redirectPath?: string) => {
|
async (credentials: LoginCredentials, redirectPath?: string) => {
|
||||||
try {
|
try {
|
||||||
const result = await authApi.login(credentials);
|
const result = await authApi.login(credentials);
|
||||||
|
sessionStorage.removeItem(SKIP_TRUSTED_LOGIN_KEY);
|
||||||
|
|
||||||
// Clear user-specific cached queries, but keep public settings (not user-specific)
|
// Clear user-specific cached queries, but keep public settings (not user-specific)
|
||||||
queryClient.removeQueries({
|
queryClient.removeQueries({
|
||||||
@@ -114,6 +116,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
|
sessionStorage.setItem(SKIP_TRUSTED_LOGIN_KEY, '1');
|
||||||
try {
|
try {
|
||||||
await authApi.logout();
|
await authApi.logout();
|
||||||
} catch (error) {
|
} 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -18,6 +18,12 @@ export const authApi = {
|
|||||||
return data;
|
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 }> => {
|
logout: async (): Promise<{ success: boolean; message: string }> => {
|
||||||
const { data } = await apiClient.post('/auth/logout');
|
const { data } = await apiClient.post('/auth/logout');
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -9,8 +9,10 @@ interface AuthState {
|
|||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
isGuest: boolean;
|
isGuest: boolean;
|
||||||
isMaintenanceMode: 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;
|
setGuestMode: () => void;
|
||||||
clearAuth: () => void;
|
clearAuth: () => void;
|
||||||
updateUser: (user: User) => void;
|
updateUser: (user: User) => void;
|
||||||
@@ -29,12 +31,14 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isGuest: false,
|
isGuest: false,
|
||||||
isMaintenanceMode: false,
|
isMaintenanceMode: false,
|
||||||
|
isTrustedSession: false,
|
||||||
|
|
||||||
setAuth: (user: User) => {
|
setAuth: (user: User, isTrustedSession = false) => {
|
||||||
set({
|
set({
|
||||||
user,
|
user,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isGuest: false,
|
isGuest: false,
|
||||||
|
isTrustedSession,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (user.role !== 'guest') {
|
if (user.role !== 'guest') {
|
||||||
@@ -66,6 +70,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isGuest: false,
|
isGuest: false,
|
||||||
isMaintenanceMode: false,
|
isMaintenanceMode: false,
|
||||||
|
isTrustedSession: false,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -141,6 +146,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
user: state.user,
|
user: state.user,
|
||||||
isAuthenticated: state.isAuthenticated,
|
isAuthenticated: state.isAuthenticated,
|
||||||
isGuest: state.isGuest,
|
isGuest: state.isGuest,
|
||||||
|
isTrustedSession: state.isTrustedSession,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
|||||||
import { Settings } from 'lucide-react';
|
import { Settings } from 'lucide-react';
|
||||||
import { AnimatePresence } from 'framer-motion';
|
import { AnimatePresence } from 'framer-motion';
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { AccountPasswordCard } from '@/components/settings/AccountPasswordCard';
|
||||||
import { useAuthStore } from '@/store/auth';
|
import { useAuthStore } from '@/store/auth';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { ruffleApi } from '@/lib/api';
|
import { ruffleApi } from '@/lib/api';
|
||||||
@@ -62,6 +63,8 @@ export function SettingsView() {
|
|||||||
<h1 className="text-3xl font-bold">Settings</h1>
|
<h1 className="text-3xl font-bold">Settings</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AccountPasswordCard />
|
||||||
|
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-4 border-2 border-primary/60 h-full">
|
<TabsList className="grid w-full grid-cols-4 border-2 border-primary/60 h-full">
|
||||||
|
|||||||
Reference in New Issue
Block a user