feat: add password authentication support (closes #7)
Add token/password auth mode toggle on the login screen.
When password mode is selected, sends { password } instead of
{ token } in the WebSocket connect handshake.
Also adds clipboard utility tests and fixes credential test
to include authMode field.
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { Sparkles, Eye, EyeOff, Loader2 } from 'lucide-react';
|
||||
import { Sparkles, Eye, EyeOff, Loader2, Key, Lock } from 'lucide-react';
|
||||
import { useT } from '../hooks/useLocale';
|
||||
import { getStoredCredentials } from '../lib/credentials';
|
||||
import { getStoredCredentials, type AuthMode } from '../lib/credentials';
|
||||
|
||||
interface Props {
|
||||
onConnect: (url: string, token: string) => void;
|
||||
onConnect: (url: string, secret: string, authMode: AuthMode) => void;
|
||||
error?: string | null;
|
||||
isConnecting?: boolean;
|
||||
}
|
||||
@@ -31,11 +31,17 @@ function getInitialToken(): string {
|
||||
return stored?.token ?? '';
|
||||
}
|
||||
|
||||
function getInitialAuthMode(): AuthMode {
|
||||
const stored = getStoredCredentials();
|
||||
return stored?.authMode ?? 'token';
|
||||
}
|
||||
|
||||
export function LoginScreen({ onConnect, error, isConnecting }: Props) {
|
||||
const t = useT();
|
||||
const [url, setUrl] = useState(getInitialUrl);
|
||||
const [token, setToken] = useState(getInitialToken);
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
const [authMode, setAuthMode] = useState<AuthMode>(getInitialAuthMode);
|
||||
|
||||
const urlTrimmed = url.trim();
|
||||
const isValidWsUrl = /^wss?:\/\/.+/.test(urlTrimmed);
|
||||
@@ -44,7 +50,7 @@ export function LoginScreen({ onConnect, error, isConnecting }: Props) {
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!urlTrimmed || !token.trim() || !isValidWsUrl) return;
|
||||
onConnect(urlTrimmed, token.trim());
|
||||
onConnect(urlTrimmed, token.trim(), authMode);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -83,9 +89,39 @@ export function LoginScreen({ onConnect, error, isConnecting }: Props) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Auth mode toggle */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAuthMode('token')}
|
||||
disabled={isConnecting}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 rounded-xl border px-3 py-2 text-xs font-medium transition-all ${
|
||||
authMode === 'token'
|
||||
? 'border-[var(--pc-accent-dim)] bg-[var(--pc-accent-dim)]/10 text-pc-text'
|
||||
: 'border-pc-border bg-pc-elevated/30 text-pc-text-muted hover:bg-pc-elevated/50'
|
||||
}`}
|
||||
>
|
||||
<Key size={14} />
|
||||
{t('login.authToken')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAuthMode('password')}
|
||||
disabled={isConnecting}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 rounded-xl border px-3 py-2 text-xs font-medium transition-all ${
|
||||
authMode === 'password'
|
||||
? 'border-[var(--pc-accent-dim)] bg-[var(--pc-accent-dim)]/10 text-pc-text'
|
||||
: 'border-pc-border bg-pc-elevated/30 text-pc-text-muted hover:bg-pc-elevated/50'
|
||||
}`}
|
||||
>
|
||||
<Lock size={14} />
|
||||
{t('login.authPassword')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="gateway-token" className="block text-xs font-medium text-pc-text-secondary uppercase tracking-wider">
|
||||
{t('login.token')}
|
||||
{authMode === 'password' ? t('login.password') : t('login.token')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
@@ -93,7 +129,7 @@ export function LoginScreen({ onConnect, error, isConnecting }: Props) {
|
||||
type={showToken ? 'text' : 'password'}
|
||||
value={token}
|
||||
onChange={e => setToken(e.target.value)}
|
||||
placeholder={t('login.tokenPlaceholder')}
|
||||
placeholder={authMode === 'password' ? t('login.passwordPlaceholder') : t('login.tokenPlaceholder')}
|
||||
className="w-full rounded-xl border border-pc-border bg-pc-elevated/50 px-4 py-3 pr-12 text-sm text-pc-text placeholder:text-pc-text-faint outline-none focus:border-[var(--pc-accent-dim)] focus:ring-1 focus:ring-[var(--pc-accent-glow)] transition-all"
|
||||
autoComplete="current-password"
|
||||
disabled={isConnecting}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { GatewayClient, type JsonPayload } from '../lib/gateway';
|
||||
import { genIdempotencyKey } from '../lib/utils';
|
||||
import { getStoredCredentials, storeCredentials, clearCredentials } from '../lib/credentials';
|
||||
import { getStoredCredentials, storeCredentials, clearCredentials, type AuthMode } from '../lib/credentials';
|
||||
import { getOrCreateDeviceIdentity } from '../lib/deviceIdentity';
|
||||
import { isSystemEvent } from '../lib/systemEvent';
|
||||
import { getCachedMessages, setCachedMessages, mergeWithCache } from '../lib/messageCache';
|
||||
@@ -272,13 +272,13 @@ export function useGateway() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setupClient = useCallback(async (wsUrl: string, token: string) => {
|
||||
const setupClient = useCallback(async (wsUrl: string, token: string, authMode: AuthMode = 'token') => {
|
||||
// Tear down existing client
|
||||
if (clientRef.current) {
|
||||
clientRef.current.disconnect();
|
||||
}
|
||||
|
||||
const client = new GatewayClient(wsUrl, token);
|
||||
const client = new GatewayClient(wsUrl, token, authMode);
|
||||
clientRef.current = client;
|
||||
|
||||
// Load device identity for signed connect handshake
|
||||
@@ -296,7 +296,7 @@ export function useGateway() {
|
||||
setConnectError(null);
|
||||
setIsConnecting(false);
|
||||
isConnectingRef.current = false;
|
||||
storeCredentials(wsUrl, token);
|
||||
storeCredentials(wsUrl, token, authMode);
|
||||
loadSessions();
|
||||
loadAgentIdentity();
|
||||
loadHistory(activeSessionRef.current);
|
||||
@@ -445,7 +445,7 @@ export function useGateway() {
|
||||
const stored = getStoredCredentials();
|
||||
if (stored) {
|
||||
// Init on mount — setupClient sets state as part of establishing the connection
|
||||
setupClient(stored.url, stored.token);
|
||||
setupClient(stored.url, stored.token, stored.authMode || 'token');
|
||||
} else {
|
||||
setAuthenticated(false);
|
||||
}
|
||||
@@ -503,8 +503,8 @@ export function useGateway() {
|
||||
loadHistory(key);
|
||||
}, [loadHistory]);
|
||||
|
||||
const login = useCallback((url: string, token: string) => {
|
||||
setupClient(url, token);
|
||||
const login = useCallback((url: string, token: string, authMode: AuthMode = 'token') => {
|
||||
setupClient(url, token, authMode);
|
||||
}, [setupClient]);
|
||||
|
||||
const deleteSession = useCallback(async (key: string) => {
|
||||
|
||||
62
src/lib/__tests__/clipboard.test.ts
Normal file
62
src/lib/__tests__/clipboard.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { copyToClipboard } from '../clipboard';
|
||||
|
||||
describe('copyToClipboard', () => {
|
||||
beforeEach(() => {
|
||||
// jsdom doesn't define execCommand — add it so we can spy on it
|
||||
if (!document.execCommand) {
|
||||
(document as unknown as Record<string, unknown>).execCommand = () => false;
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('uses navigator.clipboard.writeText when available', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.assign(navigator, { clipboard: { writeText } });
|
||||
|
||||
const result = await copyToClipboard('hello');
|
||||
expect(writeText).toHaveBeenCalledWith('hello');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to execCommand when clipboard API throws', async () => {
|
||||
const writeText = vi.fn().mockRejectedValue(new Error('denied'));
|
||||
Object.assign(navigator, { clipboard: { writeText } });
|
||||
vi.spyOn(document, 'execCommand').mockReturnValue(true);
|
||||
|
||||
const result = await copyToClipboard('fallback text');
|
||||
expect(document.execCommand).toHaveBeenCalledWith('copy');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to execCommand when clipboard API is undefined', async () => {
|
||||
Object.assign(navigator, { clipboard: undefined });
|
||||
vi.spyOn(document, 'execCommand').mockReturnValue(true);
|
||||
|
||||
const result = await copyToClipboard('no clipboard');
|
||||
expect(document.execCommand).toHaveBeenCalledWith('copy');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when execCommand returns false', async () => {
|
||||
Object.assign(navigator, { clipboard: undefined });
|
||||
vi.spyOn(document, 'execCommand').mockReturnValue(false);
|
||||
|
||||
const result = await copyToClipboard('fail');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when both methods throw', async () => {
|
||||
Object.assign(navigator, { clipboard: { writeText: vi.fn().mockRejectedValue(new Error()) } });
|
||||
vi.spyOn(document, 'execCommand').mockImplementation(() => { throw new Error('not supported'); });
|
||||
|
||||
const result = await copyToClipboard('total fail');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -47,7 +47,7 @@ describe('storeCredentials', () => {
|
||||
storeCredentials('wss://gw', 'tok');
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
'pinchchat_credentials',
|
||||
JSON.stringify({ url: 'wss://gw', token: 'tok' }),
|
||||
JSON.stringify({ url: 'wss://gw', token: 'tok', authMode: 'token' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
const STORAGE_KEY = 'pinchchat_credentials';
|
||||
|
||||
export function getStoredCredentials(): { url: string; token: string } | null {
|
||||
export type AuthMode = 'token' | 'password';
|
||||
|
||||
export interface StoredCredentials {
|
||||
url: string;
|
||||
token: string;
|
||||
/** Auth mode — defaults to 'token' for backward compatibility */
|
||||
authMode?: AuthMode;
|
||||
}
|
||||
|
||||
export function getStoredCredentials(): StoredCredentials | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
@@ -12,8 +21,8 @@ export function getStoredCredentials(): { url: string; token: string } | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function storeCredentials(url: string, token: string) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ url, token }));
|
||||
export function storeCredentials(url: string, token: string, authMode: AuthMode = 'token') {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ url, token, authMode }));
|
||||
}
|
||||
|
||||
export function clearCredentials() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { genId } from './utils';
|
||||
import type { DeviceIdentity } from './deviceIdentity';
|
||||
import { buildDeviceAuthPayload, signPayload } from './deviceIdentity';
|
||||
import type { AuthMode } from './credentials';
|
||||
|
||||
/** Debug logger — enable with localStorage.setItem('pinchchat:debug', '1') */
|
||||
const isDebug = () => {
|
||||
@@ -41,17 +42,20 @@ export class GatewayClient {
|
||||
|
||||
private wsUrl: string;
|
||||
private authToken: string;
|
||||
private authMode: AuthMode = 'token';
|
||||
private deviceIdentity: DeviceIdentity | null = null;
|
||||
|
||||
constructor(wsUrl?: string, authToken?: string) {
|
||||
constructor(wsUrl?: string, authToken?: string, authMode?: AuthMode) {
|
||||
this.wsUrl = wsUrl || `ws://${window.location.hostname}:18789`;
|
||||
this.authToken = authToken || '';
|
||||
this.authMode = authMode || 'token';
|
||||
}
|
||||
|
||||
/** Update credentials (e.g. after login). Does not reconnect automatically. */
|
||||
setCredentials(wsUrl: string, authToken: string) {
|
||||
setCredentials(wsUrl: string, authToken: string, authMode?: AuthMode) {
|
||||
this.wsUrl = wsUrl;
|
||||
this.authToken = authToken;
|
||||
if (authMode) this.authMode = authMode;
|
||||
}
|
||||
|
||||
/** Set the device identity for signed connect handshakes. */
|
||||
@@ -154,7 +158,7 @@ export class GatewayClient {
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: {},
|
||||
auth: { token: this.authToken },
|
||||
auth: this.authMode === 'password' ? { password: this.authToken } : { token: this.authToken },
|
||||
device,
|
||||
locale: (typeof navigator !== 'undefined' ? navigator.language : undefined) || 'en',
|
||||
userAgent: `pinchchat/${__APP_VERSION__}`,
|
||||
|
||||
@@ -14,6 +14,10 @@ const en = {
|
||||
'login.gatewayUrl': 'Gateway URL',
|
||||
'login.token': 'Token',
|
||||
'login.tokenPlaceholder': 'Enter your gateway token',
|
||||
'login.authToken': 'Token',
|
||||
'login.authPassword': 'Password',
|
||||
'login.password': 'Password',
|
||||
'login.passwordPlaceholder': 'Enter your gateway password',
|
||||
'login.connect': 'Connect',
|
||||
'login.connecting': 'Connecting…',
|
||||
'login.showToken': 'Show token',
|
||||
@@ -191,6 +195,10 @@ const fr: Record<keyof typeof en, string> = {
|
||||
'login.gatewayUrl': 'URL de la gateway',
|
||||
'login.token': 'Token',
|
||||
'login.tokenPlaceholder': 'Entrez votre token gateway',
|
||||
'login.authToken': 'Token',
|
||||
'login.authPassword': 'Mot de passe',
|
||||
'login.password': 'Mot de passe',
|
||||
'login.passwordPlaceholder': 'Entrez votre mot de passe gateway',
|
||||
'login.connect': 'Connexion',
|
||||
'login.connecting': 'Connexion…',
|
||||
'login.showToken': 'Afficher le token',
|
||||
@@ -353,6 +361,10 @@ const es: Record<keyof typeof en, string> = {
|
||||
'login.gatewayUrl': 'URL del gateway',
|
||||
'login.token': 'Token',
|
||||
'login.tokenPlaceholder': 'Introduce tu token de gateway',
|
||||
'login.authToken': 'Token',
|
||||
'login.authPassword': 'Contraseña',
|
||||
'login.password': 'Contraseña',
|
||||
'login.passwordPlaceholder': 'Introduce tu contraseña de gateway',
|
||||
'login.connect': 'Conectar',
|
||||
'login.connecting': 'Conectando…',
|
||||
'login.showToken': 'Mostrar token',
|
||||
@@ -517,6 +529,10 @@ const de: Record<keyof typeof en, string> = {
|
||||
'login.gatewayUrl': 'Gateway-URL',
|
||||
'login.token': 'Token',
|
||||
'login.tokenPlaceholder': 'Gateway-Token eingeben',
|
||||
'login.authToken': 'Token',
|
||||
'login.authPassword': 'Passwort',
|
||||
'login.password': 'Passwort',
|
||||
'login.passwordPlaceholder': 'Gateway-Passwort eingeben',
|
||||
'login.connect': 'Verbinden',
|
||||
'login.connecting': 'Verbinde…',
|
||||
'login.showToken': 'Token anzeigen',
|
||||
@@ -679,6 +695,10 @@ const ja: Record<keyof typeof en, string> = {
|
||||
'login.gatewayUrl': 'ゲートウェイURL',
|
||||
'login.token': 'トークン',
|
||||
'login.tokenPlaceholder': 'ゲートウェイトークンを入力',
|
||||
'login.authToken': 'トークン',
|
||||
'login.authPassword': 'パスワード',
|
||||
'login.password': 'パスワード',
|
||||
'login.passwordPlaceholder': 'ゲートウェイパスワードを入力',
|
||||
'login.connect': '接続',
|
||||
'login.connecting': '接続中…',
|
||||
'login.showToken': 'トークンを表示',
|
||||
@@ -841,6 +861,10 @@ const pt: Record<keyof typeof en, string> = {
|
||||
'login.gatewayUrl': 'URL do Gateway',
|
||||
'login.token': 'Token',
|
||||
'login.tokenPlaceholder': 'Insira o token do gateway',
|
||||
'login.authToken': 'Token',
|
||||
'login.authPassword': 'Senha',
|
||||
'login.password': 'Senha',
|
||||
'login.passwordPlaceholder': 'Insira a senha do gateway',
|
||||
'login.connect': 'Conectar',
|
||||
'login.connecting': 'Conectando…',
|
||||
'login.showToken': 'Mostrar token',
|
||||
@@ -1003,6 +1027,10 @@ const zh: Record<keyof typeof en, string> = {
|
||||
'login.gatewayUrl': '网关地址',
|
||||
'login.token': '令牌',
|
||||
'login.tokenPlaceholder': '输入网关令牌',
|
||||
'login.authToken': '令牌',
|
||||
'login.authPassword': '密码',
|
||||
'login.password': '密码',
|
||||
'login.passwordPlaceholder': '输入网关密码',
|
||||
'login.connect': '连接',
|
||||
'login.connecting': '连接中…',
|
||||
'login.showToken': '显示令牌',
|
||||
@@ -1165,6 +1193,10 @@ const it: Record<keyof typeof en, string> = {
|
||||
'login.gatewayUrl': 'URL del Gateway',
|
||||
'login.token': 'Token',
|
||||
'login.tokenPlaceholder': 'Inserisci il token del gateway',
|
||||
'login.authToken': 'Token',
|
||||
'login.authPassword': 'Password',
|
||||
'login.password': 'Password',
|
||||
'login.passwordPlaceholder': 'Inserisci la password del gateway',
|
||||
'login.connect': 'Connetti',
|
||||
'login.connecting': 'Connessione…',
|
||||
'login.showToken': 'Mostra token',
|
||||
|
||||
Reference in New Issue
Block a user