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:
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