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
This commit is contained in:
Tam Nhu Tran
2026-04-06 17:01:49 -04:00
parent 9b3e85c5d9
commit d130972916
7 changed files with 600 additions and 89 deletions
+49 -7
View File
@@ -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,
});
@@ -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',
});
});
});
+42 -2
View File
@@ -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<void>;
/** Logout current session */
@@ -37,6 +50,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [username, setUsername] = useState<string | null>(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<DashboardAccessMode>('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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+6
View File
@@ -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 {
+108 -4
View File
@@ -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 キーは必須です',
+252 -57
View File
@@ -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<string | null>(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 (
<div className="flex min-h-[100dvh] items-center justify-center bg-background px-4">
<div className="flex items-center gap-3 rounded-full border border-border/80 bg-card/90 px-5 py-3 text-sm text-muted-foreground shadow-sm">
<Loader2 className="h-4 w-4 animate-spin" />
{t('auth.loading')}
</div>
</div>
);
}
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 (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<Card className="w-full max-w-sm">
<CardHeader className="space-y-1 text-center">
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<Lock className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-2xl">{t('auth.dashboardTitle')}</CardTitle>
<CardDescription>{t('auth.loginDescription')}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Label htmlFor="username">{t('auth.username')}</Label>
<Input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t('auth.usernamePlaceholder')}
autoComplete="username"
disabled={loading}
required
/>
<div className="relative min-h-[100dvh] overflow-hidden bg-background">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(167,85,52,0.16),transparent_42%),radial-gradient(circle_at_bottom_right,rgba(39,39,42,0.12),transparent_48%)]" />
<div className="absolute inset-0 opacity-[0.18] [background-image:linear-gradient(rgba(127,117,107,0.14)_1px,transparent_1px),linear-gradient(90deg,rgba(127,117,107,0.14)_1px,transparent_1px)] [background-size:24px_24px]" />
<div className="relative mx-auto flex min-h-[100dvh] max-w-6xl items-center px-4 py-8 sm:px-6">
<div className="grid w-full items-stretch gap-6 lg:grid-cols-[minmax(0,1.05fr)_minmax(360px,430px)]">
<section className="flex flex-col justify-between rounded-[28px] border border-border/70 bg-card/85 p-6 shadow-[0_24px_80px_-48px_rgba(68,48,34,0.55)] backdrop-blur sm:p-8">
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<span className="inline-flex w-fit items-center gap-2 rounded-full border border-border/70 bg-background/75 px-3 py-1 text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
<ShieldCheck className="h-3.5 w-3.5 text-accent" />
{heroLabel}
</span>
<div className="inline-flex items-center gap-1 rounded-full border border-border/70 bg-background/75 p-1 shadow-sm backdrop-blur">
<Button
type="button"
variant="ghost"
size="sm"
className={cn(
'rounded-full px-3 text-xs font-semibold',
!isDark && 'bg-card text-foreground shadow-sm'
)}
aria-pressed={!isDark}
onClick={() => setTheme('light')}
>
<Sun className="h-3.5 w-3.5" />
{t('auth.lightMode')}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className={cn(
'rounded-full px-3 text-xs font-semibold',
isDark && 'bg-card text-foreground shadow-sm'
)}
aria-pressed={isDark}
onClick={() => setTheme('dark')}
>
<Moon className="h-3.5 w-3.5" />
{t('auth.darkMode')}
</Button>
</div>
</div>
<div className="space-y-3">
<h1 className="max-w-xl text-4xl font-semibold tracking-[-0.04em] text-balance text-foreground sm:text-5xl">
{isSetupState ? t('auth.remoteSetupTitle') : t('auth.dashboardTitle')}
</h1>
<p className="max-w-2xl text-base leading-7 text-muted-foreground sm:text-lg">
{heroDescription}
</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password">{t('auth.password')}</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t('auth.passwordPlaceholder')}
autoComplete="current-password"
disabled={loading}
required
/>
<div className="mt-8 grid gap-3 text-sm text-muted-foreground sm:grid-cols-3">
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<MonitorSmartphone className="mb-3 h-4 w-4 text-accent" />
<p>{t('auth.safetyNoteRemote')}</p>
</div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<Lock className="mb-3 h-4 w-4 text-accent" />
<p>{t('auth.safetyNoteLocal')}</p>
</div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<ShieldCheck className="mb-3 h-4 w-4 text-accent" />
<p>{t('auth.safetyNoteSession')}</p>
</div>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? (
</section>
<Card className="justify-center rounded-[28px] border-border/70 bg-card/95 py-0 shadow-[0_24px_90px_-54px_rgba(34,24,16,0.62)]">
<CardHeader className="space-y-3 border-b border-border/70 px-6 py-6">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-accent/20 bg-accent/10">
{isSetupState ? (
<MonitorSmartphone className="h-5 w-5 text-accent" />
) : (
<Lock className="h-5 w-5 text-accent" />
)}
</div>
<div className="space-y-1.5">
<CardTitle className="text-2xl tracking-[-0.03em]">
{isSetupState ? t('auth.remoteSetupTitle') : t('auth.dashboardTitle')}
</CardTitle>
<CardDescription className="text-sm leading-6 text-muted-foreground">
{isSetupState ? heroDescription : t('auth.credentialsHint')}
</CardDescription>
</div>
</CardHeader>
<CardContent className="space-y-5 px-6 py-6">
{isSetupState ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('auth.signingIn')}
<Alert
variant="warning"
className="rounded-2xl border-yellow-300/70 bg-yellow-50/80"
>
<AlertCircle className="h-4 w-4" />
<AlertTitle>{t('auth.noDefaultCredentials')}</AlertTitle>
<AlertDescription>{t('auth.credentialsHint')}</AlertDescription>
</Alert>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-2xl border border-border/70 bg-background/75 p-4">
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
<Command className="h-4 w-4 text-accent" />
{t('auth.hostStepTitle')}
</div>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
{t('auth.hostStepDescription')}
</p>
<code className="mt-4 block rounded-xl border border-border/70 bg-muted/60 px-3 py-2 font-mono text-[13px] text-foreground">
ccs config auth setup
</code>
</div>
<div className="rounded-2xl border border-border/70 bg-background/75 p-4">
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
<ShieldCheck className="h-4 w-4 text-accent" />
{t('auth.localStepTitle')}
</div>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
{t('auth.localStepDescription')}
</p>
</div>
</div>
</>
) : (
t('auth.signIn')
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<Alert variant="destructive" className="rounded-2xl">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Alert variant="info" className="rounded-2xl border-blue-200/80 bg-blue-50/80">
<ShieldCheck className="h-4 w-4" />
<AlertTitle>{t('auth.noDefaultCredentials')}</AlertTitle>
<AlertDescription>{t('auth.credentialsHint')}</AlertDescription>
</Alert>
<div className="space-y-2">
<Label htmlFor="username" className="text-sm font-semibold">
{t('auth.username')}
</Label>
<Input
id="username"
type="text"
value={username}
onChange={(e) => 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
/>
</div>
<div className="space-y-2">
<Label htmlFor="password" className="text-sm font-semibold">
{t('auth.password')}
</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => 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
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute inset-y-1 right-1 h-auto rounded-xl px-3 text-muted-foreground hover:text-foreground"
aria-label={showPassword ? t('auth.hidePassword') : t('auth.showPassword')}
aria-pressed={showPassword}
onClick={() => setShowPassword((value) => !value)}
disabled={submitting}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
</div>
<Button
type="submit"
className="h-12 w-full rounded-2xl text-sm font-semibold"
disabled={submitting}
>
{submitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('auth.signingIn')}
</>
) : (
t('auth.signIn')
)}
</Button>
</form>
)}
</Button>
</form>
</CardContent>
</Card>
</CardContent>
</Card>
</div>
</div>
</div>
);
}
+81
View File
@@ -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<typeof import('react-router-dom')>('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(<LoginPage />);
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(<LoginPage />);
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');
});
});