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

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