mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
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:
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user