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:
Nicolas Varrot
2026-02-18 22:07:06 +00:00
parent 16db1cf811
commit 5c47dd2aeb
9 changed files with 745 additions and 21 deletions

View File

@@ -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}

View File

@@ -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) => {

View 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);
});
});

View File

@@ -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' }),
);
});
});

View File

@@ -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() {

View File

@@ -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__}`,

View File

@@ -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',