From d13097291604e51d1248ef7cd86ea9f930b7f103 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 6 Apr 2026 17:00:46 -0400 Subject: [PATCH] feat(dashboard-auth): clarify remote dashboard login flow - add explicit access modes so remote users see setup guidance instead of an ambiguous login - redesign the login page with remote setup messaging, password visibility, and light/dark controls - cover the access-state contract and login UI with focused tests --- src/web-server/routes/auth-routes.ts | 56 +++- .../auth-check-remote-access.test.ts | 81 +++-- ui/src/contexts/auth-context.tsx | 44 ++- ui/src/lib/auth-api.ts | 6 + ui/src/lib/i18n.ts | 112 ++++++- ui/src/pages/login.tsx | 309 ++++++++++++++---- ui/tests/unit/pages/login-page.test.tsx | 81 +++++ 7 files changed, 600 insertions(+), 89 deletions(-) create mode 100644 ui/tests/unit/pages/login-page.test.tsx diff --git a/src/web-server/routes/auth-routes.ts b/src/web-server/routes/auth-routes.ts index 70b2564f..d3bd0bcf 100644 --- a/src/web-server/routes/auth-routes.ts +++ b/src/web-server/routes/auth-routes.ts @@ -7,6 +7,7 @@ import { Router, type Request, type Response } from 'express'; import bcrypt from 'bcrypt'; import crypto from 'crypto'; import { getDashboardAuthConfig } from '../../config/unified-config-loader'; +import type { DashboardAuthConfig } from '../../config/unified-config-types'; import { isLoopbackRemoteAddress, loginRateLimiter } from '../middleware/auth-middleware'; /** @@ -24,6 +25,52 @@ function timingSafeEqual(a: string, b: string): boolean { const router = Router(); +export type DashboardAccessMode = 'open' | 'login' | 'setup'; + +export interface DashboardAccessState { + authRequired: boolean; + authEnabled: boolean; + authConfigured: boolean; + isLocalAccess: boolean; + accessMode: DashboardAccessMode; +} + +export function resolveDashboardAccessState( + authConfig: DashboardAuthConfig, + remoteAddress: string | undefined +): DashboardAccessState { + const isLocalAccess = isLoopbackRemoteAddress(remoteAddress); + const authConfigured = Boolean(authConfig.username && authConfig.password_hash); + + if (authConfig.enabled && authConfigured) { + return { + authRequired: true, + authEnabled: true, + authConfigured: true, + isLocalAccess, + accessMode: 'login', + }; + } + + if (!authConfig.enabled && isLocalAccess) { + return { + authRequired: false, + authEnabled: false, + authConfigured, + isLocalAccess: true, + accessMode: 'open', + }; + } + + return { + authRequired: true, + authEnabled: authConfig.enabled, + authConfigured, + isLocalAccess, + accessMode: 'setup', + }; +} + /** * POST /api/auth/login * Authenticate user with username/password. @@ -94,15 +141,10 @@ router.post('/logout', (req: Request, res: Response) => { */ router.get('/check', (req: Request, res: Response) => { const authConfig = getDashboardAuthConfig(); - const isLocal = isLoopbackRemoteAddress(req.socket.remoteAddress); - - // When auth is not configured and access is remote, the dashboard API - // endpoints return 403. Signal auth-required so the UI can show the - // login/setup page instead of a silently broken dashboard. - const effectiveAuthRequired = authConfig.enabled || !isLocal; + const accessState = resolveDashboardAccessState(authConfig, req.socket.remoteAddress); res.json({ - authRequired: effectiveAuthRequired, + ...accessState, authenticated: req.session?.authenticated ?? false, username: req.session?.username ?? null, }); diff --git a/tests/unit/web-server/auth-check-remote-access.test.ts b/tests/unit/web-server/auth-check-remote-access.test.ts index 6d00d053..2f1ac17c 100644 --- a/tests/unit/web-server/auth-check-remote-access.test.ts +++ b/tests/unit/web-server/auth-check-remote-access.test.ts @@ -1,13 +1,14 @@ /** * Auth Check Route — Remote Access Detection Tests * - * Verifies that /api/auth/check returns effectiveAuthRequired=true - * for remote clients when auth is disabled, preventing a silently - * broken dashboard. + * Verifies that /api/auth/check produces a distinct setup state for + * remote clients or incomplete host config instead of always showing + * a login form. */ import { describe, it, expect } from 'bun:test'; import { isLoopbackRemoteAddress } from '../../../src/web-server/middleware/auth-middleware'; +import { resolveDashboardAccessState } from '../../../src/web-server/routes/auth-routes'; describe('isLoopbackRemoteAddress', () => { it('returns true for IPv4 localhost', () => { @@ -37,27 +38,69 @@ describe('isLoopbackRemoteAddress', () => { }); }); -describe('effectiveAuthRequired logic', () => { - // Mirrors the logic in auth-routes.ts GET /api/auth/check: - // effectiveAuthRequired = authConfig.enabled || !isLocal - function computeEffectiveAuthRequired(authEnabled: boolean, remoteAddress: string | undefined) { - const isLocal = isLoopbackRemoteAddress(remoteAddress); - return authEnabled || !isLocal; - } - - it('localhost + auth disabled -> authRequired=false', () => { - expect(computeEffectiveAuthRequired(false, '127.0.0.1')).toBe(false); +describe('resolveDashboardAccessState', () => { + it('allows localhost through when auth is disabled', () => { + expect( + resolveDashboardAccessState( + { enabled: false, username: '', password_hash: '', session_timeout_hours: 24 }, + '127.0.0.1' + ) + ).toEqual({ + authRequired: false, + authEnabled: false, + authConfigured: false, + isLocalAccess: true, + accessMode: 'open', + }); }); - it('remote + auth disabled -> authRequired=true', () => { - expect(computeEffectiveAuthRequired(false, '192.168.2.100')).toBe(true); + it('shows setup state for remote access when auth is disabled', () => { + expect( + resolveDashboardAccessState( + { enabled: false, username: '', password_hash: '', session_timeout_hours: 24 }, + '192.168.2.100' + ) + ).toEqual({ + authRequired: true, + authEnabled: false, + authConfigured: false, + isLocalAccess: false, + accessMode: 'setup', + }); }); - it('remote + auth enabled -> authRequired=true', () => { - expect(computeEffectiveAuthRequired(true, '192.168.2.100')).toBe(true); + it('shows login state only when auth is enabled and fully configured', () => { + expect( + resolveDashboardAccessState( + { + enabled: true, + username: 'admin', + password_hash: '$2b$10$123456789012345678901u4cPFsKnzGWxZmfq6OnpZnN0UiM6Qf7e', + session_timeout_hours: 24, + }, + '192.168.2.100' + ) + ).toEqual({ + authRequired: true, + authEnabled: true, + authConfigured: true, + isLocalAccess: false, + accessMode: 'login', + }); }); - it('localhost + auth enabled -> authRequired=true', () => { - expect(computeEffectiveAuthRequired(true, '127.0.0.1')).toBe(true); + it('shows setup state when auth is enabled but credentials are incomplete', () => { + expect( + resolveDashboardAccessState( + { enabled: true, username: 'admin', password_hash: '', session_timeout_hours: 24 }, + '127.0.0.1' + ) + ).toEqual({ + authRequired: true, + authEnabled: true, + authConfigured: false, + isLocalAccess: true, + accessMode: 'setup', + }); }); }); diff --git a/ui/src/contexts/auth-context.tsx b/ui/src/contexts/auth-context.tsx index d0e39ee7..29369785 100644 --- a/ui/src/contexts/auth-context.tsx +++ b/ui/src/contexts/auth-context.tsx @@ -13,7 +13,12 @@ import { useCallback, type ReactNode, } from 'react'; -import { checkAuth, login as apiLogin, logout as apiLogout } from '@/lib/auth-api'; +import { + checkAuth, + login as apiLogin, + logout as apiLogout, + type DashboardAccessMode, +} from '@/lib/auth-api'; interface AuthContextValue { /** Whether authentication is required for this dashboard */ @@ -24,6 +29,14 @@ interface AuthContextValue { username: string | null; /** Whether auth check is in progress */ loading: boolean; + /** Whether dashboard auth is enabled on the host */ + authEnabled: boolean; + /** Whether host credentials are fully configured */ + authConfigured: boolean; + /** Whether the current request comes from localhost/loopback */ + isLocalAccess: boolean; + /** Effective access mode for the current request */ + accessMode: DashboardAccessMode; /** Login with credentials */ login: (username: string, password: string) => Promise; /** Logout current session */ @@ -37,6 +50,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [isAuthenticated, setIsAuthenticated] = useState(false); const [username, setUsername] = useState(null); const [loading, setLoading] = useState(true); + const [authEnabled, setAuthEnabled] = useState(false); + const [authConfigured, setAuthConfigured] = useState(false); + const [isLocalAccess, setIsLocalAccess] = useState(false); + const [accessMode, setAccessMode] = useState('login'); // Check auth status on mount useEffect(() => { @@ -45,6 +62,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { setAuthRequired(res.authRequired); setIsAuthenticated(res.authenticated); setUsername(res.username); + setAuthEnabled(res.authEnabled); + setAuthConfigured(res.authConfigured); + setIsLocalAccess(res.isLocalAccess); + setAccessMode(res.accessMode); }) .catch(() => { // If auth check fails (network error, server down, CORS issue), @@ -52,6 +73,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { // Prevents silently broken dashboard when server is unreachable. setAuthRequired(true); setIsAuthenticated(false); + setAuthEnabled(true); + setAuthConfigured(true); + setIsLocalAccess(false); + setAccessMode('login'); }) .finally(() => setLoading(false)); }, []); @@ -74,10 +99,25 @@ export function AuthProvider({ children }: { children: ReactNode }) { isAuthenticated, username, loading, + authEnabled, + authConfigured, + isLocalAccess, + accessMode, login, logout, }), - [authRequired, isAuthenticated, username, loading, login, logout] + [ + authRequired, + isAuthenticated, + username, + loading, + authEnabled, + authConfigured, + isLocalAccess, + accessMode, + login, + logout, + ] ); return {children}; diff --git a/ui/src/lib/auth-api.ts b/ui/src/lib/auth-api.ts index 7370932d..68ae0c7d 100644 --- a/ui/src/lib/auth-api.ts +++ b/ui/src/lib/auth-api.ts @@ -5,10 +5,16 @@ const BASE_URL = '/api/auth'; +export type DashboardAccessMode = 'open' | 'login' | 'setup'; + export interface AuthCheckResponse { authRequired: boolean; authenticated: boolean; username: string | null; + authEnabled: boolean; + authConfigured: boolean; + isLocalAccess: boolean; + accessMode: DashboardAccessMode; } export interface AuthSetupResponse { diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 36034951..70b049a8 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -45,7 +45,11 @@ const resources = { }, auth: { dashboardTitle: 'CCS Dashboard', - loginDescription: 'Enter your credentials to access the dashboard', + protectedAccessLabel: 'Protected access', + remoteGuardLabel: 'Remote access guard', + loading: 'Checking dashboard access…', + loginDescription: + 'Use the username and password configured on the host to access the dashboard.', username: 'Username', password: 'Password', usernamePlaceholder: 'Enter username', @@ -53,6 +57,30 @@ const resources = { signIn: 'Sign In', signingIn: 'Signing in...', loginFailed: 'Login failed', + lightMode: 'Light', + darkMode: 'Dark', + noDefaultCredentials: 'No default credentials ship with CCS.', + credentialsHint: + 'Credentials are created on the host with `ccs config auth setup`, then used here.', + remoteSetupTitle: 'Remote access needs host setup', + remoteSetupDescription: + 'This dashboard was opened from a non-local address, but dashboard auth is not enabled on the host yet.', + incompleteSetupDescription: + 'Dashboard auth is turned on, but the host setup is incomplete. Finish the host configuration before signing in.', + safetyNoteRemote: + 'Remote management stays locked until the host owner enables dashboard auth.', + safetyNoteLocal: + 'If you are on the same machine, the localhost URL remains the simplest path in.', + safetyNoteSession: + 'Successful sign-ins create an HTTP-only session that stays scoped to this host.', + hostStepTitle: 'On the host machine', + hostStepDescription: + 'Create or re-enable dashboard credentials, then reopen this page from the remote device.', + localStepTitle: 'If this is your machine', + localStepDescription: + 'Open the localhost URL printed by `ccs config` instead of the LAN or Tailscale address.', + showPassword: 'Show password', + hidePassword: 'Hide password', }, commonToast: { apiKeyRequired: 'API key is required', @@ -1382,7 +1410,10 @@ const resources = { }, auth: { dashboardTitle: 'CCS 控制台', - loginDescription: '请输入凭据以访问控制台', + protectedAccessLabel: '受保护访问', + remoteGuardLabel: '远程访问保护', + loading: '正在检查控制台访问状态…', + loginDescription: '使用主机上配置的用户名和密码访问控制台。', username: '用户名', password: '密码', usernamePlaceholder: '请输入用户名', @@ -1390,6 +1421,24 @@ const resources = { signIn: '登录', signingIn: '登录中...', loginFailed: '登录失败', + lightMode: '浅色', + darkMode: '深色', + noDefaultCredentials: 'CCS 不提供默认用户名或密码。', + credentialsHint: '凭据需要在主机上通过 `ccs config auth setup` 创建,然后在这里使用。', + remoteSetupTitle: '远程访问需要在主机上完成设置', + remoteSetupDescription: '当前通过非本机地址打开控制台,但主机尚未启用控制台认证。', + incompleteSetupDescription: + '控制台认证已开启,但主机上的设置尚未完成。请先完成主机配置再登录。', + safetyNoteRemote: '在主机所有者启用控制台认证前,远程管理会保持锁定。', + safetyNoteLocal: '如果你就在这台机器上,使用 localhost 地址始终是最直接的方式。', + safetyNoteSession: '登录成功后会创建仅限此主机的 HTTP-only 会话。', + hostStepTitle: '在主机上操作', + hostStepDescription: '创建或重新启用控制台凭据,然后再从远程设备重新打开此页面。', + localStepTitle: '如果这就是你的机器', + localStepDescription: + '请使用 `ccs config` 输出的 localhost 地址,而不是局域网或 Tailscale 地址。', + showPassword: '显示密码', + hidePassword: '隐藏密码', }, commonToast: { apiKeyRequired: 'API key 为必填项', @@ -2655,7 +2704,11 @@ const resources = { }, auth: { dashboardTitle: 'Bảng điều khiển CCS', - loginDescription: 'Nhập thông tin đăng nhập của bạn để truy cập bảng điều khiển', + protectedAccessLabel: 'Truy cập được bảo vệ', + remoteGuardLabel: 'Lớp bảo vệ truy cập từ xa', + loading: 'Đang kiểm tra quyền truy cập bảng điều khiển…', + loginDescription: + 'Dùng tên người dùng và mật khẩu đã được cấu hình trên máy host để truy cập bảng điều khiển.', username: 'Tên người dùng', password: 'Mật khẩu', usernamePlaceholder: 'Nhập tên người dùng', @@ -2663,6 +2716,30 @@ const resources = { signIn: 'Đăng nhập', signingIn: 'Đang đăng nhập...', loginFailed: 'Đăng nhập không thành công', + lightMode: 'Sáng', + darkMode: 'Tối', + noDefaultCredentials: 'CCS không có sẵn tài khoản hay mật khẩu mặc định.', + credentialsHint: + 'Thông tin đăng nhập được tạo trên máy host bằng `ccs config auth setup`, rồi dùng tại đây.', + remoteSetupTitle: 'Truy cập từ xa cần được thiết lập trên máy host', + remoteSetupDescription: + 'Bảng điều khiển này đang được mở từ một địa chỉ không phải localhost, nhưng máy host chưa bật dashboard auth.', + incompleteSetupDescription: + 'Dashboard auth đã được bật nhưng cấu hình trên máy host vẫn chưa hoàn tất. Hãy hoàn thành cấu hình trước khi đăng nhập.', + safetyNoteRemote: + 'Quản trị từ xa sẽ tiếp tục bị khóa cho tới khi chủ máy host bật dashboard auth.', + safetyNoteLocal: + 'Nếu bạn đang ngồi ngay trên máy đó, đường localhost vẫn là cách đơn giản nhất để vào.', + safetyNoteSession: + 'Sau khi đăng nhập thành công, phiên HTTP-only sẽ chỉ có hiệu lực trên đúng máy host này.', + hostStepTitle: 'Trên máy host', + hostStepDescription: + 'Tạo hoặc bật lại thông tin đăng nhập cho dashboard, rồi mở lại trang này từ thiết bị từ xa.', + localStepTitle: 'Nếu đây là máy của bạn', + localStepDescription: + 'Hãy mở URL localhost mà `ccs config` in ra, thay vì địa chỉ LAN hoặc Tailscale.', + showPassword: 'Hiện mật khẩu', + hidePassword: 'Ẩn mật khẩu', }, commonToast: { apiKeyRequired: 'Cần có khóa API', @@ -4013,7 +4090,11 @@ const resources = { }, auth: { dashboardTitle: 'CCS Dashboard', - loginDescription: 'ダッシュボードにアクセスするには認証情報を入力してください', + protectedAccessLabel: '保護されたアクセス', + remoteGuardLabel: 'リモートアクセス保護', + loading: 'ダッシュボードのアクセス状態を確認しています…', + loginDescription: + 'ホストで設定されたユーザー名とパスワードを使ってダッシュボードにアクセスしてください。', username: 'ユーザー名', password: 'パスワード', usernamePlaceholder: 'ユーザー名を入力', @@ -4021,6 +4102,29 @@ const resources = { signIn: 'サインイン', signingIn: 'サインイン中...', loginFailed: 'ログインに失敗しました', + lightMode: 'ライト', + darkMode: 'ダーク', + noDefaultCredentials: 'CCS にデフォルトの認証情報はありません。', + credentialsHint: + '認証情報はホスト側で `ccs config auth setup` を実行して作成し、ここで使用します。', + remoteSetupTitle: 'リモートアクセスにはホスト側の設定が必要です', + remoteSetupDescription: + 'このダッシュボードはローカル以外のアドレスから開かれていますが、ホストでダッシュボード認証がまだ有効になっていません。', + incompleteSetupDescription: + 'ダッシュボード認証は有効ですが、ホスト側の設定が未完了です。サインイン前に設定を完了してください。', + safetyNoteRemote: + 'ホスト管理者がダッシュボード認証を有効にするまで、リモート管理はロックされたままです。', + safetyNoteLocal: '同じマシン上にいる場合は、localhost の URL を使うのが最も簡単です。', + safetyNoteSession: + 'サインインに成功すると、このホストに限定された HTTP-only セッションが作成されます。', + hostStepTitle: 'ホスト側で行うこと', + hostStepDescription: + 'ダッシュボード認証情報を作成または再有効化してから、このページをリモート端末で開き直してください。', + localStepTitle: 'もしこのマシンを使っているなら', + localStepDescription: + '`ccs config` が表示する localhost の URL を使い、LAN や Tailscale のアドレスは避けてください。', + showPassword: 'パスワードを表示', + hidePassword: 'パスワードを隠す', }, commonToast: { apiKeyRequired: 'API キーは必須です', diff --git a/ui/src/pages/login.tsx b/ui/src/pages/login.tsx index 87fb1e37..d9636034 100644 --- a/ui/src/pages/login.tsx +++ b/ui/src/pages/login.tsx @@ -10,32 +10,68 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { AlertCircle, Loader2, Lock } from 'lucide-react'; -import { Alert, AlertDescription } from '@/components/ui/alert'; +import { useTheme } from '@/hooks/use-theme'; +import { cn } from '@/lib/utils'; +import { + AlertCircle, + Command, + Eye, + EyeOff, + Loader2, + Lock, + MonitorSmartphone, + Moon, + ShieldCheck, + Sun, +} from 'lucide-react'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { useTranslation } from 'react-i18next'; export function LoginPage() { const { t } = useTranslation(); + const { isDark, setTheme } = useTheme(); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); + const [submitting, setSubmitting] = useState(false); - const { login, authRequired, isAuthenticated } = useAuth(); + const { login, authRequired, isAuthenticated, loading, accessMode, authEnabled } = useAuth(); const navigate = useNavigate(); const location = useLocation(); + const isSetupState = accessMode === 'setup'; + const heroLabel = isSetupState ? t('auth.remoteGuardLabel') : t('auth.protectedAccessLabel'); + const heroDescription = isSetupState + ? authEnabled + ? t('auth.incompleteSetupDescription') + : t('auth.remoteSetupDescription') + : t('auth.loginDescription'); // Get redirect destination (default to home) const from = (location.state as { from?: { pathname: string } })?.from?.pathname || '/'; // Redirect if already authenticated or auth not required (via useEffect to avoid render side effects) useEffect(() => { + if (loading) { + return; + } if (isAuthenticated || !authRequired) { navigate(from, { replace: true }); } - }, [isAuthenticated, authRequired, navigate, from]); + }, [isAuthenticated, authRequired, loading, navigate, from]); // Show nothing while redirecting + if (loading) { + return ( +
+
+ + {t('auth.loading')} +
+
+ ); + } + if (isAuthenticated || !authRequired) { return null; } @@ -43,7 +79,7 @@ export function LoginPage() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); - setLoading(true); + setSubmitting(true); try { await login(username, password); @@ -51,67 +87,226 @@ export function LoginPage() { } catch (err) { setError(err instanceof Error ? err.message : t('auth.loginFailed')); } finally { - setLoading(false); + setSubmitting(false); } }; return ( -
- - -
- -
- {t('auth.dashboardTitle')} - {t('auth.loginDescription')} -
- -
- {error && ( - - - {error} - - )} -
- - setUsername(e.target.value)} - placeholder={t('auth.usernamePlaceholder')} - autoComplete="username" - disabled={loading} - required - /> +
+
+
+ +
+
+
+
+
+ + + {heroLabel} + + +
+ + +
+
+ +
+

+ {isSetupState ? t('auth.remoteSetupTitle') : t('auth.dashboardTitle')} +

+

+ {heroDescription} +

+
-
- - setPassword(e.target.value)} - placeholder={t('auth.passwordPlaceholder')} - autoComplete="current-password" - disabled={loading} - required - /> + +
+
+ +

{t('auth.safetyNoteRemote')}

+
+
+ +

{t('auth.safetyNoteLocal')}

+
+
+ +

{t('auth.safetyNoteSession')}

+
-
+ + + +
+ {isSetupState ? ( + + ) : ( + + )} +
+
+ + {isSetupState ? t('auth.remoteSetupTitle') : t('auth.dashboardTitle')} + + + {isSetupState ? heroDescription : t('auth.credentialsHint')} + +
+
+ + + {isSetupState ? ( <> - - {t('auth.signingIn')} + + + {t('auth.noDefaultCredentials')} + {t('auth.credentialsHint')} + + +
+
+
+ + {t('auth.hostStepTitle')} +
+

+ {t('auth.hostStepDescription')} +

+ + ccs config auth setup + +
+ +
+
+ + {t('auth.localStepTitle')} +
+

+ {t('auth.localStepDescription')} +

+
+
) : ( - t('auth.signIn') + + {error && ( + + + {error} + + )} + + + + {t('auth.noDefaultCredentials')} + {t('auth.credentialsHint')} + + +
+ + setUsername(e.target.value)} + placeholder={t('auth.usernamePlaceholder')} + autoComplete="username" + className="h-12 rounded-2xl border-border/80 bg-background/85 px-4 text-base md:text-base" + disabled={submitting} + required + /> +
+ +
+ +
+ setPassword(e.target.value)} + placeholder={t('auth.passwordPlaceholder')} + autoComplete="current-password" + className="h-12 rounded-2xl border-border/80 bg-background/85 px-4 pr-14 text-base md:text-base" + disabled={submitting} + required + /> + +
+
+ + + )} - - -
-
+ + +
+
); } diff --git a/ui/tests/unit/pages/login-page.test.tsx b/ui/tests/unit/pages/login-page.test.tsx new file mode 100644 index 00000000..97ec0451 --- /dev/null +++ b/ui/tests/unit/pages/login-page.test.tsx @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import i18n from '@/lib/i18n'; +import { LoginPage } from '@/pages/login'; +import { render, screen, userEvent } from '@tests/setup/test-utils'; + +const { navigateMock, useAuthMock } = vi.hoisted(() => ({ + navigateMock: vi.fn(), + useAuthMock: vi.fn(), +})); + +vi.mock('@/contexts/auth-context', () => ({ + useAuth: useAuthMock, +})); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + + return { + ...actual, + useNavigate: () => navigateMock, + useLocation: () => ({ state: { from: { pathname: '/settings' } } }), + }; +}); + +describe('LoginPage', () => { + beforeEach(async () => { + navigateMock.mockReset(); + useAuthMock.mockReturnValue({ + authRequired: true, + isAuthenticated: false, + username: null, + loading: false, + accessMode: 'login', + authEnabled: true, + authConfigured: true, + isLocalAccess: false, + login: vi.fn(), + logout: vi.fn(), + }); + await i18n.changeLanguage('en'); + }); + + it('renders a setup state for remote access when dashboard auth is unavailable', () => { + useAuthMock.mockReturnValue({ + authRequired: true, + isAuthenticated: false, + username: null, + loading: false, + accessMode: 'setup', + authEnabled: false, + authConfigured: false, + isLocalAccess: false, + login: vi.fn(), + logout: vi.fn(), + }); + + render(); + + expect(screen.getByRole('heading', { name: 'Remote access needs host setup' })).toBeVisible(); + expect(screen.getByText('ccs config auth setup')).toBeVisible(); + expect(screen.getByText('No default credentials ship with CCS.')).toBeVisible(); + expect(screen.queryByLabelText('Username')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Sign In' })).not.toBeInTheDocument(); + }); + + it('toggles password visibility on the login form', async () => { + render(); + + expect(screen.getByRole('button', { name: 'Light' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'Dark' })).toBeVisible(); + + const passwordInput = screen.getByLabelText('Password'); + expect(passwordInput).toHaveAttribute('type', 'password'); + + await userEvent.click(screen.getByRole('button', { name: 'Show password' })); + expect(passwordInput).toHaveAttribute('type', 'text'); + + await userEvent.click(screen.getByRole('button', { name: 'Hide password' })); + expect(passwordInput).toHaveAttribute('type', 'password'); + }); +});