fix(docker): add missing @bytelyst packages to vendor directory for Docker builds
This commit is contained in:
parent
6f9c97e80c
commit
69cd2b30f3
20
vendor/bytelyst/accessibility/package.json
vendored
Normal file
20
vendor/bytelyst/accessibility/package.json
vendored
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "@bytelyst/accessibility",
|
||||||
|
"version": "0.1.5",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc"
|
||||||
|
},
|
||||||
|
"dependencies": {},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
204
vendor/bytelyst/accessibility/src/index.ts
vendored
Normal file
204
vendor/bytelyst/accessibility/src/index.ts
vendored
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
export type AlertA11yProps = {
|
||||||
|
role: 'alert';
|
||||||
|
'aria-live': 'assertive' | 'polite';
|
||||||
|
'aria-label': string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function alertLabel(level: string, description: string): AlertA11yProps {
|
||||||
|
return {
|
||||||
|
role: 'alert',
|
||||||
|
'aria-live': level === 'danger' ? 'assertive' : 'polite',
|
||||||
|
'aria-label': description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const FOCUSABLE_SELECTOR = [
|
||||||
|
'a[href]',
|
||||||
|
'button:not([disabled])',
|
||||||
|
'input:not([disabled])',
|
||||||
|
'select:not([disabled])',
|
||||||
|
'textarea:not([disabled])',
|
||||||
|
'[tabindex]:not([tabindex="-1"])',
|
||||||
|
].join(',');
|
||||||
|
|
||||||
|
export function getFocusableElements(container: HTMLElement): HTMLElement[] {
|
||||||
|
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
||||||
|
element =>
|
||||||
|
!element.hasAttribute('disabled') &&
|
||||||
|
element.getAttribute('aria-hidden') !== 'true' &&
|
||||||
|
element.offsetParent !== null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trapFocusKeydown(event: KeyboardEvent, container: HTMLElement): void {
|
||||||
|
if (event.key !== 'Tab') return;
|
||||||
|
|
||||||
|
const focusable = getFocusableElements(container);
|
||||||
|
if (focusable.length === 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable[focusable.length - 1];
|
||||||
|
|
||||||
|
if (event.shiftKey && document.activeElement === first) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!event.shiftKey && document.activeElement === last) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function focusFirstElement(container: HTMLElement, selector = FOCUSABLE_SELECTOR): void {
|
||||||
|
const preferred = container.querySelector<HTMLElement>(selector);
|
||||||
|
const fallback = getFocusableElements(container)[0];
|
||||||
|
(preferred ?? fallback)?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScreenReaderPoliteness = 'assertive' | 'polite';
|
||||||
|
|
||||||
|
export function announceToScreenReader(
|
||||||
|
message: string,
|
||||||
|
politeness: ScreenReaderPoliteness = 'polite'
|
||||||
|
): void {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
|
||||||
|
const id = `bytelyst-sr-announcer-${politeness}`;
|
||||||
|
let announcer = document.getElementById(id);
|
||||||
|
if (!announcer) {
|
||||||
|
announcer = document.createElement('div');
|
||||||
|
announcer.id = id;
|
||||||
|
announcer.setAttribute('aria-live', politeness);
|
||||||
|
announcer.setAttribute('aria-atomic', 'true');
|
||||||
|
announcer.style.position = 'absolute';
|
||||||
|
announcer.style.width = '1px';
|
||||||
|
announcer.style.height = '1px';
|
||||||
|
announcer.style.margin = '-1px';
|
||||||
|
announcer.style.padding = '0';
|
||||||
|
announcer.style.overflow = 'hidden';
|
||||||
|
announcer.style.clip = 'rect(0 0 0 0)';
|
||||||
|
announcer.style.whiteSpace = 'nowrap';
|
||||||
|
announcer.style.border = '0';
|
||||||
|
document.body.appendChild(announcer);
|
||||||
|
}
|
||||||
|
|
||||||
|
announcer.textContent = '';
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (announcer) {
|
||||||
|
announcer.textContent = message;
|
||||||
|
}
|
||||||
|
}, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProgressbarA11yProps = {
|
||||||
|
role: 'progressbar';
|
||||||
|
'aria-label': string;
|
||||||
|
'aria-valuenow': number;
|
||||||
|
'aria-valuemin': number;
|
||||||
|
'aria-valuemax': number;
|
||||||
|
'aria-valuetext': string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function progressLabel(
|
||||||
|
label: string,
|
||||||
|
valuePct: number,
|
||||||
|
description: string
|
||||||
|
): ProgressbarA11yProps {
|
||||||
|
return {
|
||||||
|
role: 'progressbar',
|
||||||
|
'aria-label': label,
|
||||||
|
'aria-valuenow': valuePct,
|
||||||
|
'aria-valuemin': 0,
|
||||||
|
'aria-valuemax': 100,
|
||||||
|
'aria-valuetext': description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AriaLabelOnly = { 'aria-label': string };
|
||||||
|
|
||||||
|
export function streakLabel(days: number): AriaLabelOnly {
|
||||||
|
return { 'aria-label': `${days} day streak` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ButtonA11yProps = {
|
||||||
|
'aria-label': string;
|
||||||
|
'aria-roledescription'?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buttonLabel(text: string, hint?: string): ButtonA11yProps {
|
||||||
|
return {
|
||||||
|
'aria-label': text,
|
||||||
|
...(hint ? { 'aria-roledescription': hint } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function achievementLabel(name: string, description: string): AriaLabelOnly {
|
||||||
|
return { 'aria-label': `Achievement: ${name} — ${description}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TimerA11yProps = {
|
||||||
|
'aria-label': string;
|
||||||
|
'aria-live': 'polite';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function timerLabel(
|
||||||
|
hours: number,
|
||||||
|
minutes: number,
|
||||||
|
seconds: number,
|
||||||
|
status: string
|
||||||
|
): TimerA11yProps {
|
||||||
|
return {
|
||||||
|
'aria-label': `Timer: ${hours}h ${minutes}m ${seconds}s, ${status}`,
|
||||||
|
'aria-live': 'polite',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SliderA11yProps = {
|
||||||
|
role: 'slider';
|
||||||
|
'aria-label': string;
|
||||||
|
'aria-valuenow': number;
|
||||||
|
'aria-valuemin': number;
|
||||||
|
'aria-valuemax': number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function sliderLabel(
|
||||||
|
label: string,
|
||||||
|
value: number,
|
||||||
|
min: number,
|
||||||
|
max: number
|
||||||
|
): SliderA11yProps {
|
||||||
|
return {
|
||||||
|
role: 'slider',
|
||||||
|
'aria-label': label,
|
||||||
|
'aria-valuenow': value,
|
||||||
|
'aria-valuemin': min,
|
||||||
|
'aria-valuemax': max,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function plural(n: number, singular: string, pluralForm: string): string {
|
||||||
|
const word = n === 1 ? singular : pluralForm;
|
||||||
|
return `${n} ${word}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spoken-friendly duration from a fractional hour value, e.g. 12 → "12 hours", 1.5 → "1 hour 30 minutes".
|
||||||
|
*/
|
||||||
|
export function formatDurationForA11y(hours: number): string {
|
||||||
|
const totalMinutes = Math.round(hours * 60);
|
||||||
|
const h = Math.floor(totalMinutes / 60);
|
||||||
|
const m = totalMinutes % 60;
|
||||||
|
|
||||||
|
if (h === 0 && m === 0) {
|
||||||
|
return '0 minutes';
|
||||||
|
}
|
||||||
|
if (m === 0) {
|
||||||
|
return plural(h, 'hour', 'hours');
|
||||||
|
}
|
||||||
|
if (h === 0) {
|
||||||
|
return plural(m, 'minute', 'minutes');
|
||||||
|
}
|
||||||
|
return `${plural(h, 'hour', 'hours')} ${plural(m, 'minute', 'minutes')}`;
|
||||||
|
}
|
||||||
18
vendor/bytelyst/accessibility/tsconfig.json
vendored
Normal file
18
vendor/bytelyst/accessibility/tsconfig.json
vendored
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"lib": ["ES2022", "DOM"],
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
24
vendor/bytelyst/auth-client/package.json
vendored
Normal file
24
vendor/bytelyst/auth-client/package.json
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "@bytelyst/auth-client",
|
||||||
|
"version": "0.1.5",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Browser/React Native-safe auth API client for platform-service",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"test": "vitest run --pool forks"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/"
|
||||||
|
}
|
||||||
|
}
|
||||||
346
vendor/bytelyst/auth-client/src/__tests__/auth-client.test.ts
vendored
Normal file
346
vendor/bytelyst/auth-client/src/__tests__/auth-client.test.ts
vendored
Normal file
@ -0,0 +1,346 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { createAuthClient } from '../client.js';
|
||||||
|
import type { TokenStorage } from '../types.js';
|
||||||
|
|
||||||
|
function createMockStorage(): TokenStorage & { store: Map<string, string> } {
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
getItem: (key: string) => store.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => store.set(key, value),
|
||||||
|
removeItem: (key: string) => store.delete(key),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockFetchResponse(data: unknown, status = 200) {
|
||||||
|
return vi.fn().mockResolvedValue({
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
json: () => Promise.resolve(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('@bytelyst/auth-client', () => {
|
||||||
|
let storage: ReturnType<typeof createMockStorage>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storage = createMockStorage();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createAuthClient', () => {
|
||||||
|
it('creates a client with all expected methods', () => {
|
||||||
|
const client = createAuthClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(client.login).toBeTypeOf('function');
|
||||||
|
expect(client.register).toBeTypeOf('function');
|
||||||
|
expect(client.getMe).toBeTypeOf('function');
|
||||||
|
expect(client.refreshAccessToken).toBeTypeOf('function');
|
||||||
|
expect(client.forgotPassword).toBeTypeOf('function');
|
||||||
|
expect(client.resetPassword).toBeTypeOf('function');
|
||||||
|
expect(client.changePassword).toBeTypeOf('function');
|
||||||
|
expect(client.deleteAccount).toBeTypeOf('function');
|
||||||
|
expect(client.verifyEmail).toBeTypeOf('function');
|
||||||
|
expect(client.resendVerification).toBeTypeOf('function');
|
||||||
|
expect(client.getAccessToken).toBeTypeOf('function');
|
||||||
|
expect(client.getRefreshToken).toBeTypeOf('function');
|
||||||
|
expect(client.setTokens).toBeTypeOf('function');
|
||||||
|
expect(client.clearTokens).toBeTypeOf('function');
|
||||||
|
expect(client.isAuthenticated).toBeTypeOf('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('token management', () => {
|
||||||
|
it('stores and retrieves tokens', () => {
|
||||||
|
const client = createAuthClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(client.isAuthenticated()).toBe(false);
|
||||||
|
expect(client.getAccessToken()).toBeNull();
|
||||||
|
|
||||||
|
client.setTokens('access-123', 'refresh-456');
|
||||||
|
|
||||||
|
expect(client.isAuthenticated()).toBe(true);
|
||||||
|
expect(client.getAccessToken()).toBe('access-123');
|
||||||
|
expect(client.getRefreshToken()).toBe('refresh-456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears tokens', () => {
|
||||||
|
const client = createAuthClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
|
||||||
|
client.setTokens('access-123', 'refresh-456');
|
||||||
|
client.clearTokens();
|
||||||
|
|
||||||
|
expect(client.isAuthenticated()).toBe(false);
|
||||||
|
expect(client.getAccessToken()).toBeNull();
|
||||||
|
expect(client.getRefreshToken()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses productId as storage key prefix by default', () => {
|
||||||
|
createAuthClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'chronomind',
|
||||||
|
storage,
|
||||||
|
}).setTokens('a', 'b');
|
||||||
|
|
||||||
|
expect(storage.store.get('chronomind-auth-token')).toBe('a');
|
||||||
|
expect(storage.store.get('chronomind-refresh-token')).toBe('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects custom storagePrefix', () => {
|
||||||
|
createAuthClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'chronomind',
|
||||||
|
storagePrefix: 'cm',
|
||||||
|
storage,
|
||||||
|
}).setTokens('a', 'b');
|
||||||
|
|
||||||
|
expect(storage.store.get('cm-auth-token')).toBe('a');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('login', () => {
|
||||||
|
it('sends correct request and stores tokens', async () => {
|
||||||
|
const mockData = {
|
||||||
|
accessToken: 'at-123',
|
||||||
|
refreshToken: 'rt-456',
|
||||||
|
user: { id: 'u1', email: 'a@b.com', displayName: 'A', role: 'user', plan: 'free' },
|
||||||
|
};
|
||||||
|
globalThis.fetch = mockFetchResponse(mockData);
|
||||||
|
|
||||||
|
const client = createAuthClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await client.login('a@b.com', 'pass123');
|
||||||
|
|
||||||
|
expect(result.user.email).toBe('a@b.com');
|
||||||
|
expect(client.getAccessToken()).toBe('at-123');
|
||||||
|
expect(client.getRefreshToken()).toBe('rt-456');
|
||||||
|
|
||||||
|
const [url, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
expect(url).toBe('http://localhost:4003/api/auth/login');
|
||||||
|
expect(opts.method).toBe('POST');
|
||||||
|
const body = JSON.parse(opts.body);
|
||||||
|
expect(body.email).toBe('a@b.com');
|
||||||
|
expect(body.productId).toBe('testapp');
|
||||||
|
|
||||||
|
expect(opts.headers['x-product-id']).toBe('testapp');
|
||||||
|
expect(opts.headers['x-request-id']).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on login failure', async () => {
|
||||||
|
globalThis.fetch = mockFetchResponse({ message: 'Invalid credentials' }, 401);
|
||||||
|
|
||||||
|
const client = createAuthClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(client.login('a@b.com', 'wrong')).rejects.toThrow('Invalid credentials');
|
||||||
|
expect(client.isAuthenticated()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('register', () => {
|
||||||
|
it('sends correct request and stores tokens', async () => {
|
||||||
|
const mockData = {
|
||||||
|
accessToken: 'at-new',
|
||||||
|
refreshToken: 'rt-new',
|
||||||
|
user: { id: 'u2', email: 'new@b.com', displayName: 'New', role: 'user', plan: 'free' },
|
||||||
|
};
|
||||||
|
globalThis.fetch = mockFetchResponse(mockData);
|
||||||
|
|
||||||
|
const client = createAuthClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'nomgap',
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await client.register('new@b.com', 'pass1234', 'New User');
|
||||||
|
|
||||||
|
expect(result.user.displayName).toBe('New');
|
||||||
|
expect(client.getAccessToken()).toBe('at-new');
|
||||||
|
|
||||||
|
const body = JSON.parse((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body);
|
||||||
|
expect(body.displayName).toBe('New User');
|
||||||
|
expect(body.productId).toBe('nomgap');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getMe', () => {
|
||||||
|
it('sends authorization header', async () => {
|
||||||
|
const mockUser = { id: 'u1', email: 'a@b.com', displayName: 'A', role: 'user', plan: 'free' };
|
||||||
|
globalThis.fetch = mockFetchResponse(mockUser);
|
||||||
|
|
||||||
|
const client = createAuthClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
client.setTokens('my-token', 'my-refresh');
|
||||||
|
|
||||||
|
const user = await client.getMe();
|
||||||
|
expect(user.email).toBe('a@b.com');
|
||||||
|
|
||||||
|
const opts = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1];
|
||||||
|
expect(opts.headers['Authorization']).toBe('Bearer my-token');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('refreshAccessToken', () => {
|
||||||
|
it('refreshes and stores new tokens', async () => {
|
||||||
|
const mockRefresh = { accessToken: 'at-refreshed', refreshToken: 'rt-refreshed' };
|
||||||
|
globalThis.fetch = mockFetchResponse(mockRefresh);
|
||||||
|
|
||||||
|
const client = createAuthClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
client.setTokens('old-at', 'old-rt');
|
||||||
|
|
||||||
|
const ok = await client.refreshAccessToken();
|
||||||
|
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
expect(client.getAccessToken()).toBe('at-refreshed');
|
||||||
|
expect(client.getRefreshToken()).toBe('rt-refreshed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears tokens on refresh failure', async () => {
|
||||||
|
globalThis.fetch = mockFetchResponse({ error: 'expired' }, 401);
|
||||||
|
|
||||||
|
const client = createAuthClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
client.setTokens('old-at', 'old-rt');
|
||||||
|
|
||||||
|
const ok = await client.refreshAccessToken();
|
||||||
|
|
||||||
|
expect(ok).toBe(false);
|
||||||
|
expect(client.isAuthenticated()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false if no refresh token', async () => {
|
||||||
|
const client = createAuthClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ok = await client.refreshAccessToken();
|
||||||
|
expect(ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('forgotPassword', () => {
|
||||||
|
it('sends email and productId', async () => {
|
||||||
|
globalThis.fetch = mockFetchResponse({ message: 'Reset email sent' });
|
||||||
|
|
||||||
|
const client = createAuthClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'mindlyst',
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await client.forgotPassword('user@test.com');
|
||||||
|
expect(result.message).toBe('Reset email sent');
|
||||||
|
|
||||||
|
const body = JSON.parse((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body);
|
||||||
|
expect(body.email).toBe('user@test.com');
|
||||||
|
expect(body.productId).toBe('mindlyst');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('changePassword', () => {
|
||||||
|
it('sends authenticated request', async () => {
|
||||||
|
globalThis.fetch = mockFetchResponse({ message: 'Password changed' });
|
||||||
|
|
||||||
|
const client = createAuthClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
client.setTokens('tok', 'ref');
|
||||||
|
|
||||||
|
const result = await client.changePassword('old', 'new12345');
|
||||||
|
expect(result.message).toBe('Password changed');
|
||||||
|
|
||||||
|
const [, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
expect(opts.headers['Authorization']).toBe('Bearer tok');
|
||||||
|
const body = JSON.parse(opts.body);
|
||||||
|
expect(body.currentPassword).toBe('old');
|
||||||
|
expect(body.newPassword).toBe('new12345');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteAccount', () => {
|
||||||
|
it('clears tokens after deletion', async () => {
|
||||||
|
globalThis.fetch = mockFetchResponse({ message: 'Deleted' });
|
||||||
|
|
||||||
|
const client = createAuthClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
client.setTokens('tok', 'ref');
|
||||||
|
|
||||||
|
await client.deleteAccount('mypassword');
|
||||||
|
|
||||||
|
expect(client.isAuthenticated()).toBe(false);
|
||||||
|
|
||||||
|
const [url, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
expect(url).toContain('/auth/account');
|
||||||
|
expect(opts.method).toBe('DELETE');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('verifyEmail', () => {
|
||||||
|
it('sends verification token', async () => {
|
||||||
|
globalThis.fetch = mockFetchResponse({ message: 'Verified' });
|
||||||
|
|
||||||
|
const client = createAuthClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await client.verifyEmail('verify-token-abc');
|
||||||
|
expect(result.message).toBe('Verified');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resendVerification', () => {
|
||||||
|
it('sends email and productId', async () => {
|
||||||
|
globalThis.fetch = mockFetchResponse({ message: 'Sent' });
|
||||||
|
|
||||||
|
const client = createAuthClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'chronomind',
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.resendVerification('user@test.com');
|
||||||
|
|
||||||
|
const body = JSON.parse((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body);
|
||||||
|
expect(body.email).toBe('user@test.com');
|
||||||
|
expect(body.productId).toBe('chronomind');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
571
vendor/bytelyst/auth-client/src/__tests__/smartauth.test.ts
vendored
Normal file
571
vendor/bytelyst/auth-client/src/__tests__/smartauth.test.ts
vendored
Normal file
@ -0,0 +1,571 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { createAuthClient } from '../client.js';
|
||||||
|
import type { TokenStorage } from '../types.js';
|
||||||
|
|
||||||
|
function createMockStorage(): TokenStorage & { store: Map<string, string> } {
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
getItem: (key: string) => store.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => store.set(key, value),
|
||||||
|
removeItem: (key: string) => store.delete(key),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockFetchResponse(data: unknown, status = 200) {
|
||||||
|
return vi.fn().mockResolvedValue({
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
json: () => Promise.resolve(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('@bytelyst/auth-client — SmartAuth', () => {
|
||||||
|
let storage: ReturnType<typeof createMockStorage>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storage = createMockStorage();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Phase 1C: Google Sign-In ──────────────────────
|
||||||
|
|
||||||
|
describe('loginWithGoogle', () => {
|
||||||
|
it('calls POST /auth/oauth/google and stores tokens', async () => {
|
||||||
|
const mockData = {
|
||||||
|
accessToken: 'google-at',
|
||||||
|
refreshToken: 'google-rt',
|
||||||
|
user: { id: 'u1', email: 'g@gmail.com', displayName: 'G', role: 'user', plan: 'free' },
|
||||||
|
};
|
||||||
|
globalThis.fetch = mockFetchResponse(mockData);
|
||||||
|
|
||||||
|
const client = createAuthClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await client.loginWithGoogle('google-id-token-123');
|
||||||
|
|
||||||
|
expect('user' in result).toBe(true);
|
||||||
|
if ('user' in result) {
|
||||||
|
expect(result.user.email).toBe('g@gmail.com');
|
||||||
|
}
|
||||||
|
expect(client.getAccessToken()).toBe('google-at');
|
||||||
|
expect(client.getRefreshToken()).toBe('google-rt');
|
||||||
|
|
||||||
|
const [url, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
expect(url).toBe('http://localhost:4003/api/auth/oauth/google');
|
||||||
|
expect(opts.method).toBe('POST');
|
||||||
|
const body = JSON.parse(opts.body);
|
||||||
|
expect(body.idToken).toBe('google-id-token-123');
|
||||||
|
expect(body.productId).toBe('testapp');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns MFA challenge when mfaRequired is true', async () => {
|
||||||
|
const mfaData = {
|
||||||
|
mfaRequired: true,
|
||||||
|
mfaChallenge: 'challenge-token-abc',
|
||||||
|
methods: ['totp'],
|
||||||
|
};
|
||||||
|
globalThis.fetch = mockFetchResponse(mfaData);
|
||||||
|
|
||||||
|
const client = createAuthClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await client.loginWithGoogle('google-id-token-456');
|
||||||
|
|
||||||
|
expect('mfaRequired' in result).toBe(true);
|
||||||
|
if ('mfaRequired' in result) {
|
||||||
|
expect(result.mfaRequired).toBe(true);
|
||||||
|
expect(result.mfaChallenge).toBe('challenge-token-abc');
|
||||||
|
expect(result.methods).toEqual(['totp']);
|
||||||
|
}
|
||||||
|
// Tokens should NOT be stored when MFA is required
|
||||||
|
expect(client.isAuthenticated()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loginWithMicrosoft', () => {
|
||||||
|
it('calls POST /auth/oauth/microsoft', async () => {
|
||||||
|
const mockData = {
|
||||||
|
accessToken: 'ms-at',
|
||||||
|
refreshToken: 'ms-rt',
|
||||||
|
user: { id: 'u2', email: 'ms@outlook.com', displayName: 'MS', role: 'user', plan: 'free' },
|
||||||
|
};
|
||||||
|
globalThis.fetch = mockFetchResponse(mockData);
|
||||||
|
|
||||||
|
const client = createAuthClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.loginWithMicrosoft('ms-id-token');
|
||||||
|
|
||||||
|
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
expect(url).toBe('http://localhost:4003/api/auth/oauth/microsoft');
|
||||||
|
expect(client.getAccessToken()).toBe('ms-at');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loginWithApple', () => {
|
||||||
|
it('calls POST /auth/oauth/apple', async () => {
|
||||||
|
const mockData = {
|
||||||
|
accessToken: 'apple-at',
|
||||||
|
refreshToken: 'apple-rt',
|
||||||
|
user: { id: 'u3', email: 'a@icloud.com', displayName: 'A', role: 'user', plan: 'free' },
|
||||||
|
};
|
||||||
|
globalThis.fetch = mockFetchResponse(mockData);
|
||||||
|
|
||||||
|
const client = createAuthClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.loginWithApple('apple-id-token');
|
||||||
|
|
||||||
|
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
expect(url).toBe('http://localhost:4003/api/auth/oauth/apple');
|
||||||
|
expect(client.getAccessToken()).toBe('apple-at');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Phase 1C: Provider management ─────────────────
|
||||||
|
|
||||||
|
describe('getProviders', () => {
|
||||||
|
it('calls GET /auth/providers', async () => {
|
||||||
|
const providers = {
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provider: 'google',
|
||||||
|
email: 'g@gmail.com',
|
||||||
|
linkedAt: '2025-01-01T00:00:00Z',
|
||||||
|
lastUsedAt: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
globalThis.fetch = mockFetchResponse(providers);
|
||||||
|
|
||||||
|
const client = createAuthClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
client.setTokens('tok', 'ref');
|
||||||
|
|
||||||
|
const result = await client.getProviders();
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].provider).toBe('google');
|
||||||
|
|
||||||
|
const [url, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
expect(url).toBe('http://localhost:4003/api/auth/providers');
|
||||||
|
expect(opts.method).toBe('GET');
|
||||||
|
expect(opts.headers['Authorization']).toBe('Bearer tok');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('linkProvider', () => {
|
||||||
|
it('calls POST /auth/providers/link with provider and idToken', async () => {
|
||||||
|
globalThis.fetch = mockFetchResponse({});
|
||||||
|
|
||||||
|
const client = createAuthClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
client.setTokens('tok', 'ref');
|
||||||
|
|
||||||
|
await client.linkProvider('google', 'link-token');
|
||||||
|
|
||||||
|
const [url, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
expect(url).toBe('http://localhost:4003/api/auth/providers/link');
|
||||||
|
expect(opts.method).toBe('POST');
|
||||||
|
const body = JSON.parse(opts.body);
|
||||||
|
expect(body.provider).toBe('google');
|
||||||
|
expect(body.idToken).toBe('link-token');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unlinkProvider', () => {
|
||||||
|
it('calls DELETE /auth/providers/:provider', async () => {
|
||||||
|
globalThis.fetch = mockFetchResponse({});
|
||||||
|
|
||||||
|
const client = createAuthClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
client.setTokens('tok', 'ref');
|
||||||
|
|
||||||
|
await client.unlinkProvider('google');
|
||||||
|
|
||||||
|
const [url, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
expect(url).toBe('http://localhost:4003/api/auth/providers/google');
|
||||||
|
expect(opts.method).toBe('DELETE');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Phase 2D: MFA ─────────────────────────────────
|
||||||
|
|
||||||
|
describe('verifyMfa', () => {
|
||||||
|
it('sends challenge token and TOTP code, stores tokens', async () => {
|
||||||
|
const mockData = {
|
||||||
|
accessToken: 'mfa-at',
|
||||||
|
refreshToken: 'mfa-rt',
|
||||||
|
user: { id: 'u1', email: 'a@b.com', displayName: 'A', role: 'user', plan: 'free' },
|
||||||
|
};
|
||||||
|
globalThis.fetch = mockFetchResponse(mockData);
|
||||||
|
|
||||||
|
const client = createAuthClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await client.verifyMfa('challenge-xyz', '123456', 'totp');
|
||||||
|
|
||||||
|
expect(result.user.email).toBe('a@b.com');
|
||||||
|
expect(client.getAccessToken()).toBe('mfa-at');
|
||||||
|
|
||||||
|
const body = JSON.parse((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body);
|
||||||
|
expect(body.challengeToken).toBe('challenge-xyz');
|
||||||
|
expect(body.code).toBe('123456');
|
||||||
|
expect(body.method).toBe('totp');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setupTotp', () => {
|
||||||
|
it('calls POST /auth/mfa/setup', async () => {
|
||||||
|
const setup = {
|
||||||
|
secret: 'ABCDEFGH',
|
||||||
|
otpauthUri: 'otpauth://totp/Test?secret=ABC',
|
||||||
|
recoveryCodes: ['code1', 'code2'],
|
||||||
|
};
|
||||||
|
globalThis.fetch = mockFetchResponse(setup);
|
||||||
|
|
||||||
|
const client = createAuthClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
client.setTokens('tok', 'ref');
|
||||||
|
|
||||||
|
const result = await client.setupTotp();
|
||||||
|
|
||||||
|
expect(result.otpauthUri).toContain('otpauth://');
|
||||||
|
expect(result.secret).toBe('ABCDEFGH');
|
||||||
|
expect(result.recoveryCodes).toHaveLength(2);
|
||||||
|
|
||||||
|
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
expect(url).toBe('http://localhost:4003/api/auth/mfa/setup');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getMfaStatus', () => {
|
||||||
|
it('returns MFA status', async () => {
|
||||||
|
const status = { mfaEnabled: true, methods: ['totp'], recoveryCodesRemaining: 6 };
|
||||||
|
globalThis.fetch = mockFetchResponse(status);
|
||||||
|
|
||||||
|
const client = createAuthClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
client.setTokens('tok', 'ref');
|
||||||
|
|
||||||
|
const result = await client.getMfaStatus();
|
||||||
|
expect(result.mfaEnabled).toBe(true);
|
||||||
|
expect(result.methods).toContain('totp');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Phase 3: Passkeys ─────────────────────────────
|
||||||
|
|
||||||
|
describe('listPasskeys', () => {
|
||||||
|
it('calls GET /auth/passkeys and unwraps response', async () => {
|
||||||
|
const data = {
|
||||||
|
passkeys: [
|
||||||
|
{
|
||||||
|
credentialId: 'pk1',
|
||||||
|
friendlyName: 'MacBook',
|
||||||
|
deviceType: 'singleDevice',
|
||||||
|
backedUp: true,
|
||||||
|
lastUsedAt: '2025-01-01T00:00:00Z',
|
||||||
|
createdAt: '2025-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
globalThis.fetch = mockFetchResponse(data);
|
||||||
|
|
||||||
|
const client = createAuthClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
client.setTokens('tok', 'ref');
|
||||||
|
|
||||||
|
const result = await client.listPasskeys();
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].friendlyName).toBe('MacBook');
|
||||||
|
expect(result[0].credentialId).toBe('pk1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('verifyPasskeyAuth', () => {
|
||||||
|
it('stores tokens after passkey authentication', async () => {
|
||||||
|
const mockData = {
|
||||||
|
accessToken: 'pk-at',
|
||||||
|
refreshToken: 'pk-rt',
|
||||||
|
user: { id: 'u1', email: 'a@b.com', displayName: 'A', role: 'user', plan: 'free' },
|
||||||
|
};
|
||||||
|
globalThis.fetch = mockFetchResponse(mockData);
|
||||||
|
|
||||||
|
const client = createAuthClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await client.verifyPasskeyAuth({ id: 'cred-1', response: {} });
|
||||||
|
|
||||||
|
expect(result.user.email).toBe('a@b.com');
|
||||||
|
expect(client.getAccessToken()).toBe('pk-at');
|
||||||
|
|
||||||
|
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
expect(url).toBe('http://localhost:4003/api/auth/passkeys/authenticate/verify');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Phase 3: Devices ──────────────────────────────
|
||||||
|
|
||||||
|
describe('listDevices', () => {
|
||||||
|
it('calls GET /auth/devices and unwraps response', async () => {
|
||||||
|
const data = {
|
||||||
|
devices: [
|
||||||
|
{
|
||||||
|
fingerprint: 'fp-abc',
|
||||||
|
trustLevel: 'trusted',
|
||||||
|
deviceInfo: { platform: 'web' },
|
||||||
|
lastIp: '1.2.3.4',
|
||||||
|
trustExpiresAt: '2025-04-01T00:00:00Z',
|
||||||
|
createdAt: '2025-01-01T00:00:00Z',
|
||||||
|
lastSeenAt: '2025-01-01T00:00:00Z',
|
||||||
|
isTrusted: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
globalThis.fetch = mockFetchResponse(data);
|
||||||
|
|
||||||
|
const client = createAuthClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
client.setTokens('tok', 'ref');
|
||||||
|
|
||||||
|
const result = await client.listDevices();
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].trustLevel).toBe('trusted');
|
||||||
|
expect(result[0].fingerprint).toBe('fp-abc');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('trustDevice', () => {
|
||||||
|
it('calls POST /auth/devices/trust with fingerprint and trustLevel', async () => {
|
||||||
|
globalThis.fetch = mockFetchResponse({});
|
||||||
|
|
||||||
|
const client = createAuthClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
client.setTokens('tok', 'ref');
|
||||||
|
|
||||||
|
await client.trustDevice('fp-abc', 'trusted', { platform: 'web' });
|
||||||
|
|
||||||
|
const [url, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
expect(url).toBe('http://localhost:4003/api/auth/devices/trust');
|
||||||
|
expect(opts.method).toBe('POST');
|
||||||
|
const body = JSON.parse(opts.body);
|
||||||
|
expect(body.fingerprint).toBe('fp-abc');
|
||||||
|
expect(body.trustLevel).toBe('trusted');
|
||||||
|
expect(body.deviceInfo).toEqual({ platform: 'web' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('revokeDevice', () => {
|
||||||
|
it('calls DELETE /auth/devices/:fingerprint', async () => {
|
||||||
|
globalThis.fetch = mockFetchResponse({});
|
||||||
|
|
||||||
|
const client = createAuthClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
client.setTokens('tok', 'ref');
|
||||||
|
|
||||||
|
await client.revokeDevice('fp-xyz');
|
||||||
|
|
||||||
|
const [url, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
expect(url).toBe('http://localhost:4003/api/auth/devices/fp-xyz');
|
||||||
|
expect(opts.method).toBe('DELETE');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('revokeAllDevices', () => {
|
||||||
|
it('calls POST /auth/devices/revoke-all', async () => {
|
||||||
|
globalThis.fetch = mockFetchResponse({});
|
||||||
|
|
||||||
|
const client = createAuthClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
client.setTokens('tok', 'ref');
|
||||||
|
|
||||||
|
await client.revokeAllDevices();
|
||||||
|
|
||||||
|
const [url, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
expect(url).toBe('http://localhost:4003/api/auth/devices/revoke-all');
|
||||||
|
expect(opts.method).toBe('POST');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Phase 5B: Admin security ──────────────────────
|
||||||
|
|
||||||
|
describe('getSecurityOverview', () => {
|
||||||
|
it('calls GET /auth/security/overview', async () => {
|
||||||
|
const overview = {
|
||||||
|
totalUsers: 100,
|
||||||
|
mfaAdoptionPercent: 42.5,
|
||||||
|
providerDistribution: { google: 60, microsoft: 30, password: 10 },
|
||||||
|
activeSessions: 250,
|
||||||
|
suspiciousEvents24h: 3,
|
||||||
|
};
|
||||||
|
globalThis.fetch = mockFetchResponse(overview);
|
||||||
|
|
||||||
|
const client = createAuthClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
client.setTokens('admin-tok', 'admin-ref');
|
||||||
|
|
||||||
|
const result = await client.getSecurityOverview();
|
||||||
|
expect(result.totalUsers).toBe(100);
|
||||||
|
expect(result.mfaAdoptionPercent).toBe(42.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unlockUser', () => {
|
||||||
|
it('calls POST /auth/users/:id/unlock', async () => {
|
||||||
|
globalThis.fetch = mockFetchResponse({});
|
||||||
|
|
||||||
|
const client = createAuthClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
client.setTokens('admin-tok', 'admin-ref');
|
||||||
|
|
||||||
|
await client.unlockUser('user-locked-123');
|
||||||
|
|
||||||
|
const [url, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
expect(url).toBe('http://localhost:4003/api/auth/users/user-locked-123/unlock');
|
||||||
|
expect(opts.method).toBe('POST');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cancelDeletion', () => {
|
||||||
|
it('calls POST /auth/account/cancel-deletion', async () => {
|
||||||
|
globalThis.fetch = mockFetchResponse({ message: 'Deletion cancelled' });
|
||||||
|
|
||||||
|
const client = createAuthClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
client.setTokens('tok', 'ref');
|
||||||
|
|
||||||
|
const result = await client.cancelDeletion();
|
||||||
|
expect(result.message).toBe('Deletion cancelled');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── login() MFA flow ──────────────────────────────
|
||||||
|
|
||||||
|
describe('login with MFA challenge', () => {
|
||||||
|
it('returns MfaLoginResult and does not store tokens', async () => {
|
||||||
|
const mfaResponse = {
|
||||||
|
mfaRequired: true,
|
||||||
|
mfaChallenge: 'login-challenge',
|
||||||
|
methods: ['totp', 'recovery'],
|
||||||
|
};
|
||||||
|
globalThis.fetch = mockFetchResponse(mfaResponse);
|
||||||
|
|
||||||
|
const client = createAuthClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await client.login('user@test.com', 'password');
|
||||||
|
|
||||||
|
expect('mfaRequired' in result).toBe(true);
|
||||||
|
if ('mfaRequired' in result) {
|
||||||
|
expect(result.mfaChallenge).toBe('login-challenge');
|
||||||
|
expect(result.methods).toEqual(['totp', 'recovery']);
|
||||||
|
}
|
||||||
|
expect(client.isAuthenticated()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── createAuthClient includes all SmartAuth methods ──
|
||||||
|
|
||||||
|
describe('client exposes all SmartAuth methods', () => {
|
||||||
|
it('has all phase 1C, 2D, 3, 5B methods', () => {
|
||||||
|
const client = createAuthClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Phase 1C
|
||||||
|
expect(client.loginWithGoogle).toBeTypeOf('function');
|
||||||
|
expect(client.loginWithMicrosoft).toBeTypeOf('function');
|
||||||
|
expect(client.loginWithApple).toBeTypeOf('function');
|
||||||
|
expect(client.getProviders).toBeTypeOf('function');
|
||||||
|
expect(client.linkProvider).toBeTypeOf('function');
|
||||||
|
expect(client.unlinkProvider).toBeTypeOf('function');
|
||||||
|
// Phase 2D
|
||||||
|
expect(client.verifyMfa).toBeTypeOf('function');
|
||||||
|
expect(client.setupTotp).toBeTypeOf('function');
|
||||||
|
expect(client.verifyTotpSetup).toBeTypeOf('function');
|
||||||
|
expect(client.disableMfa).toBeTypeOf('function');
|
||||||
|
expect(client.getMfaStatus).toBeTypeOf('function');
|
||||||
|
expect(client.regenerateRecoveryCodes).toBeTypeOf('function');
|
||||||
|
// Phase 3
|
||||||
|
expect(client.getPasskeyRegisterOptions).toBeTypeOf('function');
|
||||||
|
expect(client.verifyPasskeyRegistration).toBeTypeOf('function');
|
||||||
|
expect(client.getPasskeyAuthOptions).toBeTypeOf('function');
|
||||||
|
expect(client.verifyPasskeyAuth).toBeTypeOf('function');
|
||||||
|
expect(client.listPasskeys).toBeTypeOf('function');
|
||||||
|
expect(client.deletePasskey).toBeTypeOf('function');
|
||||||
|
expect(client.listDevices).toBeTypeOf('function');
|
||||||
|
expect(client.trustDevice).toBeTypeOf('function');
|
||||||
|
expect(client.revokeDevice).toBeTypeOf('function');
|
||||||
|
expect(client.revokeAllDevices).toBeTypeOf('function');
|
||||||
|
// Phase 5B
|
||||||
|
expect(client.getSecurityOverview).toBeTypeOf('function');
|
||||||
|
expect(client.unlockUser).toBeTypeOf('function');
|
||||||
|
expect(client.exportAuthData).toBeTypeOf('function');
|
||||||
|
expect(client.cancelDeletion).toBeTypeOf('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
527
vendor/bytelyst/auth-client/src/client.ts
vendored
Normal file
527
vendor/bytelyst/auth-client/src/client.ts
vendored
Normal file
@ -0,0 +1,527 @@
|
|||||||
|
/**
|
||||||
|
* Browser/React Native-safe auth API client for platform-service.
|
||||||
|
*
|
||||||
|
* Replaces hand-rolled auth clients in ChronoMind web, MindLyst web, NomGap, etc.
|
||||||
|
* No Node.js dependencies — uses globalThis.fetch and configurable storage.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* import { createAuthClient } from '@bytelyst/auth-client';
|
||||||
|
*
|
||||||
|
* const auth = createAuthClient({
|
||||||
|
* baseUrl: 'http://localhost:4003/api',
|
||||||
|
* productId: 'chronomind',
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* const result = await auth.login('user@example.com', 'password123');
|
||||||
|
* console.log(result.user.displayName);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AuthClient,
|
||||||
|
AuthClientConfig,
|
||||||
|
AuthProvider,
|
||||||
|
AuthResult,
|
||||||
|
AuthUser,
|
||||||
|
Device,
|
||||||
|
LoginEventInfo,
|
||||||
|
MfaRequiredResult,
|
||||||
|
MfaStatus,
|
||||||
|
Passkey,
|
||||||
|
SecurityOverview,
|
||||||
|
TokenStorage,
|
||||||
|
TotpSetupResult,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
// ── Default localStorage adapter ─────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No-op storage fallback used when `localStorage` is unavailable (e.g. SSR / Node.js).
|
||||||
|
* Tokens stored via noopStorage are NOT persisted — they are lost on page reload.
|
||||||
|
* For server-side rendering, use cookie-based auth instead of relying on this client.
|
||||||
|
*/
|
||||||
|
const noopStorage: TokenStorage = {
|
||||||
|
getItem: () => null,
|
||||||
|
setItem: () => {},
|
||||||
|
removeItem: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
function getDefaultStorage(): TokenStorage {
|
||||||
|
if (
|
||||||
|
typeof globalThis.localStorage !== 'undefined' &&
|
||||||
|
typeof globalThis.localStorage?.getItem === 'function'
|
||||||
|
) {
|
||||||
|
return globalThis.localStorage;
|
||||||
|
}
|
||||||
|
return noopStorage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UUID helper (browser + RN safe) ──────────────────────────────
|
||||||
|
|
||||||
|
function uuid(): string {
|
||||||
|
if (typeof globalThis.crypto?.randomUUID === 'function') {
|
||||||
|
return globalThis.crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
||||||
|
const r = (Math.random() * 16) | 0;
|
||||||
|
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Factory ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function createAuthClient(config: AuthClientConfig): AuthClient {
|
||||||
|
const { baseUrl, productId, timeoutMs = 15_000 } = config;
|
||||||
|
const storage = config.storage ?? getDefaultStorage();
|
||||||
|
const prefix = config.storagePrefix ?? productId;
|
||||||
|
|
||||||
|
const KEYS = {
|
||||||
|
accessToken: `${prefix}-auth-token`,
|
||||||
|
refreshToken: `${prefix}-refresh-token`,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ── Token management ────────────────────────────
|
||||||
|
|
||||||
|
function getAccessToken(): string | null {
|
||||||
|
return storage.getItem(KEYS.accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRefreshToken(): string | null {
|
||||||
|
return storage.getItem(KEYS.refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTokens(accessToken: string, refreshToken: string): void {
|
||||||
|
storage.setItem(KEYS.accessToken, accessToken);
|
||||||
|
storage.setItem(KEYS.refreshToken, refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTokens(): void {
|
||||||
|
storage.removeItem(KEYS.accessToken);
|
||||||
|
storage.removeItem(KEYS.refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAuthenticated(): boolean {
|
||||||
|
return getAccessToken() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTTP helper ─────────────────────────────────
|
||||||
|
|
||||||
|
async function request<T>(
|
||||||
|
path: string,
|
||||||
|
method: string,
|
||||||
|
body?: unknown,
|
||||||
|
opts?: { skipAuth?: boolean }
|
||||||
|
): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-product-id': productId,
|
||||||
|
'x-request-id': uuid(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!opts?.skipAuth) {
|
||||||
|
const token = getAccessToken();
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await globalThis.fetch(`${baseUrl}${path}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(
|
||||||
|
(data as Record<string, string>).message ||
|
||||||
|
(data as Record<string, string>).error ||
|
||||||
|
`HTTP ${res.status}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Singleton refresh guard ─────────────────────
|
||||||
|
|
||||||
|
let _refreshPromise: Promise<boolean> | null = null;
|
||||||
|
|
||||||
|
async function refreshAccessToken(): Promise<boolean> {
|
||||||
|
if (_refreshPromise) return _refreshPromise;
|
||||||
|
|
||||||
|
_refreshPromise = (async () => {
|
||||||
|
const rt = getRefreshToken();
|
||||||
|
if (!rt) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await request<{ accessToken: string; refreshToken: string }>(
|
||||||
|
'/auth/refresh',
|
||||||
|
'POST',
|
||||||
|
{ refreshToken: rt },
|
||||||
|
{ skipAuth: true }
|
||||||
|
);
|
||||||
|
setTokens(data.accessToken, data.refreshToken);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
clearTokens();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await _refreshPromise;
|
||||||
|
} finally {
|
||||||
|
_refreshPromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auth operations ─────────────────────────────
|
||||||
|
|
||||||
|
async function login(email: string, password: string): Promise<AuthResult | MfaRequiredResult> {
|
||||||
|
const result = await request<AuthResult | MfaRequiredResult>('/auth/login', 'POST', {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
productId,
|
||||||
|
});
|
||||||
|
if ('mfaRequired' in result && result.mfaRequired) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const authResult = result as AuthResult;
|
||||||
|
setTokens(authResult.accessToken, authResult.refreshToken);
|
||||||
|
return authResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function register(
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
displayName: string
|
||||||
|
): Promise<AuthResult> {
|
||||||
|
const result = await request<AuthResult>('/auth/register', 'POST', {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
displayName,
|
||||||
|
productId,
|
||||||
|
});
|
||||||
|
setTokens(result.accessToken, result.refreshToken);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMe(): Promise<AuthUser> {
|
||||||
|
return request<AuthUser>('/auth/me', 'GET');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Password management ─────────────────────────
|
||||||
|
|
||||||
|
async function forgotPassword(email: string): Promise<{ message: string }> {
|
||||||
|
return request<{ message: string }>('/auth/forgot-password', 'POST', {
|
||||||
|
email,
|
||||||
|
productId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetPassword(token: string, newPassword: string): Promise<{ message: string }> {
|
||||||
|
return request<{ message: string }>('/auth/reset-password', 'POST', {
|
||||||
|
token,
|
||||||
|
newPassword,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changePassword(
|
||||||
|
currentPassword: string,
|
||||||
|
newPassword: string
|
||||||
|
): Promise<{ message: string }> {
|
||||||
|
return request<{ message: string }>('/auth/change-password', 'POST', {
|
||||||
|
currentPassword,
|
||||||
|
newPassword,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Account management ──────────────────────────
|
||||||
|
|
||||||
|
async function deleteAccount(password: string): Promise<{ message: string }> {
|
||||||
|
const result = await request<{ message: string }>('/auth/account', 'DELETE', {
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
clearTokens();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Email verification ──────────────────────────
|
||||||
|
|
||||||
|
async function verifyEmail(token: string): Promise<{ message: string }> {
|
||||||
|
return request<{ message: string }>('/auth/verify-email', 'POST', { token });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resendVerification(email: string): Promise<{ message: string }> {
|
||||||
|
return request<{ message: string }>('/auth/resend-verification', 'POST', {
|
||||||
|
email,
|
||||||
|
productId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── OAuth / Social login (Phase 1C) ────────────────
|
||||||
|
|
||||||
|
async function loginWithOAuth(
|
||||||
|
provider: string,
|
||||||
|
idToken: string
|
||||||
|
): Promise<AuthResult | MfaRequiredResult> {
|
||||||
|
const result = await request<AuthResult | MfaRequiredResult>(
|
||||||
|
`/auth/oauth/${provider}`,
|
||||||
|
'POST',
|
||||||
|
{ idToken, productId },
|
||||||
|
{ skipAuth: true }
|
||||||
|
);
|
||||||
|
if ('mfaRequired' in result && result.mfaRequired) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const authResult = result as AuthResult;
|
||||||
|
setTokens(authResult.accessToken, authResult.refreshToken);
|
||||||
|
return authResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginWithGoogle(idToken: string): Promise<AuthResult | MfaRequiredResult> {
|
||||||
|
return loginWithOAuth('google', idToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginWithMicrosoft(idToken: string): Promise<AuthResult | MfaRequiredResult> {
|
||||||
|
return loginWithOAuth('microsoft', idToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginWithApple(idToken: string): Promise<AuthResult | MfaRequiredResult> {
|
||||||
|
return loginWithOAuth('apple', idToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Provider management (Phase 1C) ─────────────────
|
||||||
|
|
||||||
|
async function getProviders(): Promise<AuthProvider[]> {
|
||||||
|
const data = await request<{ providers: AuthProvider[] }>('/auth/providers', 'GET');
|
||||||
|
return data.providers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function linkProvider(provider: string, idToken: string): Promise<void> {
|
||||||
|
await request<void>('/auth/providers/link', 'POST', { provider, idToken });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unlinkProvider(provider: string): Promise<void> {
|
||||||
|
await request<void>(`/auth/providers/${provider}`, 'DELETE');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MFA (Phase 2D) ─────────────────────────────────
|
||||||
|
|
||||||
|
async function verifyMfa(
|
||||||
|
challengeToken: string,
|
||||||
|
code: string,
|
||||||
|
method: 'totp' | 'recovery'
|
||||||
|
): Promise<AuthResult> {
|
||||||
|
const result = await request<AuthResult>('/auth/mfa/verify', 'POST', {
|
||||||
|
challengeToken,
|
||||||
|
code,
|
||||||
|
method,
|
||||||
|
});
|
||||||
|
setTokens(result.accessToken, result.refreshToken);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupTotp(): Promise<TotpSetupResult> {
|
||||||
|
return request<TotpSetupResult>('/auth/mfa/setup', 'POST');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyTotpSetup(code: string): Promise<void> {
|
||||||
|
await request<void>('/auth/mfa/verify-setup', 'POST', { code });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disableMfa(code: string): Promise<void> {
|
||||||
|
await request<void>('/auth/mfa/disable', 'POST', { code });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMfaStatus(): Promise<MfaStatus> {
|
||||||
|
return request<MfaStatus>('/auth/mfa/status', 'GET');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function regenerateRecoveryCodes(): Promise<{ codes: string[] }> {
|
||||||
|
return request<{ codes: string[] }>('/auth/mfa/recovery/regenerate', 'POST');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Passkeys (Phase 3) ─────────────────────────────
|
||||||
|
|
||||||
|
async function getPasskeyRegisterOptions(): Promise<unknown> {
|
||||||
|
return request<unknown>('/auth/passkeys/register/options', 'POST');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyPasskeyRegistration(response: unknown): Promise<void> {
|
||||||
|
await request<void>('/auth/passkeys/register/verify', 'POST', response);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPasskeyAuthOptions(): Promise<unknown> {
|
||||||
|
return request<unknown>('/auth/passkeys/authenticate/options', 'POST', undefined, {
|
||||||
|
skipAuth: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyPasskeyAuth(response: unknown): Promise<AuthResult> {
|
||||||
|
const result = await request<AuthResult>(
|
||||||
|
'/auth/passkeys/authenticate/verify',
|
||||||
|
'POST',
|
||||||
|
response,
|
||||||
|
{ skipAuth: true }
|
||||||
|
);
|
||||||
|
setTokens(result.accessToken, result.refreshToken);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listPasskeys(): Promise<Passkey[]> {
|
||||||
|
const data = await request<{ passkeys: Passkey[] }>('/auth/passkeys', 'GET');
|
||||||
|
return data.passkeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePasskey(id: string): Promise<void> {
|
||||||
|
await request<void>(`/auth/passkeys/${id}`, 'DELETE');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Devices (Phase 3) ──────────────────────────────
|
||||||
|
|
||||||
|
async function listDevices(): Promise<Device[]> {
|
||||||
|
const data = await request<{ devices: Device[] }>('/auth/devices', 'GET');
|
||||||
|
return data.devices;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trustDevice(
|
||||||
|
fingerprint: string,
|
||||||
|
trustLevel: 'trusted' | 'remembered',
|
||||||
|
deviceInfo?: Record<string, string>
|
||||||
|
): Promise<void> {
|
||||||
|
await request<void>('/auth/devices/trust', 'POST', { fingerprint, trustLevel, deviceInfo });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeDevice(fingerprint: string): Promise<void> {
|
||||||
|
await request<void>(`/auth/devices/${fingerprint}`, 'DELETE');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeAllDevices(): Promise<void> {
|
||||||
|
await request<void>('/auth/devices/revoke-all', 'POST');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin security (Phase 5B) ──────────────────────
|
||||||
|
|
||||||
|
async function getSecurityOverview(): Promise<SecurityOverview> {
|
||||||
|
return request<SecurityOverview>('/auth/security/overview', 'GET');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unlockUser(userId: string): Promise<void> {
|
||||||
|
await request<void>(`/auth/users/${userId}/unlock`, 'POST');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportAuthData(): Promise<unknown> {
|
||||||
|
return request<unknown>('/auth/export', 'GET');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelDeletion(): Promise<{ message: string }> {
|
||||||
|
return request<{ message: string }>('/auth/account/cancel-deletion', 'POST');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step-up auth ────────────────────────────────────
|
||||||
|
|
||||||
|
async function stepUp(method: string, credential: string): Promise<{ stepUpToken: string }> {
|
||||||
|
return request<{ stepUpToken: string }>('/auth/step-up', 'POST', { method, credential });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Login history ───────────────────────────────────
|
||||||
|
|
||||||
|
async function getLoginHistory(limit = 50): Promise<LoginEventInfo[]> {
|
||||||
|
const data = await request<{ events: LoginEventInfo[] }>(
|
||||||
|
`/auth/login-events?limit=${limit}`,
|
||||||
|
'GET'
|
||||||
|
);
|
||||||
|
return data.events;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin security ──────────────────────────────────
|
||||||
|
|
||||||
|
async function getAdminLoginEvents(opts?: {
|
||||||
|
userId?: string;
|
||||||
|
suspicious?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<LoginEventInfo[]> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (opts?.userId) params.set('userId', opts.userId);
|
||||||
|
if (opts?.suspicious) params.set('suspicious', 'true');
|
||||||
|
if (opts?.limit) params.set('limit', String(opts.limit));
|
||||||
|
const qs = params.toString();
|
||||||
|
const data = await request<{ events: LoginEventInfo[] }>(
|
||||||
|
`/auth/login-events/admin${qs ? `?${qs}` : ''}`,
|
||||||
|
'GET'
|
||||||
|
);
|
||||||
|
return data.events;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAdminDevices(userId: string): Promise<Device[]> {
|
||||||
|
const data = await request<{ devices: Device[] }>(`/auth/devices/user/${userId}`, 'GET');
|
||||||
|
return data.devices;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getAccessToken,
|
||||||
|
getRefreshToken,
|
||||||
|
setTokens,
|
||||||
|
clearTokens,
|
||||||
|
isAuthenticated,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
getMe,
|
||||||
|
refreshAccessToken,
|
||||||
|
forgotPassword,
|
||||||
|
resetPassword,
|
||||||
|
changePassword,
|
||||||
|
deleteAccount,
|
||||||
|
verifyEmail,
|
||||||
|
resendVerification,
|
||||||
|
// OAuth / Social login (Phase 1C)
|
||||||
|
loginWithGoogle,
|
||||||
|
loginWithMicrosoft,
|
||||||
|
loginWithApple,
|
||||||
|
// Provider management (Phase 1C)
|
||||||
|
getProviders,
|
||||||
|
linkProvider,
|
||||||
|
unlinkProvider,
|
||||||
|
// MFA (Phase 2D)
|
||||||
|
verifyMfa,
|
||||||
|
setupTotp,
|
||||||
|
verifyTotpSetup,
|
||||||
|
disableMfa,
|
||||||
|
getMfaStatus,
|
||||||
|
regenerateRecoveryCodes,
|
||||||
|
// Passkeys (Phase 3)
|
||||||
|
getPasskeyRegisterOptions,
|
||||||
|
verifyPasskeyRegistration,
|
||||||
|
getPasskeyAuthOptions,
|
||||||
|
verifyPasskeyAuth,
|
||||||
|
listPasskeys,
|
||||||
|
deletePasskey,
|
||||||
|
// Devices (Phase 3)
|
||||||
|
listDevices,
|
||||||
|
trustDevice,
|
||||||
|
revokeDevice,
|
||||||
|
revokeAllDevices,
|
||||||
|
// Admin security (Phase 5B)
|
||||||
|
getSecurityOverview,
|
||||||
|
unlockUser,
|
||||||
|
exportAuthData,
|
||||||
|
cancelDeletion,
|
||||||
|
// Step-up auth
|
||||||
|
stepUp,
|
||||||
|
// Login history
|
||||||
|
getLoginHistory,
|
||||||
|
// Admin queries
|
||||||
|
getAdminLoginEvents,
|
||||||
|
getAdminDevices,
|
||||||
|
};
|
||||||
|
}
|
||||||
16
vendor/bytelyst/auth-client/src/index.ts
vendored
Normal file
16
vendor/bytelyst/auth-client/src/index.ts
vendored
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
export { createAuthClient } from './client.js';
|
||||||
|
export type {
|
||||||
|
AuthClient,
|
||||||
|
AuthClientConfig,
|
||||||
|
AuthProvider,
|
||||||
|
AuthResult,
|
||||||
|
AuthUser,
|
||||||
|
Device,
|
||||||
|
LoginEventInfo,
|
||||||
|
MfaRequiredResult,
|
||||||
|
MfaStatus,
|
||||||
|
Passkey,
|
||||||
|
SecurityOverview,
|
||||||
|
TokenStorage,
|
||||||
|
TotpSetupResult,
|
||||||
|
} from './types.js';
|
||||||
190
vendor/bytelyst/auth-client/src/types.ts
vendored
Normal file
190
vendor/bytelyst/auth-client/src/types.ts
vendored
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* Types for @bytelyst/auth-client.
|
||||||
|
* Browser/React Native-safe — no Node.js dependencies.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface AuthClientConfig {
|
||||||
|
/** Platform-service base URL (e.g. "http://localhost:4003/api" or "https://api.example.com"). */
|
||||||
|
baseUrl: string;
|
||||||
|
|
||||||
|
/** Product identifier sent with every request as x-product-id header. */
|
||||||
|
productId: string;
|
||||||
|
|
||||||
|
/** Storage adapter for tokens. Defaults to localStorage if available. */
|
||||||
|
storage?: TokenStorage;
|
||||||
|
|
||||||
|
/** Optional prefix for storage keys. Default: product ID. */
|
||||||
|
storagePrefix?: string;
|
||||||
|
|
||||||
|
/** Request timeout in milliseconds. Default: 15000. */
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenStorage {
|
||||||
|
getItem(key: string): string | null;
|
||||||
|
setItem(key: string, value: string): void;
|
||||||
|
removeItem(key: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
displayName: string;
|
||||||
|
role: string;
|
||||||
|
plan: string;
|
||||||
|
mfaEnabled?: boolean;
|
||||||
|
mfaMethods?: string[];
|
||||||
|
providers?: string[];
|
||||||
|
products?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResult {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
user: AuthUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MfaRequiredResult {
|
||||||
|
mfaRequired: true;
|
||||||
|
mfaChallenge: string;
|
||||||
|
methods: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TotpSetupResult {
|
||||||
|
secret: string;
|
||||||
|
otpauthUri: string;
|
||||||
|
recoveryCodes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MfaStatus {
|
||||||
|
mfaEnabled: boolean;
|
||||||
|
methods: string[];
|
||||||
|
recoveryCodesRemaining: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthProvider {
|
||||||
|
provider: string;
|
||||||
|
email: string;
|
||||||
|
linkedAt: string;
|
||||||
|
lastUsedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Passkey {
|
||||||
|
credentialId: string;
|
||||||
|
friendlyName: string;
|
||||||
|
deviceType: string;
|
||||||
|
backedUp: boolean;
|
||||||
|
lastUsedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Device {
|
||||||
|
fingerprint: string;
|
||||||
|
trustLevel: 'trusted' | 'remembered' | 'unknown';
|
||||||
|
deviceInfo: Record<string, string | undefined>;
|
||||||
|
lastIp?: string;
|
||||||
|
lastLocation?: string;
|
||||||
|
trustExpiresAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
lastSeenAt: string;
|
||||||
|
isTrusted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginEventInfo {
|
||||||
|
id: string;
|
||||||
|
result: string;
|
||||||
|
method: string;
|
||||||
|
ip: string;
|
||||||
|
userAgent?: string;
|
||||||
|
fingerprint?: string;
|
||||||
|
location?: string;
|
||||||
|
riskLevel: string;
|
||||||
|
riskScore: number;
|
||||||
|
riskFlags: string[];
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecurityOverview {
|
||||||
|
totalUsers: number;
|
||||||
|
mfaAdoptionPercent: number;
|
||||||
|
providerDistribution: Record<string, number>;
|
||||||
|
activeSessions: number;
|
||||||
|
suspiciousEvents24h: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthClient {
|
||||||
|
// ── Token management ────────────────────────────
|
||||||
|
getAccessToken(): string | null;
|
||||||
|
getRefreshToken(): string | null;
|
||||||
|
setTokens(accessToken: string, refreshToken: string): void;
|
||||||
|
clearTokens(): void;
|
||||||
|
isAuthenticated(): boolean;
|
||||||
|
|
||||||
|
// ── Auth operations ─────────────────────────────
|
||||||
|
login(email: string, password: string): Promise<AuthResult | MfaRequiredResult>;
|
||||||
|
register(email: string, password: string, displayName: string): Promise<AuthResult>;
|
||||||
|
getMe(): Promise<AuthUser>;
|
||||||
|
refreshAccessToken(): Promise<boolean>;
|
||||||
|
|
||||||
|
// ── OAuth / Social login ────────────────────────
|
||||||
|
loginWithGoogle(idToken: string): Promise<AuthResult | MfaRequiredResult>;
|
||||||
|
loginWithMicrosoft(idToken: string): Promise<AuthResult | MfaRequiredResult>;
|
||||||
|
loginWithApple(idToken: string): Promise<AuthResult | MfaRequiredResult>;
|
||||||
|
|
||||||
|
// ── Provider management ─────────────────────────
|
||||||
|
getProviders(): Promise<AuthProvider[]>;
|
||||||
|
linkProvider(provider: string, idToken: string): Promise<void>;
|
||||||
|
unlinkProvider(provider: string): Promise<void>;
|
||||||
|
|
||||||
|
// ── MFA ─────────────────────────────────────────
|
||||||
|
verifyMfa(challengeToken: string, code: string, method: 'totp' | 'recovery'): Promise<AuthResult>;
|
||||||
|
setupTotp(): Promise<TotpSetupResult>;
|
||||||
|
verifyTotpSetup(code: string): Promise<void>;
|
||||||
|
disableMfa(code: string): Promise<void>;
|
||||||
|
getMfaStatus(): Promise<MfaStatus>;
|
||||||
|
regenerateRecoveryCodes(): Promise<{ codes: string[] }>;
|
||||||
|
|
||||||
|
// ── Passkeys (WebAuthn) ─────────────────────────
|
||||||
|
getPasskeyRegisterOptions(): Promise<unknown>;
|
||||||
|
verifyPasskeyRegistration(response: unknown): Promise<void>;
|
||||||
|
getPasskeyAuthOptions(): Promise<unknown>;
|
||||||
|
verifyPasskeyAuth(response: unknown): Promise<AuthResult>;
|
||||||
|
listPasskeys(): Promise<Passkey[]>;
|
||||||
|
deletePasskey(id: string): Promise<void>;
|
||||||
|
|
||||||
|
// ── Devices ─────────────────────────────────────
|
||||||
|
listDevices(): Promise<Device[]>;
|
||||||
|
trustDevice(
|
||||||
|
fingerprint: string,
|
||||||
|
trustLevel: 'trusted' | 'remembered',
|
||||||
|
deviceInfo?: Record<string, string>
|
||||||
|
): Promise<void>;
|
||||||
|
revokeDevice(fingerprint: string): Promise<void>;
|
||||||
|
revokeAllDevices(): Promise<void>;
|
||||||
|
|
||||||
|
// ── Step-up auth ────────────────────────────────
|
||||||
|
stepUp(method: string, credential: string): Promise<{ stepUpToken: string }>;
|
||||||
|
|
||||||
|
// ── Login history ───────────────────────────────
|
||||||
|
getLoginHistory(limit?: number): Promise<LoginEventInfo[]>;
|
||||||
|
|
||||||
|
// ── Admin security ──────────────────────────────
|
||||||
|
getSecurityOverview(): Promise<SecurityOverview>;
|
||||||
|
unlockUser(userId: string): Promise<void>;
|
||||||
|
getAdminLoginEvents(opts?: { suspicious?: boolean; limit?: number }): Promise<LoginEventInfo[]>;
|
||||||
|
getAdminDevices(userId: string): Promise<Device[]>;
|
||||||
|
exportAuthData(): Promise<unknown>;
|
||||||
|
|
||||||
|
// ── Password management ─────────────────────────
|
||||||
|
forgotPassword(email: string): Promise<{ message: string }>;
|
||||||
|
resetPassword(token: string, newPassword: string): Promise<{ message: string }>;
|
||||||
|
changePassword(currentPassword: string, newPassword: string): Promise<{ message: string }>;
|
||||||
|
|
||||||
|
// ── Account management ──────────────────────────
|
||||||
|
deleteAccount(password: string): Promise<{ message: string }>;
|
||||||
|
cancelDeletion(): Promise<{ message: string }>;
|
||||||
|
|
||||||
|
// ── Email verification ──────────────────────────
|
||||||
|
verifyEmail(token: string): Promise<{ message: string }>;
|
||||||
|
resendVerification(email: string): Promise<{ message: string }>;
|
||||||
|
}
|
||||||
10
vendor/bytelyst/auth-client/tsconfig.json
vendored
Normal file
10
vendor/bytelyst/auth-client/tsconfig.json
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"lib": ["ES2022", "DOM"]
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
|
}
|
||||||
36
vendor/bytelyst/auth-ui/package.json
vendored
Normal file
36
vendor/bytelyst/auth-ui/package.json
vendored
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "@bytelyst/auth-ui",
|
||||||
|
"version": "0.1.5",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Shared auth UI components for SmartAuth (LoginForm, MfaChallenge, SocialButtons)",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"test": "vitest run --pool forks"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18.0.0",
|
||||||
|
"react-dom": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"happy-dom": "^18.0.1",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/"
|
||||||
|
}
|
||||||
|
}
|
||||||
101
vendor/bytelyst/auth-ui/src/AuthPageLayout.tsx
vendored
Normal file
101
vendor/bytelyst/auth-ui/src/AuthPageLayout.tsx
vendored
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import type { AuthPageLayoutProps } from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-page auth layout — centered card with product branding.
|
||||||
|
* Wraps any auth form (LoginForm, RegisterForm, etc.).
|
||||||
|
* Styled via CSS custom properties (inherits --bl-* from host app).
|
||||||
|
*/
|
||||||
|
export function AuthPageLayout({
|
||||||
|
productName,
|
||||||
|
logo,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
className,
|
||||||
|
}: AuthPageLayoutProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
data-testid="bl-auth-page"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
minHeight: '100vh',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '16px',
|
||||||
|
background: 'var(--bl-page-bg, #f5f5f5)',
|
||||||
|
fontFamily: 'var(--bl-font, system-ui, -apple-system, sans-serif)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: '400px',
|
||||||
|
background: 'var(--bl-surface, #fff)',
|
||||||
|
borderRadius: 'var(--bl-card-radius, 12px)',
|
||||||
|
boxShadow: 'var(--bl-card-shadow, 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06))',
|
||||||
|
padding: '32px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Branding */}
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: '24px' }}>
|
||||||
|
{logo && (
|
||||||
|
<div style={{ marginBottom: '12px' }} data-testid="bl-auth-logo">
|
||||||
|
{typeof logo === 'string' ? (
|
||||||
|
<img src={logo} alt={productName} style={{ height: '48px' }} />
|
||||||
|
) : (
|
||||||
|
logo
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{ fontSize: '20px', fontWeight: 700, color: 'var(--bl-text, #111)' }}
|
||||||
|
data-testid="bl-auth-product-name"
|
||||||
|
>
|
||||||
|
{productName}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--bl-text, #333)',
|
||||||
|
marginTop: '8px',
|
||||||
|
}}
|
||||||
|
data-testid="bl-auth-title"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{subtitle && (
|
||||||
|
<div
|
||||||
|
style={{ fontSize: '14px', color: 'var(--bl-muted, #666)', marginTop: '4px' }}
|
||||||
|
data-testid="bl-auth-subtitle"
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form content */}
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{footer && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '20px',
|
||||||
|
paddingTop: '16px',
|
||||||
|
borderTop: '1px solid var(--bl-border, #eee)',
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: '13px',
|
||||||
|
color: 'var(--bl-muted, #999)',
|
||||||
|
}}
|
||||||
|
data-testid="bl-auth-footer"
|
||||||
|
>
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
vendor/bytelyst/auth-ui/src/ForgotPasswordForm.tsx
vendored
Normal file
111
vendor/bytelyst/auth-ui/src/ForgotPasswordForm.tsx
vendored
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import type { ForgotPasswordFormProps } from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forgot password form — email input to request a reset link.
|
||||||
|
* Styled via CSS custom properties (inherits --bl-* from host app).
|
||||||
|
*/
|
||||||
|
export function ForgotPasswordForm({
|
||||||
|
onSubmit,
|
||||||
|
isLoading = false,
|
||||||
|
error,
|
||||||
|
success,
|
||||||
|
onBack,
|
||||||
|
className,
|
||||||
|
}: ForgotPasswordFormProps) {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
|
||||||
|
function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
padding: '10px 12px',
|
||||||
|
border: '1px solid var(--bl-border, #ccc)',
|
||||||
|
borderRadius: 'var(--bl-radius, 6px)',
|
||||||
|
fontSize: '14px',
|
||||||
|
width: '100%',
|
||||||
|
boxSizing: 'border-box' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} data-testid="bl-forgot-password-form">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '14px', color: 'var(--bl-text, #333)' }}>
|
||||||
|
Enter your email address and we'll send you a link to reset your password.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="Email"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
data-testid="bl-forgot-email"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
data-testid="bl-forgot-error"
|
||||||
|
style={{ color: 'var(--bl-error, #dc3545)', fontSize: '13px' }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div
|
||||||
|
data-testid="bl-forgot-success"
|
||||||
|
style={{ color: 'var(--bl-success, #22c55e)', fontSize: '13px' }}
|
||||||
|
>
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || email.length === 0}
|
||||||
|
data-testid="bl-forgot-submit"
|
||||||
|
style={{
|
||||||
|
padding: '10px 16px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--bl-radius, 6px)',
|
||||||
|
background: 'var(--bl-primary, #0066ff)',
|
||||||
|
color: '#fff',
|
||||||
|
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
opacity: isLoading ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Sending...' : 'Send reset link'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{onBack && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBack}
|
||||||
|
data-testid="bl-forgot-back"
|
||||||
|
style={{
|
||||||
|
padding: '8px',
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--bl-link, #0066ff)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '13px',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back to sign in
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
vendor/bytelyst/auth-ui/src/LoginForm.tsx
vendored
Normal file
116
vendor/bytelyst/auth-ui/src/LoginForm.tsx
vendored
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { SocialButtons } from './SocialButtons.js';
|
||||||
|
import type { LoginFormProps } from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email/password login form with optional social login buttons.
|
||||||
|
* Styled via CSS custom properties (inherits --bl-* from host app).
|
||||||
|
*/
|
||||||
|
export function LoginForm({
|
||||||
|
onSubmit,
|
||||||
|
providers,
|
||||||
|
onSocialLogin,
|
||||||
|
isLoading = false,
|
||||||
|
error,
|
||||||
|
className,
|
||||||
|
}: LoginFormProps) {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
|
||||||
|
function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit(email, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} data-testid="bl-login-form">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="Email"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
data-testid="bl-login-email"
|
||||||
|
style={{
|
||||||
|
padding: '10px 12px',
|
||||||
|
border: '1px solid var(--bl-border, #ccc)',
|
||||||
|
borderRadius: 'var(--bl-radius, 6px)',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
data-testid="bl-login-password"
|
||||||
|
style={{
|
||||||
|
padding: '10px 12px',
|
||||||
|
border: '1px solid var(--bl-border, #ccc)',
|
||||||
|
borderRadius: 'var(--bl-radius, 6px)',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
data-testid="bl-login-error"
|
||||||
|
style={{ color: 'var(--bl-error, #dc3545)', fontSize: '13px' }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
data-testid="bl-login-submit"
|
||||||
|
style={{
|
||||||
|
padding: '10px 16px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--bl-radius, 6px)',
|
||||||
|
background: 'var(--bl-primary, #0066ff)',
|
||||||
|
color: '#fff',
|
||||||
|
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
opacity: isLoading ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Signing in...' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{providers && providers.length > 0 && onSocialLogin && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
margin: '16px 0',
|
||||||
|
color: 'var(--bl-muted, #999)',
|
||||||
|
fontSize: '13px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<hr
|
||||||
|
style={{ flex: 1, border: 'none', borderTop: '1px solid var(--bl-border, #eee)' }}
|
||||||
|
/>
|
||||||
|
or
|
||||||
|
<hr
|
||||||
|
style={{ flex: 1, border: 'none', borderTop: '1px solid var(--bl-border, #eee)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<SocialButtons providers={providers} onSelect={onSocialLogin} disabled={isLoading} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
vendor/bytelyst/auth-ui/src/MfaChallenge.tsx
vendored
Normal file
114
vendor/bytelyst/auth-ui/src/MfaChallenge.tsx
vendored
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import type { MfaChallengeProps } from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MFA code entry form (6-digit TOTP or recovery code).
|
||||||
|
* Styled via CSS custom properties (inherits --bl-* from host app).
|
||||||
|
*/
|
||||||
|
export function MfaChallenge({
|
||||||
|
onSubmit,
|
||||||
|
onUseRecovery,
|
||||||
|
methods,
|
||||||
|
isLoading = false,
|
||||||
|
error,
|
||||||
|
className,
|
||||||
|
}: MfaChallengeProps) {
|
||||||
|
const [code, setCode] = useState('');
|
||||||
|
|
||||||
|
function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} data-testid="bl-mfa-challenge">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '14px', color: 'var(--bl-text, #333)' }}>
|
||||||
|
Enter your authentication code
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{methods && methods.length > 0 && (
|
||||||
|
<div
|
||||||
|
data-testid="bl-mfa-methods"
|
||||||
|
style={{ fontSize: '12px', color: 'var(--bl-muted, #999)' }}
|
||||||
|
>
|
||||||
|
Available methods: {methods.join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
placeholder="000000"
|
||||||
|
value={code}
|
||||||
|
onChange={e => setCode(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
maxLength={8}
|
||||||
|
data-testid="bl-mfa-code"
|
||||||
|
style={{
|
||||||
|
padding: '12px',
|
||||||
|
border: '1px solid var(--bl-border, #ccc)',
|
||||||
|
borderRadius: 'var(--bl-radius, 6px)',
|
||||||
|
fontSize: '24px',
|
||||||
|
textAlign: 'center',
|
||||||
|
letterSpacing: '4px',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
data-testid="bl-mfa-error"
|
||||||
|
style={{ color: 'var(--bl-error, #dc3545)', fontSize: '13px' }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || code.length < 6}
|
||||||
|
data-testid="bl-mfa-submit"
|
||||||
|
style={{
|
||||||
|
padding: '10px 16px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--bl-radius, 6px)',
|
||||||
|
background: 'var(--bl-primary, #0066ff)',
|
||||||
|
color: '#fff',
|
||||||
|
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
opacity: isLoading || code.length < 6 ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Verifying...' : 'Verify'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{onUseRecovery && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onUseRecovery}
|
||||||
|
disabled={isLoading}
|
||||||
|
data-testid="bl-mfa-recovery"
|
||||||
|
style={{
|
||||||
|
padding: '8px',
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--bl-link, #0066ff)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '13px',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Use a recovery code
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
vendor/bytelyst/auth-ui/src/OnboardingShell.tsx
vendored
Normal file
148
vendor/bytelyst/auth-ui/src/OnboardingShell.tsx
vendored
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import type { OnboardingShellProps } from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Onboarding shell — step indicator, navigation, progress bar.
|
||||||
|
* Wraps arbitrary step content provided via children.
|
||||||
|
* Styled via CSS custom properties (inherits --bl-* from host app).
|
||||||
|
*/
|
||||||
|
export function OnboardingShell({
|
||||||
|
steps,
|
||||||
|
currentStep,
|
||||||
|
onNext,
|
||||||
|
onBack,
|
||||||
|
onComplete,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: OnboardingShellProps) {
|
||||||
|
const isFirst = currentStep === 0;
|
||||||
|
const isLast = currentStep === steps.length - 1;
|
||||||
|
const progress = steps.length > 1 ? ((currentStep + 1) / steps.length) * 100 : 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} data-testid="bl-onboarding-shell">
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '4px',
|
||||||
|
borderRadius: '2px',
|
||||||
|
background: 'var(--bl-border, #e5e7eb)',
|
||||||
|
marginBottom: '24px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-testid="bl-onboarding-progress"
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${progress}%`,
|
||||||
|
background: 'var(--bl-primary, #0066ff)',
|
||||||
|
transition: 'width 0.3s ease',
|
||||||
|
borderRadius: '2px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step indicator */}
|
||||||
|
<div
|
||||||
|
data-testid="bl-onboarding-steps"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '8px',
|
||||||
|
marginBottom: '24px',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{steps.map((step, i) => (
|
||||||
|
<div
|
||||||
|
key={step.key}
|
||||||
|
data-testid={`bl-onboarding-step-${step.key}`}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
fontSize: '13px',
|
||||||
|
color:
|
||||||
|
i === currentStep
|
||||||
|
? 'var(--bl-primary, #0066ff)'
|
||||||
|
: i < currentStep
|
||||||
|
? 'var(--bl-success, #22c55e)'
|
||||||
|
: 'var(--bl-muted, #999)',
|
||||||
|
fontWeight: i === currentStep ? 600 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: '24px',
|
||||||
|
height: '24px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 600,
|
||||||
|
background:
|
||||||
|
i <= currentStep ? 'var(--bl-primary, #0066ff)' : 'var(--bl-border, #e5e7eb)',
|
||||||
|
color: i <= currentStep ? '#fff' : 'var(--bl-muted, #999)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{i < currentStep ? '✓' : i + 1}
|
||||||
|
</span>
|
||||||
|
{step.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step content */}
|
||||||
|
<div data-testid="bl-onboarding-content" style={{ marginBottom: '24px' }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBack}
|
||||||
|
disabled={isFirst}
|
||||||
|
data-testid="bl-onboarding-back"
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
border: '1px solid var(--bl-border, #ccc)',
|
||||||
|
borderRadius: 'var(--bl-radius, 6px)',
|
||||||
|
background: 'var(--bl-surface, #fff)',
|
||||||
|
color: 'var(--bl-text, #333)',
|
||||||
|
cursor: isFirst ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
opacity: isFirst ? 0.4 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={isLast ? onComplete : onNext}
|
||||||
|
data-testid={isLast ? 'bl-onboarding-complete' : 'bl-onboarding-next'}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--bl-radius, 6px)',
|
||||||
|
background: 'var(--bl-primary, #0066ff)',
|
||||||
|
color: '#fff',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLast ? 'Complete' : 'Next'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
vendor/bytelyst/auth-ui/src/PasswordStrengthBar.tsx
vendored
Normal file
67
vendor/bytelyst/auth-ui/src/PasswordStrengthBar.tsx
vendored
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { PasswordStrength } from './types.js';
|
||||||
|
|
||||||
|
interface PasswordStrengthBarProps {
|
||||||
|
password: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STRENGTH_CONFIG: Record<PasswordStrength, { color: string; label: string }> = {
|
||||||
|
weak: { color: 'var(--bl-error, #dc3545)', label: 'Weak' },
|
||||||
|
fair: { color: 'var(--bl-warning, #f59e0b)', label: 'Fair' },
|
||||||
|
good: { color: 'var(--bl-info, #3b82f6)', label: 'Good' },
|
||||||
|
strong: { color: 'var(--bl-success, #22c55e)', label: 'Strong' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getPasswordStrength(password: string): PasswordStrength {
|
||||||
|
let score = 0;
|
||||||
|
if (password.length >= 8) score++;
|
||||||
|
if (password.length >= 12) score++;
|
||||||
|
if (/[A-Z]/.test(password)) score++;
|
||||||
|
if (/[a-z]/.test(password)) score++;
|
||||||
|
if (/\d/.test(password)) score++;
|
||||||
|
if (/[^A-Za-z0-9]/.test(password)) score++;
|
||||||
|
|
||||||
|
if (score <= 2) return 'weak';
|
||||||
|
if (score <= 3) return 'fair';
|
||||||
|
if (score <= 4) return 'good';
|
||||||
|
return 'strong';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PasswordStrengthBar({ password, className }: PasswordStrengthBarProps) {
|
||||||
|
const strength = useMemo(() => getPasswordStrength(password), [password]);
|
||||||
|
const config = STRENGTH_CONFIG[strength];
|
||||||
|
const widthPercent = { weak: 25, fair: 50, good: 75, strong: 100 }[strength];
|
||||||
|
|
||||||
|
if (!password) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} data-testid="bl-password-strength">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '4px',
|
||||||
|
borderRadius: '2px',
|
||||||
|
background: 'var(--bl-border, #e5e7eb)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${widthPercent}%`,
|
||||||
|
background: config.color,
|
||||||
|
transition: 'width 0.2s, background 0.2s',
|
||||||
|
borderRadius: '2px',
|
||||||
|
}}
|
||||||
|
data-testid="bl-password-strength-fill"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{ fontSize: '12px', color: config.color, marginTop: '4px' }}
|
||||||
|
data-testid="bl-password-strength-label"
|
||||||
|
>
|
||||||
|
{config.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
226
vendor/bytelyst/auth-ui/src/RegisterForm.tsx
vendored
Normal file
226
vendor/bytelyst/auth-ui/src/RegisterForm.tsx
vendored
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { PasswordStrengthBar } from './PasswordStrengthBar.js';
|
||||||
|
import type { RegisterFormProps } from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registration form with name, email, password, confirm password,
|
||||||
|
* password strength indicator, and optional terms checkbox.
|
||||||
|
* Styled via CSS custom properties (inherits --bl-* from host app).
|
||||||
|
*/
|
||||||
|
export function RegisterForm({
|
||||||
|
onSubmit,
|
||||||
|
isLoading = false,
|
||||||
|
error,
|
||||||
|
termsUrl,
|
||||||
|
privacyUrl,
|
||||||
|
onSwitchToLogin,
|
||||||
|
className,
|
||||||
|
}: RegisterFormProps) {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirm, setConfirm] = useState('');
|
||||||
|
const [termsAccepted, setTermsAccepted] = useState(!termsUrl);
|
||||||
|
|
||||||
|
const passwordMismatch = confirm.length > 0 && password !== confirm;
|
||||||
|
const canSubmit =
|
||||||
|
name.trim().length > 0 &&
|
||||||
|
email.length > 0 &&
|
||||||
|
password.length >= 8 &&
|
||||||
|
!passwordMismatch &&
|
||||||
|
termsAccepted &&
|
||||||
|
!isLoading;
|
||||||
|
|
||||||
|
function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!canSubmit) return;
|
||||||
|
onSubmit({ name: name.trim(), email, password });
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
padding: '10px 12px',
|
||||||
|
border: '1px solid var(--bl-border, #ccc)',
|
||||||
|
borderRadius: 'var(--bl-radius, 6px)',
|
||||||
|
fontSize: '14px',
|
||||||
|
width: '100%',
|
||||||
|
boxSizing: 'border-box' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} data-testid="bl-register-form">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}
|
||||||
|
>
|
||||||
|
<label style={{ fontSize: '13px', fontWeight: 500, color: 'var(--bl-text, #333)' }}>
|
||||||
|
Name
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Your name"
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
data-testid="bl-register-name"
|
||||||
|
style={{ ...inputStyle, marginTop: '4px', display: 'block' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ fontSize: '13px', fontWeight: 500, color: 'var(--bl-text, #333)' }}>
|
||||||
|
Email
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
data-testid="bl-register-email"
|
||||||
|
style={{ ...inputStyle, marginTop: '4px', display: 'block' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ fontSize: '13px', fontWeight: 500, color: 'var(--bl-text, #333)' }}>
|
||||||
|
Password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Min 8 characters"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
disabled={isLoading}
|
||||||
|
data-testid="bl-register-password"
|
||||||
|
style={{ ...inputStyle, marginTop: '4px', display: 'block' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<PasswordStrengthBar password={password} />
|
||||||
|
|
||||||
|
<label style={{ fontSize: '13px', fontWeight: 500, color: 'var(--bl-text, #333)' }}>
|
||||||
|
Confirm password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Re-enter password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={e => setConfirm(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
data-testid="bl-register-confirm"
|
||||||
|
style={{
|
||||||
|
...inputStyle,
|
||||||
|
marginTop: '4px',
|
||||||
|
display: 'block',
|
||||||
|
borderColor: passwordMismatch ? 'var(--bl-error, #dc3545)' : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{passwordMismatch && (
|
||||||
|
<div
|
||||||
|
data-testid="bl-register-mismatch"
|
||||||
|
style={{ color: 'var(--bl-error, #dc3545)', fontSize: '12px' }}
|
||||||
|
>
|
||||||
|
Passwords do not match
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{termsUrl && (
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: '8px',
|
||||||
|
fontSize: '13px',
|
||||||
|
color: 'var(--bl-text, #333)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={termsAccepted}
|
||||||
|
onChange={e => setTermsAccepted(e.target.checked)}
|
||||||
|
disabled={isLoading}
|
||||||
|
data-testid="bl-register-terms"
|
||||||
|
style={{ marginTop: '2px' }}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
I agree to the{' '}
|
||||||
|
<a
|
||||||
|
href={termsUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ color: 'var(--bl-link, #0066ff)' }}
|
||||||
|
>
|
||||||
|
Terms of Service
|
||||||
|
</a>
|
||||||
|
{privacyUrl && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
and{' '}
|
||||||
|
<a
|
||||||
|
href={privacyUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ color: 'var(--bl-link, #0066ff)' }}
|
||||||
|
>
|
||||||
|
Privacy Policy
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
data-testid="bl-register-error"
|
||||||
|
style={{ color: 'var(--bl-error, #dc3545)', fontSize: '13px' }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!canSubmit}
|
||||||
|
data-testid="bl-register-submit"
|
||||||
|
style={{
|
||||||
|
padding: '10px 16px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--bl-radius, 6px)',
|
||||||
|
background: 'var(--bl-primary, #0066ff)',
|
||||||
|
color: '#fff',
|
||||||
|
cursor: !canSubmit ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
opacity: !canSubmit ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Creating account...' : 'Create account'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{onSwitchToLogin && (
|
||||||
|
<div style={{ textAlign: 'center', fontSize: '13px', color: 'var(--bl-muted, #999)' }}>
|
||||||
|
Already have an account?{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSwitchToLogin}
|
||||||
|
data-testid="bl-register-switch-login"
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--bl-link, #0066ff)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
fontSize: '13px',
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
vendor/bytelyst/auth-ui/src/ResetPasswordForm.tsx
vendored
Normal file
131
vendor/bytelyst/auth-ui/src/ResetPasswordForm.tsx
vendored
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { PasswordStrengthBar } from './PasswordStrengthBar.js';
|
||||||
|
import type { ResetPasswordFormProps } from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset password form — new password + confirm, with strength indicator.
|
||||||
|
* Styled via CSS custom properties (inherits --bl-* from host app).
|
||||||
|
*/
|
||||||
|
export function ResetPasswordForm({
|
||||||
|
onSubmit,
|
||||||
|
isLoading = false,
|
||||||
|
error,
|
||||||
|
success,
|
||||||
|
className,
|
||||||
|
}: ResetPasswordFormProps) {
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirm, setConfirm] = useState('');
|
||||||
|
|
||||||
|
const passwordMismatch = confirm.length > 0 && password !== confirm;
|
||||||
|
const canSubmit = password.length >= 8 && !passwordMismatch && !isLoading;
|
||||||
|
|
||||||
|
function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!canSubmit) return;
|
||||||
|
onSubmit(password);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
padding: '10px 12px',
|
||||||
|
border: '1px solid var(--bl-border, #ccc)',
|
||||||
|
borderRadius: 'var(--bl-radius, 6px)',
|
||||||
|
fontSize: '14px',
|
||||||
|
width: '100%',
|
||||||
|
boxSizing: 'border-box' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} data-testid="bl-reset-password-form">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '14px', color: 'var(--bl-text, #333)' }}>
|
||||||
|
Enter your new password.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label style={{ fontSize: '13px', fontWeight: 500, color: 'var(--bl-text, #333)' }}>
|
||||||
|
New password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Min 8 characters"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
disabled={isLoading}
|
||||||
|
data-testid="bl-reset-password"
|
||||||
|
style={{ ...inputStyle, marginTop: '4px', display: 'block' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<PasswordStrengthBar password={password} />
|
||||||
|
|
||||||
|
<label style={{ fontSize: '13px', fontWeight: 500, color: 'var(--bl-text, #333)' }}>
|
||||||
|
Confirm password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Re-enter password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={e => setConfirm(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
data-testid="bl-reset-confirm"
|
||||||
|
style={{
|
||||||
|
...inputStyle,
|
||||||
|
marginTop: '4px',
|
||||||
|
display: 'block',
|
||||||
|
borderColor: passwordMismatch ? 'var(--bl-error, #dc3545)' : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{passwordMismatch && (
|
||||||
|
<div
|
||||||
|
data-testid="bl-reset-mismatch"
|
||||||
|
style={{ color: 'var(--bl-error, #dc3545)', fontSize: '12px' }}
|
||||||
|
>
|
||||||
|
Passwords do not match
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
data-testid="bl-reset-error"
|
||||||
|
style={{ color: 'var(--bl-error, #dc3545)', fontSize: '13px' }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div
|
||||||
|
data-testid="bl-reset-success"
|
||||||
|
style={{ color: 'var(--bl-success, #22c55e)', fontSize: '13px' }}
|
||||||
|
>
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!canSubmit}
|
||||||
|
data-testid="bl-reset-submit"
|
||||||
|
style={{
|
||||||
|
padding: '10px 16px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--bl-radius, 6px)',
|
||||||
|
background: 'var(--bl-primary, #0066ff)',
|
||||||
|
color: '#fff',
|
||||||
|
cursor: !canSubmit ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
opacity: !canSubmit ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Updating...' : 'Update password'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
vendor/bytelyst/auth-ui/src/SocialButtons.tsx
vendored
Normal file
48
vendor/bytelyst/auth-ui/src/SocialButtons.tsx
vendored
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import type { SocialButtonsProps, SocialProvider } from './types.js';
|
||||||
|
|
||||||
|
const PROVIDER_LABELS: Record<SocialProvider, string> = {
|
||||||
|
google: 'Google',
|
||||||
|
microsoft: 'Microsoft',
|
||||||
|
apple: 'Apple',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders social login buttons for the configured providers.
|
||||||
|
* Styled via CSS custom properties (inherits --bl-* from host app).
|
||||||
|
*/
|
||||||
|
export function SocialButtons({
|
||||||
|
providers,
|
||||||
|
onSelect,
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
}: SocialButtonsProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}
|
||||||
|
data-testid="bl-social-buttons"
|
||||||
|
>
|
||||||
|
{providers.map(provider => (
|
||||||
|
<button
|
||||||
|
key={provider}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(provider)}
|
||||||
|
disabled={disabled}
|
||||||
|
data-testid={`bl-social-${provider}`}
|
||||||
|
style={{
|
||||||
|
padding: '10px 16px',
|
||||||
|
border: '1px solid var(--bl-border, #ccc)',
|
||||||
|
borderRadius: 'var(--bl-radius, 6px)',
|
||||||
|
background: 'var(--bl-surface, #fff)',
|
||||||
|
color: 'var(--bl-text, #333)',
|
||||||
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
opacity: disabled ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Continue with {PROVIDER_LABELS[provider]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
vendor/bytelyst/auth-ui/src/VerifyEmailForm.tsx
vendored
Normal file
117
vendor/bytelyst/auth-ui/src/VerifyEmailForm.tsx
vendored
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import type { VerifyEmailFormProps } from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email verification form — 6-digit code input with resend option.
|
||||||
|
* Styled via CSS custom properties (inherits --bl-* from host app).
|
||||||
|
*/
|
||||||
|
export function VerifyEmailForm({
|
||||||
|
onSubmit,
|
||||||
|
onResend,
|
||||||
|
isLoading = false,
|
||||||
|
error,
|
||||||
|
success,
|
||||||
|
email,
|
||||||
|
className,
|
||||||
|
}: VerifyEmailFormProps) {
|
||||||
|
const [code, setCode] = useState('');
|
||||||
|
|
||||||
|
function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} data-testid="bl-verify-email-form">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '14px', color: 'var(--bl-text, #333)' }}>
|
||||||
|
Enter the 6-digit code sent to {email ? <strong>{email}</strong> : 'your email'}.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
placeholder="000000"
|
||||||
|
value={code}
|
||||||
|
onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
maxLength={6}
|
||||||
|
data-testid="bl-verify-code"
|
||||||
|
style={{
|
||||||
|
padding: '12px',
|
||||||
|
border: '1px solid var(--bl-border, #ccc)',
|
||||||
|
borderRadius: 'var(--bl-radius, 6px)',
|
||||||
|
fontSize: '24px',
|
||||||
|
textAlign: 'center',
|
||||||
|
letterSpacing: '6px',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
width: '100%',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
data-testid="bl-verify-error"
|
||||||
|
style={{ color: 'var(--bl-error, #dc3545)', fontSize: '13px' }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div
|
||||||
|
data-testid="bl-verify-success"
|
||||||
|
style={{ color: 'var(--bl-success, #22c55e)', fontSize: '13px' }}
|
||||||
|
>
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || code.length < 6}
|
||||||
|
data-testid="bl-verify-submit"
|
||||||
|
style={{
|
||||||
|
padding: '10px 16px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--bl-radius, 6px)',
|
||||||
|
background: 'var(--bl-primary, #0066ff)',
|
||||||
|
color: '#fff',
|
||||||
|
cursor: isLoading || code.length < 6 ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
opacity: isLoading || code.length < 6 ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Verifying...' : 'Verify email'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{onResend && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onResend}
|
||||||
|
disabled={isLoading}
|
||||||
|
data-testid="bl-verify-resend"
|
||||||
|
style={{
|
||||||
|
padding: '8px',
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--bl-link, #0066ff)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '13px',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Resend code
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
vendor/bytelyst/auth-ui/src/__tests__/auth-ui.test.tsx
vendored
Normal file
155
vendor/bytelyst/auth-ui/src/__tests__/auth-ui.test.tsx
vendored
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||||
|
import { LoginForm } from '../LoginForm.js';
|
||||||
|
import { MfaChallenge } from '../MfaChallenge.js';
|
||||||
|
import { SocialButtons } from '../SocialButtons.js';
|
||||||
|
|
||||||
|
describe('@bytelyst/auth-ui', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SocialButtons', () => {
|
||||||
|
it('renders buttons for each provider', () => {
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
render(<SocialButtons providers={['google', 'microsoft', 'apple']} onSelect={onSelect} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('bl-social-google')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('bl-social-microsoft')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('bl-social-apple')).toBeDefined();
|
||||||
|
expect(screen.getByText('Continue with Google')).toBeDefined();
|
||||||
|
expect(screen.getByText('Continue with Microsoft')).toBeDefined();
|
||||||
|
expect(screen.getByText('Continue with Apple')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSelect with provider when clicked', () => {
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
render(<SocialButtons providers={['google']} onSelect={onSelect} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('bl-social-google'));
|
||||||
|
expect(onSelect).toHaveBeenCalledWith('google');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables buttons when disabled prop is true', () => {
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
render(<SocialButtons providers={['google']} onSelect={onSelect} disabled />);
|
||||||
|
|
||||||
|
const btn = screen.getByTestId('bl-social-google');
|
||||||
|
expect(btn.getAttribute('disabled')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('LoginForm', () => {
|
||||||
|
it('renders email, password, and submit button', () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
render(<LoginForm onSubmit={onSubmit} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('bl-login-email')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('bl-login-password')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('bl-login-submit')).toBeDefined();
|
||||||
|
expect(screen.getByText('Sign in')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSubmit with email and password', () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
render(<LoginForm onSubmit={onSubmit} />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByTestId('bl-login-email'), {
|
||||||
|
target: { value: 'test@example.com' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByTestId('bl-login-password'), {
|
||||||
|
target: { value: 'password123' },
|
||||||
|
});
|
||||||
|
fireEvent.submit(screen.getByTestId('bl-login-submit').closest('form')!);
|
||||||
|
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith('test@example.com', 'password123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays error message', () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
render(<LoginForm onSubmit={onSubmit} error="Invalid credentials" />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('bl-login-error')).toBeDefined();
|
||||||
|
expect(screen.getByText('Invalid credentials')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders social buttons when providers are given', () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
const onSocialLogin = vi.fn();
|
||||||
|
render(
|
||||||
|
<LoginForm
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
providers={['google', 'apple']}
|
||||||
|
onSocialLogin={onSocialLogin}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('bl-social-google')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('bl-social-apple')).toBeDefined();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('bl-social-google'));
|
||||||
|
expect(onSocialLogin).toHaveBeenCalledWith('google');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows loading state', () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
render(<LoginForm onSubmit={onSubmit} isLoading />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Signing in...')).toBeDefined();
|
||||||
|
const btn = screen.getByTestId('bl-login-submit');
|
||||||
|
expect(btn.getAttribute('disabled')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MfaChallenge', () => {
|
||||||
|
it('renders code input and verify button', () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
render(<MfaChallenge onSubmit={onSubmit} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('bl-mfa-code')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('bl-mfa-submit')).toBeDefined();
|
||||||
|
expect(screen.getByText('Verify')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSubmit with code', () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
render(<MfaChallenge onSubmit={onSubmit} />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByTestId('bl-mfa-code'), {
|
||||||
|
target: { value: '123456' },
|
||||||
|
});
|
||||||
|
fireEvent.submit(screen.getByTestId('bl-mfa-submit').closest('form')!);
|
||||||
|
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith('123456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays available methods', () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
render(<MfaChallenge onSubmit={onSubmit} methods={['totp', 'recovery']} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('bl-mfa-methods')).toBeDefined();
|
||||||
|
expect(screen.getByText('Available methods: totp, recovery')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows recovery code button when handler provided', () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
const onUseRecovery = vi.fn();
|
||||||
|
render(<MfaChallenge onSubmit={onSubmit} onUseRecovery={onUseRecovery} />);
|
||||||
|
|
||||||
|
const recoveryBtn = screen.getByTestId('bl-mfa-recovery');
|
||||||
|
expect(recoveryBtn).toBeDefined();
|
||||||
|
|
||||||
|
fireEvent.click(recoveryBtn);
|
||||||
|
expect(onUseRecovery).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays error message', () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
render(<MfaChallenge onSubmit={onSubmit} error="Invalid code" />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('bl-mfa-error')).toBeDefined();
|
||||||
|
expect(screen.getByText('Invalid code')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
402
vendor/bytelyst/auth-ui/src/__tests__/new-components.test.tsx
vendored
Normal file
402
vendor/bytelyst/auth-ui/src/__tests__/new-components.test.tsx
vendored
Normal file
@ -0,0 +1,402 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||||
|
import { RegisterForm } from '../RegisterForm.js';
|
||||||
|
import { ForgotPasswordForm } from '../ForgotPasswordForm.js';
|
||||||
|
import { ResetPasswordForm } from '../ResetPasswordForm.js';
|
||||||
|
import { VerifyEmailForm } from '../VerifyEmailForm.js';
|
||||||
|
import { OnboardingShell } from '../OnboardingShell.js';
|
||||||
|
import { AuthPageLayout } from '../AuthPageLayout.js';
|
||||||
|
import { PasswordStrengthBar, getPasswordStrength } from '../PasswordStrengthBar.js';
|
||||||
|
|
||||||
|
describe('RegisterForm', () => {
|
||||||
|
beforeEach(() => cleanup());
|
||||||
|
|
||||||
|
it('renders all fields', () => {
|
||||||
|
render(<RegisterForm onSubmit={vi.fn()} />);
|
||||||
|
expect(screen.getByTestId('bl-register-name')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('bl-register-email')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('bl-register-password')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('bl-register-confirm')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('bl-register-submit')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSubmit with name, email, password', () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
render(<RegisterForm onSubmit={onSubmit} />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByTestId('bl-register-name'), { target: { value: 'Alice' } });
|
||||||
|
fireEvent.change(screen.getByTestId('bl-register-email'), {
|
||||||
|
target: { value: 'alice@example.com' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByTestId('bl-register-password'), {
|
||||||
|
target: { value: 'Password1!' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByTestId('bl-register-confirm'), {
|
||||||
|
target: { value: 'Password1!' },
|
||||||
|
});
|
||||||
|
fireEvent.submit(screen.getByTestId('bl-register-submit').closest('form')!);
|
||||||
|
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith({
|
||||||
|
name: 'Alice',
|
||||||
|
email: 'alice@example.com',
|
||||||
|
password: 'Password1!',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows password mismatch error', () => {
|
||||||
|
render(<RegisterForm onSubmit={vi.fn()} />);
|
||||||
|
fireEvent.change(screen.getByTestId('bl-register-password'), {
|
||||||
|
target: { value: 'Password1!' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByTestId('bl-register-confirm'), {
|
||||||
|
target: { value: 'Different1!' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId('bl-register-mismatch')).toBeDefined();
|
||||||
|
expect(screen.getByText('Passwords do not match')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays error message', () => {
|
||||||
|
render(<RegisterForm onSubmit={vi.fn()} error="Email already taken" />);
|
||||||
|
expect(screen.getByTestId('bl-register-error')).toBeDefined();
|
||||||
|
expect(screen.getByText('Email already taken')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows terms checkbox when termsUrl provided', () => {
|
||||||
|
render(<RegisterForm onSubmit={vi.fn()} termsUrl="https://example.com/terms" />);
|
||||||
|
expect(screen.getByTestId('bl-register-terms')).toBeDefined();
|
||||||
|
expect(screen.getByText('Terms of Service')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders switch to login link', () => {
|
||||||
|
const onSwitch = vi.fn();
|
||||||
|
render(<RegisterForm onSubmit={vi.fn()} onSwitchToLogin={onSwitch} />);
|
||||||
|
const link = screen.getByTestId('bl-register-switch-login');
|
||||||
|
fireEvent.click(link);
|
||||||
|
expect(onSwitch).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows loading state', () => {
|
||||||
|
render(<RegisterForm onSubmit={vi.fn()} isLoading />);
|
||||||
|
expect(screen.getByText('Creating account...')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows password strength bar when typing', () => {
|
||||||
|
render(<RegisterForm onSubmit={vi.fn()} />);
|
||||||
|
fireEvent.change(screen.getByTestId('bl-register-password'), { target: { value: 'ab' } });
|
||||||
|
expect(screen.getByTestId('bl-password-strength')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ForgotPasswordForm', () => {
|
||||||
|
beforeEach(() => cleanup());
|
||||||
|
|
||||||
|
it('renders email input and submit', () => {
|
||||||
|
render(<ForgotPasswordForm onSubmit={vi.fn()} />);
|
||||||
|
expect(screen.getByTestId('bl-forgot-email')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('bl-forgot-submit')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSubmit with email', () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
render(<ForgotPasswordForm onSubmit={onSubmit} />);
|
||||||
|
fireEvent.change(screen.getByTestId('bl-forgot-email'), {
|
||||||
|
target: { value: 'test@example.com' },
|
||||||
|
});
|
||||||
|
fireEvent.submit(screen.getByTestId('bl-forgot-submit').closest('form')!);
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith('test@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays error message', () => {
|
||||||
|
render(<ForgotPasswordForm onSubmit={vi.fn()} error="Email not found" />);
|
||||||
|
expect(screen.getByTestId('bl-forgot-error')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays success message', () => {
|
||||||
|
render(<ForgotPasswordForm onSubmit={vi.fn()} success="Check your email" />);
|
||||||
|
expect(screen.getByTestId('bl-forgot-success')).toBeDefined();
|
||||||
|
expect(screen.getByText('Check your email')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders back button and calls onBack', () => {
|
||||||
|
const onBack = vi.fn();
|
||||||
|
render(<ForgotPasswordForm onSubmit={vi.fn()} onBack={onBack} />);
|
||||||
|
fireEvent.click(screen.getByTestId('bl-forgot-back'));
|
||||||
|
expect(onBack).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows loading state', () => {
|
||||||
|
render(<ForgotPasswordForm onSubmit={vi.fn()} isLoading />);
|
||||||
|
expect(screen.getByText('Sending...')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ResetPasswordForm', () => {
|
||||||
|
beforeEach(() => cleanup());
|
||||||
|
|
||||||
|
it('renders password fields and submit', () => {
|
||||||
|
render(<ResetPasswordForm onSubmit={vi.fn()} />);
|
||||||
|
expect(screen.getByTestId('bl-reset-password')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('bl-reset-confirm')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('bl-reset-submit')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSubmit with password', () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
render(<ResetPasswordForm onSubmit={onSubmit} />);
|
||||||
|
fireEvent.change(screen.getByTestId('bl-reset-password'), { target: { value: 'NewPass1!' } });
|
||||||
|
fireEvent.change(screen.getByTestId('bl-reset-confirm'), { target: { value: 'NewPass1!' } });
|
||||||
|
fireEvent.submit(screen.getByTestId('bl-reset-submit').closest('form')!);
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith('NewPass1!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows mismatch error', () => {
|
||||||
|
render(<ResetPasswordForm onSubmit={vi.fn()} />);
|
||||||
|
fireEvent.change(screen.getByTestId('bl-reset-password'), { target: { value: 'NewPass1!' } });
|
||||||
|
fireEvent.change(screen.getByTestId('bl-reset-confirm'), { target: { value: 'Different!' } });
|
||||||
|
expect(screen.getByTestId('bl-reset-mismatch')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays error and success messages', () => {
|
||||||
|
const { rerender } = render(<ResetPasswordForm onSubmit={vi.fn()} error="Token expired" />);
|
||||||
|
expect(screen.getByTestId('bl-reset-error')).toBeDefined();
|
||||||
|
|
||||||
|
rerender(<ResetPasswordForm onSubmit={vi.fn()} success="Password updated" />);
|
||||||
|
expect(screen.getByTestId('bl-reset-success')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows password strength bar', () => {
|
||||||
|
render(<ResetPasswordForm onSubmit={vi.fn()} />);
|
||||||
|
fireEvent.change(screen.getByTestId('bl-reset-password'), { target: { value: 'abc' } });
|
||||||
|
expect(screen.getByTestId('bl-password-strength')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('VerifyEmailForm', () => {
|
||||||
|
beforeEach(() => cleanup());
|
||||||
|
|
||||||
|
it('renders code input and submit', () => {
|
||||||
|
render(<VerifyEmailForm onSubmit={vi.fn()} />);
|
||||||
|
expect(screen.getByTestId('bl-verify-code')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('bl-verify-submit')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSubmit with code', () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
render(<VerifyEmailForm onSubmit={onSubmit} />);
|
||||||
|
fireEvent.change(screen.getByTestId('bl-verify-code'), { target: { value: '123456' } });
|
||||||
|
fireEvent.submit(screen.getByTestId('bl-verify-submit').closest('form')!);
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith('123456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays email address', () => {
|
||||||
|
render(<VerifyEmailForm onSubmit={vi.fn()} email="test@example.com" />);
|
||||||
|
expect(screen.getByText('test@example.com')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders resend button', () => {
|
||||||
|
const onResend = vi.fn();
|
||||||
|
render(<VerifyEmailForm onSubmit={vi.fn()} onResend={onResend} />);
|
||||||
|
fireEvent.click(screen.getByTestId('bl-verify-resend'));
|
||||||
|
expect(onResend).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays error and success messages', () => {
|
||||||
|
const { rerender } = render(<VerifyEmailForm onSubmit={vi.fn()} error="Invalid code" />);
|
||||||
|
expect(screen.getByTestId('bl-verify-error')).toBeDefined();
|
||||||
|
|
||||||
|
rerender(<VerifyEmailForm onSubmit={vi.fn()} success="Code resent" />);
|
||||||
|
expect(screen.getByTestId('bl-verify-success')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips non-numeric characters', () => {
|
||||||
|
render(<VerifyEmailForm onSubmit={vi.fn()} />);
|
||||||
|
const input = screen.getByTestId('bl-verify-code');
|
||||||
|
fireEvent.change(input, { target: { value: 'abc123def456' } });
|
||||||
|
expect((input as unknown as { value: string }).value).toBe('123456');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('OnboardingShell', () => {
|
||||||
|
beforeEach(() => cleanup());
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{ key: 'welcome', label: 'Welcome' },
|
||||||
|
{ key: 'profile', label: 'Profile' },
|
||||||
|
{ key: 'preferences', label: 'Preferences' },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('renders steps and content', () => {
|
||||||
|
render(
|
||||||
|
<OnboardingShell
|
||||||
|
steps={steps}
|
||||||
|
currentStep={0}
|
||||||
|
onNext={vi.fn()}
|
||||||
|
onBack={vi.fn()}
|
||||||
|
onComplete={vi.fn()}
|
||||||
|
>
|
||||||
|
<div>Step 1 content</div>
|
||||||
|
</OnboardingShell>
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('bl-onboarding-shell')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('bl-onboarding-steps')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('bl-onboarding-progress')).toBeDefined();
|
||||||
|
expect(screen.getByText('Step 1 content')).toBeDefined();
|
||||||
|
expect(screen.getByText('Welcome')).toBeDefined();
|
||||||
|
expect(screen.getByText('Profile')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables Back on first step', () => {
|
||||||
|
render(
|
||||||
|
<OnboardingShell
|
||||||
|
steps={steps}
|
||||||
|
currentStep={0}
|
||||||
|
onNext={vi.fn()}
|
||||||
|
onBack={vi.fn()}
|
||||||
|
onComplete={vi.fn()}
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</OnboardingShell>
|
||||||
|
);
|
||||||
|
const back = screen.getByTestId('bl-onboarding-back');
|
||||||
|
expect(back.getAttribute('disabled')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onNext on middle step', () => {
|
||||||
|
const onNext = vi.fn();
|
||||||
|
render(
|
||||||
|
<OnboardingShell
|
||||||
|
steps={steps}
|
||||||
|
currentStep={1}
|
||||||
|
onNext={onNext}
|
||||||
|
onBack={vi.fn()}
|
||||||
|
onComplete={vi.fn()}
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</OnboardingShell>
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByTestId('bl-onboarding-next'));
|
||||||
|
expect(onNext).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Complete on last step and calls onComplete', () => {
|
||||||
|
const onComplete = vi.fn();
|
||||||
|
render(
|
||||||
|
<OnboardingShell
|
||||||
|
steps={steps}
|
||||||
|
currentStep={2}
|
||||||
|
onNext={vi.fn()}
|
||||||
|
onBack={vi.fn()}
|
||||||
|
onComplete={onComplete}
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</OnboardingShell>
|
||||||
|
);
|
||||||
|
const btn = screen.getByTestId('bl-onboarding-complete');
|
||||||
|
expect(btn.textContent).toBe('Complete');
|
||||||
|
fireEvent.click(btn);
|
||||||
|
expect(onComplete).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onBack on non-first step', () => {
|
||||||
|
const onBack = vi.fn();
|
||||||
|
render(
|
||||||
|
<OnboardingShell
|
||||||
|
steps={steps}
|
||||||
|
currentStep={1}
|
||||||
|
onNext={vi.fn()}
|
||||||
|
onBack={onBack}
|
||||||
|
onComplete={vi.fn()}
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</OnboardingShell>
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByTestId('bl-onboarding-back'));
|
||||||
|
expect(onBack).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AuthPageLayout', () => {
|
||||||
|
beforeEach(() => cleanup());
|
||||||
|
|
||||||
|
it('renders product name and title', () => {
|
||||||
|
render(
|
||||||
|
<AuthPageLayout productName="TestApp" title="Sign In">
|
||||||
|
<div>Form content</div>
|
||||||
|
</AuthPageLayout>
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('bl-auth-product-name').textContent).toBe('TestApp');
|
||||||
|
expect(screen.getByTestId('bl-auth-title').textContent).toBe('Sign In');
|
||||||
|
expect(screen.getByText('Form content')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders subtitle when provided', () => {
|
||||||
|
render(
|
||||||
|
<AuthPageLayout productName="TestApp" title="Sign In" subtitle="Welcome back">
|
||||||
|
<div />
|
||||||
|
</AuthPageLayout>
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('bl-auth-subtitle').textContent).toBe('Welcome back');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders logo as element', () => {
|
||||||
|
render(
|
||||||
|
<AuthPageLayout
|
||||||
|
productName="TestApp"
|
||||||
|
title="Sign In"
|
||||||
|
logo={<span data-testid="custom-logo">Logo</span>}
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</AuthPageLayout>
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('custom-logo')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders footer', () => {
|
||||||
|
render(
|
||||||
|
<AuthPageLayout productName="TestApp" title="Sign In" footer={<span>Footer text</span>}>
|
||||||
|
<div />
|
||||||
|
</AuthPageLayout>
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('bl-auth-footer')).toBeDefined();
|
||||||
|
expect(screen.getByText('Footer text')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PasswordStrengthBar', () => {
|
||||||
|
beforeEach(() => cleanup());
|
||||||
|
|
||||||
|
it('returns null for empty password', () => {
|
||||||
|
const { container } = render(<PasswordStrengthBar password="" />);
|
||||||
|
expect(container.querySelector('[data-testid="bl-password-strength"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Weak for short password', () => {
|
||||||
|
render(<PasswordStrengthBar password="ab" />);
|
||||||
|
expect(screen.getByTestId('bl-password-strength-label').textContent).toBe('Weak');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Strong for complex password', () => {
|
||||||
|
render(<PasswordStrengthBar password="MyStr0ng!Pass" />);
|
||||||
|
expect(screen.getByTestId('bl-password-strength-label').textContent).toBe('Strong');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPasswordStrength', () => {
|
||||||
|
it('returns weak for very short passwords', () => {
|
||||||
|
expect(getPasswordStrength('ab')).toBe('weak');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fair for medium passwords', () => {
|
||||||
|
expect(getPasswordStrength('abcdefgh1')).toBe('fair');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns good for decent passwords', () => {
|
||||||
|
expect(getPasswordStrength('Abcdefgh1')).toBe('good');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns strong for complex passwords', () => {
|
||||||
|
expect(getPasswordStrength('MyStr0ng!Pass')).toBe('strong');
|
||||||
|
});
|
||||||
|
});
|
||||||
24
vendor/bytelyst/auth-ui/src/index.ts
vendored
Normal file
24
vendor/bytelyst/auth-ui/src/index.ts
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
export { LoginForm } from './LoginForm.js';
|
||||||
|
export { RegisterForm } from './RegisterForm.js';
|
||||||
|
export { ForgotPasswordForm } from './ForgotPasswordForm.js';
|
||||||
|
export { ResetPasswordForm } from './ResetPasswordForm.js';
|
||||||
|
export { VerifyEmailForm } from './VerifyEmailForm.js';
|
||||||
|
export { MfaChallenge } from './MfaChallenge.js';
|
||||||
|
export { SocialButtons } from './SocialButtons.js';
|
||||||
|
export { OnboardingShell } from './OnboardingShell.js';
|
||||||
|
export { AuthPageLayout } from './AuthPageLayout.js';
|
||||||
|
export { PasswordStrengthBar, getPasswordStrength } from './PasswordStrengthBar.js';
|
||||||
|
export type {
|
||||||
|
LoginFormProps,
|
||||||
|
RegisterFormProps,
|
||||||
|
ForgotPasswordFormProps,
|
||||||
|
ResetPasswordFormProps,
|
||||||
|
VerifyEmailFormProps,
|
||||||
|
MfaChallengeProps,
|
||||||
|
SocialButtonsProps,
|
||||||
|
SocialProvider,
|
||||||
|
OnboardingShellProps,
|
||||||
|
OnboardingStep,
|
||||||
|
AuthPageLayoutProps,
|
||||||
|
PasswordStrength,
|
||||||
|
} from './types.js';
|
||||||
147
vendor/bytelyst/auth-ui/src/types.ts
vendored
Normal file
147
vendor/bytelyst/auth-ui/src/types.ts
vendored
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
export type SocialProvider = 'google' | 'microsoft' | 'apple';
|
||||||
|
|
||||||
|
export interface LoginFormProps {
|
||||||
|
/** Called when user submits email/password. */
|
||||||
|
onSubmit: (email: string, password: string) => void;
|
||||||
|
/** Social providers to display. */
|
||||||
|
providers?: SocialProvider[];
|
||||||
|
/** Called when user clicks a social login button. */
|
||||||
|
onSocialLogin?: (provider: SocialProvider) => void;
|
||||||
|
/** Whether the form is currently loading. */
|
||||||
|
isLoading?: boolean;
|
||||||
|
/** Error message to display. */
|
||||||
|
error?: string | null;
|
||||||
|
/** Additional CSS class for the root element. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MfaChallengeProps {
|
||||||
|
/** Called when user submits the MFA code. */
|
||||||
|
onSubmit: (code: string) => void;
|
||||||
|
/** Called when user clicks "Use recovery code". */
|
||||||
|
onUseRecovery?: () => void;
|
||||||
|
/** MFA methods available. */
|
||||||
|
methods?: string[];
|
||||||
|
/** Whether the form is currently loading. */
|
||||||
|
isLoading?: boolean;
|
||||||
|
/** Error message to display. */
|
||||||
|
error?: string | null;
|
||||||
|
/** Additional CSS class for the root element. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SocialButtonsProps {
|
||||||
|
/** Providers to display buttons for. */
|
||||||
|
providers: SocialProvider[];
|
||||||
|
/** Called when a provider button is clicked. */
|
||||||
|
onSelect: (provider: SocialProvider) => void;
|
||||||
|
/** Whether the buttons are disabled. */
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Additional CSS class for the root element. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterFormProps {
|
||||||
|
/** Called when user submits registration. */
|
||||||
|
onSubmit: (data: { name: string; email: string; password: string }) => void;
|
||||||
|
/** Whether the form is currently loading. */
|
||||||
|
isLoading?: boolean;
|
||||||
|
/** Error message to display. */
|
||||||
|
error?: string | null;
|
||||||
|
/** Terms of service URL (renders checkbox if provided). */
|
||||||
|
termsUrl?: string;
|
||||||
|
/** Privacy policy URL. */
|
||||||
|
privacyUrl?: string;
|
||||||
|
/** Called when user clicks "Already have an account?" */
|
||||||
|
onSwitchToLogin?: () => void;
|
||||||
|
/** Additional CSS class for the root element. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForgotPasswordFormProps {
|
||||||
|
/** Called when user submits email for password reset. */
|
||||||
|
onSubmit: (email: string) => void;
|
||||||
|
/** Whether the form is currently loading. */
|
||||||
|
isLoading?: boolean;
|
||||||
|
/** Error message to display. */
|
||||||
|
error?: string | null;
|
||||||
|
/** Success message (e.g., "Check your email"). */
|
||||||
|
success?: string | null;
|
||||||
|
/** Called when user clicks "Back to login". */
|
||||||
|
onBack?: () => void;
|
||||||
|
/** Additional CSS class for the root element. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResetPasswordFormProps {
|
||||||
|
/** Called when user submits new password. */
|
||||||
|
onSubmit: (password: string) => void;
|
||||||
|
/** Whether the form is currently loading. */
|
||||||
|
isLoading?: boolean;
|
||||||
|
/** Error message to display. */
|
||||||
|
error?: string | null;
|
||||||
|
/** Success message (e.g., "Password updated"). */
|
||||||
|
success?: string | null;
|
||||||
|
/** Additional CSS class for the root element. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifyEmailFormProps {
|
||||||
|
/** Called when user submits the verification code. */
|
||||||
|
onSubmit: (code: string) => void;
|
||||||
|
/** Called when user clicks "Resend code". */
|
||||||
|
onResend?: () => void;
|
||||||
|
/** Whether the form is currently loading. */
|
||||||
|
isLoading?: boolean;
|
||||||
|
/** Error message to display. */
|
||||||
|
error?: string | null;
|
||||||
|
/** Success message (e.g., "Code resent"). */
|
||||||
|
success?: string | null;
|
||||||
|
/** Email address being verified (for display). */
|
||||||
|
email?: string;
|
||||||
|
/** Additional CSS class for the root element. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnboardingStep {
|
||||||
|
/** Unique key for the step. */
|
||||||
|
key: string;
|
||||||
|
/** Display label for the step indicator. */
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnboardingShellProps {
|
||||||
|
/** Ordered list of steps. */
|
||||||
|
steps: OnboardingStep[];
|
||||||
|
/** Index of the current step (0-based). */
|
||||||
|
currentStep: number;
|
||||||
|
/** Called when user clicks Next. */
|
||||||
|
onNext: () => void;
|
||||||
|
/** Called when user clicks Back. */
|
||||||
|
onBack: () => void;
|
||||||
|
/** Called when the final step completes. */
|
||||||
|
onComplete: () => void;
|
||||||
|
/** Content to render for the current step. */
|
||||||
|
children: React.ReactNode;
|
||||||
|
/** Additional CSS class for the root element. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthPageLayoutProps {
|
||||||
|
/** Product name displayed at the top. */
|
||||||
|
productName: string;
|
||||||
|
/** Optional logo element or URL. */
|
||||||
|
logo?: React.ReactNode;
|
||||||
|
/** Page title (e.g., "Sign In", "Create Account"). */
|
||||||
|
title: string;
|
||||||
|
/** Subtitle or description. */
|
||||||
|
subtitle?: string;
|
||||||
|
/** Form content. */
|
||||||
|
children: React.ReactNode;
|
||||||
|
/** Footer content (links, etc.). */
|
||||||
|
footer?: React.ReactNode;
|
||||||
|
/** Additional CSS class for the root element. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PasswordStrength = 'weak' | 'fair' | 'good' | 'strong';
|
||||||
11
vendor/bytelyst/auth-ui/tsconfig.json
vendored
Normal file
11
vendor/bytelyst/auth-ui/tsconfig.json
vendored
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"lib": ["ES2022", "DOM"],
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
|
||||||
|
}
|
||||||
8
vendor/bytelyst/auth-ui/vitest.config.ts
vendored
Normal file
8
vendor/bytelyst/auth-ui/vitest.config.ts
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'happy-dom',
|
||||||
|
pool: 'forks',
|
||||||
|
},
|
||||||
|
});
|
||||||
2
vendor/bytelyst/auth/package.json
vendored
2
vendor/bytelyst/auth/package.json
vendored
@ -18,7 +18,7 @@
|
|||||||
"test": "vitest run --pool forks"
|
"test": "vitest run --pool forks"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bytelyst/errors": "file:../errors"
|
"@bytelyst/errors": "workspace:*"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"jose": ">=5.0.0",
|
"jose": ">=5.0.0",
|
||||||
|
|||||||
33
vendor/bytelyst/backend-config/package.json
vendored
Normal file
33
vendor/bytelyst/backend-config/package.json
vendored
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "@bytelyst/backend-config",
|
||||||
|
"version": "0.1.5",
|
||||||
|
"description": "Shared Zod config schema base for Fastify product backends",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run --pool forks",
|
||||||
|
"clean": "rm -rf dist"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"zod": "^3.24.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vitest": "^3.0.5"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/"
|
||||||
|
}
|
||||||
|
}
|
||||||
82
vendor/bytelyst/backend-config/src/index.test.ts
vendored
Normal file
82
vendor/bytelyst/backend-config/src/index.test.ts
vendored
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { baseBackendConfigSchema, parseBackendConfig } from './index.js';
|
||||||
|
|
||||||
|
describe('baseBackendConfigSchema', () => {
|
||||||
|
it('parses minimal valid env', () => {
|
||||||
|
const config = baseBackendConfigSchema.parse({
|
||||||
|
JWT_SECRET: 'test-secret',
|
||||||
|
});
|
||||||
|
expect(config.PORT).toBe(3000);
|
||||||
|
expect(config.HOST).toBe('0.0.0.0');
|
||||||
|
expect(config.NODE_ENV).toBe('development');
|
||||||
|
expect(config.DB_PROVIDER).toBe('cosmos');
|
||||||
|
expect(config.COSMOS_DATABASE).toBe('lysnrai');
|
||||||
|
expect(config.JWT_SECRET).toBe('test-secret');
|
||||||
|
expect(config.PLATFORM_JWKS_URL).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies overrides', () => {
|
||||||
|
const config = baseBackendConfigSchema.parse({
|
||||||
|
PORT: '4010',
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
DB_PROVIDER: 'memory',
|
||||||
|
JWT_SECRET: 'prod-secret',
|
||||||
|
PLATFORM_JWKS_URL: 'https://example.com/.well-known/jwks.json',
|
||||||
|
});
|
||||||
|
expect(config.PORT).toBe(4010);
|
||||||
|
expect(config.NODE_ENV).toBe('production');
|
||||||
|
expect(config.DB_PROVIDER).toBe('memory');
|
||||||
|
expect(config.PLATFORM_JWKS_URL).toBe('https://example.com/.well-known/jwks.json');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects missing JWT_SECRET', () => {
|
||||||
|
expect(() => baseBackendConfigSchema.parse({})).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid NODE_ENV', () => {
|
||||||
|
expect(() => baseBackendConfigSchema.parse({ JWT_SECRET: 's', NODE_ENV: 'staging' })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid DB_PROVIDER', () => {
|
||||||
|
expect(() =>
|
||||||
|
baseBackendConfigSchema.parse({ JWT_SECRET: 's', DB_PROVIDER: 'postgres' })
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('baseBackendConfigSchema.extend()', () => {
|
||||||
|
const extendedSchema = baseBackendConfigSchema.extend({
|
||||||
|
PLATFORM_SERVICE_URL: baseBackendConfigSchema.shape.HOST.default('http://localhost:4003'),
|
||||||
|
CUSTOM_FLAG: baseBackendConfigSchema.shape.NODE_ENV.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses extended config with product-specific fields', () => {
|
||||||
|
const config = extendedSchema.parse({
|
||||||
|
JWT_SECRET: 'test-secret',
|
||||||
|
PORT: '4018',
|
||||||
|
SERVICE_NAME: 'actiontrail-backend',
|
||||||
|
});
|
||||||
|
expect(config.PORT).toBe(4018);
|
||||||
|
expect(config.SERVICE_NAME).toBe('actiontrail-backend');
|
||||||
|
expect(config.PLATFORM_SERVICE_URL).toBe('http://localhost:4003');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseBackendConfig', () => {
|
||||||
|
it('parses from explicit env object', () => {
|
||||||
|
const config = parseBackendConfig(baseBackendConfigSchema, {
|
||||||
|
JWT_SECRET: 'from-env',
|
||||||
|
PORT: '9999',
|
||||||
|
});
|
||||||
|
expect(config.JWT_SECRET).toBe('from-env');
|
||||||
|
expect(config.PORT).toBe(9999);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with extended schemas', () => {
|
||||||
|
const schema = baseBackendConfigSchema.extend({
|
||||||
|
WEBHOOK_SECRET: baseBackendConfigSchema.shape.HOST.default('dev-webhook'),
|
||||||
|
});
|
||||||
|
const config = parseBackendConfig(schema, { JWT_SECRET: 'x' });
|
||||||
|
expect(config.WEBHOOK_SECRET).toBe('dev-webhook');
|
||||||
|
});
|
||||||
|
});
|
||||||
39
vendor/bytelyst/backend-config/src/index.ts
vendored
Normal file
39
vendor/bytelyst/backend-config/src/index.ts
vendored
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base Zod schema shared by all product backends.
|
||||||
|
*
|
||||||
|
* Products extend this with `.extend({...})` to add product-specific fields.
|
||||||
|
* The base covers: server, CORS, Cosmos DB, JWT auth, DB provider.
|
||||||
|
*/
|
||||||
|
export const baseBackendConfigSchema = z.object({
|
||||||
|
PORT: z.coerce.number().default(3000),
|
||||||
|
HOST: z.string().default('0.0.0.0'),
|
||||||
|
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||||
|
CORS_ORIGIN: z.string().optional(),
|
||||||
|
SERVICE_NAME: z.string().default('backend'),
|
||||||
|
|
||||||
|
DB_PROVIDER: z.enum(['cosmos', 'memory']).default('cosmos'),
|
||||||
|
COSMOS_ENDPOINT: z.string().default(''),
|
||||||
|
COSMOS_KEY: z.string().default(''),
|
||||||
|
COSMOS_DATABASE: z.string().default('lysnrai'),
|
||||||
|
|
||||||
|
JWT_SECRET: z.string().min(1, 'JWT_SECRET is required'),
|
||||||
|
PLATFORM_JWKS_URL: z.string().url().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type BaseBackendConfig = z.infer<typeof baseBackendConfigSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse and validate backend config from process.env.
|
||||||
|
*
|
||||||
|
* @param schema — Zod object schema (typically `baseBackendConfigSchema.extend({...})`)
|
||||||
|
* @param env — environment object (defaults to process.env)
|
||||||
|
* @returns Validated, typed config
|
||||||
|
*/
|
||||||
|
export function parseBackendConfig<T extends z.ZodRawShape>(
|
||||||
|
schema: z.ZodObject<T>,
|
||||||
|
env: Record<string, string | undefined> = process.env as Record<string, string | undefined>
|
||||||
|
): z.infer<z.ZodObject<T>> {
|
||||||
|
return schema.parse(env);
|
||||||
|
}
|
||||||
9
vendor/bytelyst/backend-config/tsconfig.json
vendored
Normal file
9
vendor/bytelyst/backend-config/tsconfig.json
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"declaration": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
30
vendor/bytelyst/backend-flags/package.json
vendored
Normal file
30
vendor/bytelyst/backend-flags/package.json
vendored
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "@bytelyst/backend-flags",
|
||||||
|
"version": "0.1.5",
|
||||||
|
"description": "In-memory feature flag registry for Fastify product backends",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run --pool forks",
|
||||||
|
"clean": "rm -rf dist"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vitest": "^3.0.5"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/"
|
||||||
|
}
|
||||||
|
}
|
||||||
43
vendor/bytelyst/backend-flags/src/index.test.ts
vendored
Normal file
43
vendor/bytelyst/backend-flags/src/index.test.ts
vendored
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { createFlagRegistry } from './index.js';
|
||||||
|
|
||||||
|
describe('createFlagRegistry', () => {
|
||||||
|
it('returns default flag values', () => {
|
||||||
|
const registry = createFlagRegistry({
|
||||||
|
defaults: { 'feature.a': true, 'feature.b': false },
|
||||||
|
});
|
||||||
|
expect(registry.isFeatureEnabled('feature.a')).toBe(true);
|
||||||
|
expect(registry.isFeatureEnabled('feature.b')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for unknown flags', () => {
|
||||||
|
const registry = createFlagRegistry({ defaults: {} });
|
||||||
|
expect(registry.isFeatureEnabled('nonexistent')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getAllFlags returns all defaults', () => {
|
||||||
|
const registry = createFlagRegistry({
|
||||||
|
defaults: { a: true, b: false, c: true },
|
||||||
|
});
|
||||||
|
expect(registry.getAllFlags()).toEqual({ a: true, b: false, c: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setFlag overrides a value', () => {
|
||||||
|
const registry = createFlagRegistry({ defaults: { x: false } });
|
||||||
|
expect(registry.isFeatureEnabled('x')).toBe(false);
|
||||||
|
registry.setFlag('x', true);
|
||||||
|
expect(registry.isFeatureEnabled('x')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setFlag creates new flags', () => {
|
||||||
|
const registry = createFlagRegistry({ defaults: {} });
|
||||||
|
registry.setFlag('new.flag', true);
|
||||||
|
expect(registry.isFeatureEnabled('new.flag')).toBe(true);
|
||||||
|
expect(registry.getAllFlags()).toEqual({ 'new.flag': true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts userId parameter without error', () => {
|
||||||
|
const registry = createFlagRegistry({ defaults: { a: true } });
|
||||||
|
expect(registry.isFeatureEnabled('a', 'user-1')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
38
vendor/bytelyst/backend-flags/src/index.ts
vendored
Normal file
38
vendor/bytelyst/backend-flags/src/index.ts
vendored
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* In-memory feature flag registry for product backends.
|
||||||
|
*
|
||||||
|
* Products call createFlagRegistry() with their default flags,
|
||||||
|
* then use isFeatureEnabled/getAllFlags/setFlag as needed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface FlagRegistry {
|
||||||
|
isFeatureEnabled(flag: string, userId?: string): boolean;
|
||||||
|
getAllFlags(): Record<string, boolean>;
|
||||||
|
setFlag(flag: string, value: boolean): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlagRegistryOptions {
|
||||||
|
/** Default flag values. */
|
||||||
|
defaults: Record<string, boolean>;
|
||||||
|
/** Master switch — when false, flags are still resolved from defaults but
|
||||||
|
* the registry won't attempt remote/dynamic flag resolution (future use). */
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFlagRegistry(opts: FlagRegistryOptions): FlagRegistry {
|
||||||
|
const flags: Map<string, boolean> = new Map(Object.entries(opts.defaults));
|
||||||
|
|
||||||
|
return {
|
||||||
|
isFeatureEnabled(flag: string, _userId?: string): boolean {
|
||||||
|
return flags.get(flag) ?? false;
|
||||||
|
},
|
||||||
|
|
||||||
|
getAllFlags(): Record<string, boolean> {
|
||||||
|
return Object.fromEntries(flags);
|
||||||
|
},
|
||||||
|
|
||||||
|
setFlag(flag: string, value: boolean): void {
|
||||||
|
flags.set(flag, value);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
9
vendor/bytelyst/backend-flags/tsconfig.json
vendored
Normal file
9
vendor/bytelyst/backend-flags/tsconfig.json
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"declaration": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
30
vendor/bytelyst/backend-telemetry/package.json
vendored
Normal file
30
vendor/bytelyst/backend-telemetry/package.json
vendored
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "@bytelyst/backend-telemetry",
|
||||||
|
"version": "0.1.5",
|
||||||
|
"description": "In-memory telemetry event buffer for Fastify product backends",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run --pool forks",
|
||||||
|
"clean": "rm -rf dist"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vitest": "^3.0.5"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/"
|
||||||
|
}
|
||||||
|
}
|
||||||
46
vendor/bytelyst/backend-telemetry/src/index.test.ts
vendored
Normal file
46
vendor/bytelyst/backend-telemetry/src/index.test.ts
vendored
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { createTelemetryBuffer } from './index.js';
|
||||||
|
|
||||||
|
describe('createTelemetryBuffer', () => {
|
||||||
|
it('buffers events when enabled', () => {
|
||||||
|
const buf = createTelemetryBuffer({ enabled: true });
|
||||||
|
buf.trackEvent('test.event', 'user-1', { key: 'val' });
|
||||||
|
const events = buf.getBufferedEvents();
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].event).toBe('test.event');
|
||||||
|
expect(events[0].userId).toBe('user-1');
|
||||||
|
expect(events[0].properties).toEqual({ key: 'val' });
|
||||||
|
expect(events[0].timestamp).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when disabled', () => {
|
||||||
|
const buf = createTelemetryBuffer({ enabled: false });
|
||||||
|
buf.trackEvent('test.event', 'user-1');
|
||||||
|
expect(buf.getBufferedEvents()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flushEvents returns and clears buffer', () => {
|
||||||
|
const buf = createTelemetryBuffer({ enabled: true });
|
||||||
|
buf.trackEvent('a');
|
||||||
|
buf.trackEvent('b');
|
||||||
|
const flushed = buf.flushEvents();
|
||||||
|
expect(flushed).toHaveLength(2);
|
||||||
|
expect(buf.getBufferedEvents()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getBufferedEvents returns a copy', () => {
|
||||||
|
const buf = createTelemetryBuffer({ enabled: true });
|
||||||
|
buf.trackEvent('a');
|
||||||
|
const copy = buf.getBufferedEvents();
|
||||||
|
copy.push({ event: 'fake' });
|
||||||
|
expect(buf.getBufferedEvents()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing optional fields', () => {
|
||||||
|
const buf = createTelemetryBuffer({ enabled: true });
|
||||||
|
buf.trackEvent('minimal');
|
||||||
|
const events = buf.getBufferedEvents();
|
||||||
|
expect(events[0].userId).toBeUndefined();
|
||||||
|
expect(events[0].properties).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
50
vendor/bytelyst/backend-telemetry/src/index.ts
vendored
Normal file
50
vendor/bytelyst/backend-telemetry/src/index.ts
vendored
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* In-memory telemetry event buffer for product backends.
|
||||||
|
*
|
||||||
|
* Products call createTelemetryBuffer() with an enabled flag,
|
||||||
|
* then use trackEvent/getBufferedEvents/flushEvents as needed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface TelemetryEvent {
|
||||||
|
event: string;
|
||||||
|
userId?: string;
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
timestamp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TelemetryBuffer {
|
||||||
|
trackEvent(event: string, userId?: string, properties?: Record<string, unknown>): void;
|
||||||
|
getBufferedEvents(): TelemetryEvent[];
|
||||||
|
flushEvents(): TelemetryEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TelemetryBufferOptions {
|
||||||
|
/** Master switch — when false, trackEvent is a no-op. */
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTelemetryBuffer(opts: TelemetryBufferOptions): TelemetryBuffer {
|
||||||
|
const buffer: TelemetryEvent[] = [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
trackEvent(event: string, userId?: string, properties?: Record<string, unknown>): void {
|
||||||
|
if (!opts.enabled) return;
|
||||||
|
buffer.push({
|
||||||
|
event,
|
||||||
|
userId,
|
||||||
|
properties,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getBufferedEvents(): TelemetryEvent[] {
|
||||||
|
return [...buffer];
|
||||||
|
},
|
||||||
|
|
||||||
|
flushEvents(): TelemetryEvent[] {
|
||||||
|
const flushed = [...buffer];
|
||||||
|
buffer.length = 0;
|
||||||
|
return flushed;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
9
vendor/bytelyst/backend-telemetry/tsconfig.json
vendored
Normal file
9
vendor/bytelyst/backend-telemetry/tsconfig.json
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"declaration": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
1
vendor/bytelyst/billing-client/.gitignore
vendored
Normal file
1
vendor/bytelyst/billing-client/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
*.tgz
|
||||||
28
vendor/bytelyst/billing-client/package.json
vendored
Normal file
28
vendor/bytelyst/billing-client/package.json
vendored
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "@bytelyst/billing-client",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Browser/React Native-safe billing and subscription client for platform-service — plans, subscriptions, payments, and usage",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"test": "vitest run --pool forks"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vitest": "^3.0.0"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/"
|
||||||
|
}
|
||||||
|
}
|
||||||
119
vendor/bytelyst/billing-client/src/index.test.ts
vendored
Normal file
119
vendor/bytelyst/billing-client/src/index.test.ts
vendored
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { createBillingClient, BillingApiError } from './index.js';
|
||||||
|
import type { BillingClient } from './index.js';
|
||||||
|
|
||||||
|
function mockFetch(status: number, body: unknown) {
|
||||||
|
return vi.fn().mockResolvedValue({
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
json: () => Promise.resolve(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createBillingClient', () => {
|
||||||
|
let client: BillingClient;
|
||||||
|
const config = {
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'notelett',
|
||||||
|
getAccessToken: () => 'test-token',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
client = createBillingClient(config);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listPlans — returns plans array', async () => {
|
||||||
|
const plans = [{ name: 'free', displayName: 'Free', price: 0 }];
|
||||||
|
globalThis.fetch = mockFetch(200, { plans });
|
||||||
|
const result = await client.listPlans();
|
||||||
|
expect(result).toEqual(plans);
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||||
|
'http://localhost:4003/api/plans',
|
||||||
|
expect.objectContaining({ method: 'GET' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getPlan — returns single plan', async () => {
|
||||||
|
const plan = { name: 'pro', displayName: 'Pro', price: 9.99 };
|
||||||
|
globalThis.fetch = mockFetch(200, plan);
|
||||||
|
const result = await client.getPlan('pro');
|
||||||
|
expect(result).toEqual(plan);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getSubscription — returns subscription', async () => {
|
||||||
|
const sub = { id: 'sub_1', plan: 'free', status: 'active' };
|
||||||
|
globalThis.fetch = mockFetch(200, sub);
|
||||||
|
const result = await client.getSubscription();
|
||||||
|
expect(result).toEqual(sub);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getSubscription — returns null on 404', async () => {
|
||||||
|
globalThis.fetch = mockFetch(404, { message: 'Not found' });
|
||||||
|
const result = await client.getSubscription();
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changePlan — sends plan in body', async () => {
|
||||||
|
const sub = { id: 'sub_1', plan: 'pro', status: 'active' };
|
||||||
|
globalThis.fetch = mockFetch(200, sub);
|
||||||
|
const result = await client.changePlan('pro');
|
||||||
|
expect(result.plan).toBe('pro');
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||||
|
'http://localhost:4003/api/subscriptions/me/change-plan',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ plan: 'pro' }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancelSubscription — POST to cancel endpoint', async () => {
|
||||||
|
const sub = { id: 'sub_1', plan: 'pro', cancelAtPeriodEnd: true };
|
||||||
|
globalThis.fetch = mockFetch(200, sub);
|
||||||
|
const result = await client.cancelSubscription();
|
||||||
|
expect(result.cancelAtPeriodEnd).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resumeSubscription — POST to resume endpoint', async () => {
|
||||||
|
const sub = { id: 'sub_1', plan: 'pro', cancelAtPeriodEnd: false };
|
||||||
|
globalThis.fetch = mockFetch(200, sub);
|
||||||
|
const result = await client.resumeSubscription();
|
||||||
|
expect(result.cancelAtPeriodEnd).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listPayments — returns payments array', async () => {
|
||||||
|
const payments = [{ id: 'pay_1', amount: 999, status: 'succeeded' }];
|
||||||
|
globalThis.fetch = mockFetch(200, { payments });
|
||||||
|
const result = await client.listPayments();
|
||||||
|
expect(result).toEqual(payments);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getUsage — returns usage summary', async () => {
|
||||||
|
const usage = { tokensUsed: 500, tokensIncluded: 10000, tokensRemaining: 9500, percentUsed: 5 };
|
||||||
|
globalThis.fetch = mockFetch(200, usage);
|
||||||
|
const result = await client.getUsage();
|
||||||
|
expect(result.tokensRemaining).toBe(9500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends auth header and product-id header', async () => {
|
||||||
|
globalThis.fetch = mockFetch(200, { plans: [] });
|
||||||
|
await client.listPlans();
|
||||||
|
const call = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
const headers = call[1].headers;
|
||||||
|
expect(headers['Authorization']).toBe('Bearer test-token');
|
||||||
|
expect(headers['x-product-id']).toBe('notelett');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws BillingApiError on non-ok response', async () => {
|
||||||
|
globalThis.fetch = mockFetch(403, { message: 'Forbidden' });
|
||||||
|
await expect(client.changePlan('enterprise')).rejects.toThrow(BillingApiError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works without access token', async () => {
|
||||||
|
const noAuthClient = createBillingClient({ ...config, getAccessToken: () => null });
|
||||||
|
globalThis.fetch = mockFetch(200, { plans: [] });
|
||||||
|
await noAuthClient.listPlans();
|
||||||
|
const headers = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].headers;
|
||||||
|
expect(headers['Authorization']).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
214
vendor/bytelyst/billing-client/src/index.ts
vendored
Normal file
214
vendor/bytelyst/billing-client/src/index.ts
vendored
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
/**
|
||||||
|
* @bytelyst/billing-client — Browser/React Native-safe billing client
|
||||||
|
*
|
||||||
|
* Wraps platform-service /plans, /subscriptions, /payments, /usage
|
||||||
|
* endpoints with typed methods. Any ByteLyst product can add billing
|
||||||
|
* with minimal wiring:
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* import { createBillingClient } from '@bytelyst/billing-client';
|
||||||
|
*
|
||||||
|
* const billing = createBillingClient({
|
||||||
|
* baseUrl: 'http://localhost:4003/api',
|
||||||
|
* productId: 'notelett',
|
||||||
|
* getAccessToken: () => localStorage.getItem('notelett_access_token'),
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* const plans = await billing.listPlans();
|
||||||
|
* const sub = await billing.getSubscription();
|
||||||
|
* await billing.changePlan('pro');
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Types ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type PlanTier = 'free' | 'pro' | 'enterprise';
|
||||||
|
export type SubscriptionStatus = 'active' | 'cancelled' | 'past_due' | 'trialing';
|
||||||
|
export type PaymentStatus = 'succeeded' | 'pending' | 'failed' | 'refunded';
|
||||||
|
|
||||||
|
export interface PlanConfig {
|
||||||
|
id: string;
|
||||||
|
productId: string;
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
price: number;
|
||||||
|
tokens: number;
|
||||||
|
words: number;
|
||||||
|
dictations: number;
|
||||||
|
features: string[];
|
||||||
|
stripePriceId?: string;
|
||||||
|
active: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Subscription {
|
||||||
|
id: string;
|
||||||
|
productId: string;
|
||||||
|
userId: string;
|
||||||
|
plan: PlanTier;
|
||||||
|
status: SubscriptionStatus;
|
||||||
|
currentPeriodStart: string;
|
||||||
|
currentPeriodEnd: string;
|
||||||
|
cancelAtPeriodEnd: boolean;
|
||||||
|
monthlyPrice: number;
|
||||||
|
tokensIncluded: number;
|
||||||
|
tokensUsed: number;
|
||||||
|
stripeCustomerId?: string;
|
||||||
|
stripeSubscriptionId?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Payment {
|
||||||
|
id: string;
|
||||||
|
productId: string;
|
||||||
|
userId: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
status: PaymentStatus;
|
||||||
|
description: string;
|
||||||
|
method: string;
|
||||||
|
invoiceUrl?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageSummary {
|
||||||
|
tokensUsed: number;
|
||||||
|
tokensIncluded: number;
|
||||||
|
tokensRemaining: number;
|
||||||
|
percentUsed: number;
|
||||||
|
currentPeriodStart: string;
|
||||||
|
currentPeriodEnd: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Config ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface BillingClientConfig {
|
||||||
|
/** Platform-service base URL (e.g. "http://localhost:4003/api"). */
|
||||||
|
baseUrl: string;
|
||||||
|
/** Product identifier. */
|
||||||
|
productId: string;
|
||||||
|
/** Returns current access token, or null if not authenticated. */
|
||||||
|
getAccessToken: () => string | null;
|
||||||
|
/** Request timeout in ms (default: 15000). */
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Client Interface ─────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface BillingClient {
|
||||||
|
/** List available plans for the product. */
|
||||||
|
listPlans(): Promise<PlanConfig[]>;
|
||||||
|
/** Get a specific plan by name. */
|
||||||
|
getPlan(planName: string): Promise<PlanConfig>;
|
||||||
|
/** Get current user's subscription. Returns null if no subscription. */
|
||||||
|
getSubscription(): Promise<Subscription | null>;
|
||||||
|
/** Change to a different plan (creates or updates subscription). */
|
||||||
|
changePlan(plan: PlanTier): Promise<Subscription>;
|
||||||
|
/** Cancel subscription at period end. */
|
||||||
|
cancelSubscription(): Promise<Subscription>;
|
||||||
|
/** Resume a cancelled subscription (undo cancel-at-period-end). */
|
||||||
|
resumeSubscription(): Promise<Subscription>;
|
||||||
|
/** List payment history. */
|
||||||
|
listPayments(): Promise<Payment[]>;
|
||||||
|
/** Get usage summary for current billing period. */
|
||||||
|
getUsage(): Promise<UsageSummary>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Errors ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class BillingApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly status: number,
|
||||||
|
public readonly body: unknown,
|
||||||
|
message?: string
|
||||||
|
) {
|
||||||
|
super(message ?? `Billing API error ${status}`);
|
||||||
|
this.name = 'BillingApiError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Factory ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function createBillingClient(config: BillingClientConfig): BillingClient {
|
||||||
|
const { baseUrl, productId, getAccessToken, timeoutMs = 15_000 } = config;
|
||||||
|
|
||||||
|
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-product-id': productId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const token = getAccessToken();
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await globalThis.fetch(`${baseUrl}${path}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body != null ? JSON.stringify(body) : undefined,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
if (res.status === 404) return null as T;
|
||||||
|
|
||||||
|
const json = await res.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new BillingApiError(
|
||||||
|
res.status,
|
||||||
|
json,
|
||||||
|
(json as Record<string, string>).message ?? `HTTP ${res.status}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json as T;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async listPlans() {
|
||||||
|
const result = await request<{ plans: PlanConfig[] }>('GET', '/plans');
|
||||||
|
return result.plans;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPlan(planName: string) {
|
||||||
|
return request<PlanConfig>('GET', `/plans/${encodeURIComponent(planName)}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getSubscription() {
|
||||||
|
// Platform-service expects userId in path; the userId comes from the JWT.
|
||||||
|
// We use a convenience endpoint that reads userId from the token.
|
||||||
|
return request<Subscription | null>('GET', '/subscriptions/me');
|
||||||
|
},
|
||||||
|
|
||||||
|
async changePlan(plan: PlanTier) {
|
||||||
|
return request<Subscription>('POST', '/subscriptions/me/change-plan', { plan });
|
||||||
|
},
|
||||||
|
|
||||||
|
async cancelSubscription() {
|
||||||
|
return request<Subscription>('POST', '/subscriptions/me/cancel');
|
||||||
|
},
|
||||||
|
|
||||||
|
async resumeSubscription() {
|
||||||
|
return request<Subscription>('POST', '/subscriptions/me/resume');
|
||||||
|
},
|
||||||
|
|
||||||
|
async listPayments() {
|
||||||
|
const result = await request<{ payments: Payment[] }>('GET', '/payments/me');
|
||||||
|
return result.payments;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getUsage() {
|
||||||
|
return request<UsageSummary>('GET', '/subscriptions/me/usage');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
8
vendor/bytelyst/billing-client/tsconfig.json
vendored
Normal file
8
vendor/bytelyst/billing-client/tsconfig.json
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
24
vendor/bytelyst/blob-client/package.json
vendored
Normal file
24
vendor/bytelyst/blob-client/package.json
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "@bytelyst/blob-client",
|
||||||
|
"version": "0.1.5",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Browser/React Native-safe blob storage client — SAS URL upload/download via platform-service",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"test": "vitest run --pool forks"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/"
|
||||||
|
}
|
||||||
|
}
|
||||||
276
vendor/bytelyst/blob-client/src/index.test.ts
vendored
Normal file
276
vendor/bytelyst/blob-client/src/index.test.ts
vendored
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { createBlobClient, BlobApiError, BlobUploadError } from './index.js';
|
||||||
|
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
globalThis.fetch = mockFetch;
|
||||||
|
|
||||||
|
function jsonResponse(data: unknown, status = 200): Response {
|
||||||
|
return {
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
json: () => Promise.resolve(data),
|
||||||
|
headers: new Headers(),
|
||||||
|
} as unknown as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
function blobClient(overrides?: Partial<Parameters<typeof createBlobClient>[0]>) {
|
||||||
|
return createBlobClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
getAccessToken: () => 'test-token',
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFetch.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createBlobClient', () => {
|
||||||
|
describe('getSasUrl', () => {
|
||||||
|
it('requests a SAS URL from platform-service', async () => {
|
||||||
|
const sasResponse = {
|
||||||
|
sasUrl: 'https://storage.blob.core.windows.net/attachments/blob?sig=abc',
|
||||||
|
container: 'attachments',
|
||||||
|
blobName: 'testapp/user1/photo.jpg',
|
||||||
|
permissions: 'r',
|
||||||
|
expiresInMinutes: 60,
|
||||||
|
expiresAt: '2026-03-02T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
mockFetch.mockResolvedValueOnce(jsonResponse(sasResponse));
|
||||||
|
|
||||||
|
const client = blobClient();
|
||||||
|
const result = await client.getSasUrl('attachments', 'testapp/user1/photo.jpg');
|
||||||
|
|
||||||
|
expect(result).toEqual(sasResponse);
|
||||||
|
expect(mockFetch).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
const [url, init] = mockFetch.mock.calls[0];
|
||||||
|
expect(url).toBe('http://localhost:4003/api/blob/sas');
|
||||||
|
expect(init.method).toBe('POST');
|
||||||
|
expect(JSON.parse(init.body)).toEqual({
|
||||||
|
container: 'attachments',
|
||||||
|
blobName: 'testapp/user1/photo.jpg',
|
||||||
|
permissions: 'r',
|
||||||
|
expiresInMinutes: 60,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends auth and product headers', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(jsonResponse({ sasUrl: 'https://x' }));
|
||||||
|
|
||||||
|
const client = blobClient();
|
||||||
|
await client.getSasUrl('attachments', 'blob.jpg');
|
||||||
|
|
||||||
|
const headers = mockFetch.mock.calls[0][1].headers;
|
||||||
|
expect(headers['Authorization']).toBe('Bearer test-token');
|
||||||
|
expect(headers['x-product-id']).toBe('testapp');
|
||||||
|
expect(headers['x-request-id']).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits auth header when no token', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(jsonResponse({ sasUrl: 'https://x' }));
|
||||||
|
|
||||||
|
const client = blobClient({ getAccessToken: () => null });
|
||||||
|
await client.getSasUrl('attachments', 'blob.jpg');
|
||||||
|
|
||||||
|
const headers = mockFetch.mock.calls[0][1].headers;
|
||||||
|
expect(headers['Authorization']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws BlobApiError on non-ok response', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(jsonResponse({ message: 'Unauthorized' }, 401));
|
||||||
|
|
||||||
|
const client = blobClient();
|
||||||
|
await expect(client.getSasUrl('releases', 'secret.bin')).rejects.toThrow(BlobApiError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('upload', () => {
|
||||||
|
it('requests SAS then uploads directly to Azure', async () => {
|
||||||
|
const sasResponse = {
|
||||||
|
sasUrl: 'https://storage.blob.core.windows.net/attachments/testapp/user1/photo.jpg?sig=abc',
|
||||||
|
container: 'attachments',
|
||||||
|
blobName: 'testapp/user1/photo.jpg',
|
||||||
|
permissions: 'w',
|
||||||
|
expiresInMinutes: 30,
|
||||||
|
expiresAt: '2026-03-02T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(jsonResponse(sasResponse)) // SAS request
|
||||||
|
.mockResolvedValueOnce({ ok: true, status: 201 } as Response); // Azure upload
|
||||||
|
|
||||||
|
const client = blobClient();
|
||||||
|
const result = await client.upload('attachments', 'file-data', {
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
blobName: 'testapp/user1/photo.jpg',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.container).toBe('attachments');
|
||||||
|
expect(result.blobName).toBe('testapp/user1/photo.jpg');
|
||||||
|
expect(result.sasUrl).toBe(
|
||||||
|
'https://storage.blob.core.windows.net/attachments/testapp/user1/photo.jpg'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify Azure upload call
|
||||||
|
const [uploadUrl, uploadInit] = mockFetch.mock.calls[1];
|
||||||
|
expect(uploadUrl).toBe(sasResponse.sasUrl);
|
||||||
|
expect(uploadInit.method).toBe('PUT');
|
||||||
|
expect(uploadInit.headers['x-ms-blob-type']).toBe('BlockBlob');
|
||||||
|
expect(uploadInit.headers['Content-Type']).toBe('image/jpeg');
|
||||||
|
expect(uploadInit.body).toBe('file-data');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-generates blobName when not provided', async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
jsonResponse({
|
||||||
|
sasUrl: 'https://storage.blob.core.windows.net/attachments/testapp/123-abc?sig=x',
|
||||||
|
container: 'attachments',
|
||||||
|
blobName: 'testapp/123-abc',
|
||||||
|
permissions: 'w',
|
||||||
|
expiresInMinutes: 30,
|
||||||
|
expiresAt: '2026-03-02T00:00:00.000Z',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce({ ok: true, status: 201 } as Response);
|
||||||
|
|
||||||
|
const client = blobClient();
|
||||||
|
const result = await client.upload('attachments', 'data', {
|
||||||
|
contentType: 'text/plain',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify the SAS request included a generated blobName starting with productId
|
||||||
|
const sasBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||||
|
expect(sasBody.blobName).toMatch(/^testapp\//);
|
||||||
|
expect(result.container).toBe('attachments');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws BlobUploadError when Azure returns non-ok', async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
jsonResponse({
|
||||||
|
sasUrl: 'https://storage.blob.core.windows.net/attachments/blob?sig=abc',
|
||||||
|
container: 'attachments',
|
||||||
|
blobName: 'blob',
|
||||||
|
permissions: 'w',
|
||||||
|
expiresInMinutes: 30,
|
||||||
|
expiresAt: '2026-03-02T00:00:00.000Z',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce({ ok: false, status: 403 } as Response);
|
||||||
|
|
||||||
|
const client = blobClient();
|
||||||
|
await expect(
|
||||||
|
client.upload('attachments', 'data', { contentType: 'text/plain', blobName: 'blob' })
|
||||||
|
).rejects.toThrow(BlobUploadError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('download', () => {
|
||||||
|
it('requests read SAS then fetches the blob', async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
jsonResponse({
|
||||||
|
sasUrl: 'https://storage.blob.core.windows.net/attachments/blob?sig=read',
|
||||||
|
container: 'attachments',
|
||||||
|
blobName: 'blob',
|
||||||
|
permissions: 'r',
|
||||||
|
expiresInMinutes: 15,
|
||||||
|
expiresAt: '2026-03-02T00:00:00.000Z',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
blob: () => Promise.resolve(new Blob()),
|
||||||
|
} as unknown as Response);
|
||||||
|
|
||||||
|
const client = blobClient();
|
||||||
|
const res = await client.download('attachments', 'blob');
|
||||||
|
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
// Second call should be to the SAS URL
|
||||||
|
expect(mockFetch.mock.calls[1][0]).toBe(
|
||||||
|
'https://storage.blob.core.windows.net/attachments/blob?sig=read'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws BlobApiError on download failure', async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
jsonResponse({
|
||||||
|
sasUrl: 'https://storage.blob.core.windows.net/attachments/blob?sig=read',
|
||||||
|
container: 'attachments',
|
||||||
|
blobName: 'blob',
|
||||||
|
permissions: 'r',
|
||||||
|
expiresInMinutes: 15,
|
||||||
|
expiresAt: '2026-03-02T00:00:00.000Z',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce({ ok: false, status: 404 } as Response);
|
||||||
|
|
||||||
|
const client = blobClient();
|
||||||
|
await expect(client.download('attachments', 'blob')).rejects.toThrow(BlobApiError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('list', () => {
|
||||||
|
it('lists blobs with prefix and limit', async () => {
|
||||||
|
const listResponse = {
|
||||||
|
blobs: [{ name: 'testapp/user1/photo.jpg', container: 'attachments', size: 1024 }],
|
||||||
|
count: 1,
|
||||||
|
container: 'attachments',
|
||||||
|
prefix: 'testapp/user1/',
|
||||||
|
};
|
||||||
|
mockFetch.mockResolvedValueOnce(jsonResponse(listResponse));
|
||||||
|
|
||||||
|
const client = blobClient();
|
||||||
|
const result = await client.list('attachments', { prefix: 'testapp/user1/', limit: 10 });
|
||||||
|
|
||||||
|
expect(result).toEqual(listResponse);
|
||||||
|
const url = mockFetch.mock.calls[0][0] as string;
|
||||||
|
expect(url).toContain('container=attachments');
|
||||||
|
expect(url).toContain('prefix=testapp%2Fuser1%2F');
|
||||||
|
expect(url).toContain('limit=10');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works without options', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(
|
||||||
|
jsonResponse({ blobs: [], count: 0, container: 'audio', prefix: null })
|
||||||
|
);
|
||||||
|
|
||||||
|
const client = blobClient();
|
||||||
|
await client.list('audio');
|
||||||
|
|
||||||
|
const url = mockFetch.mock.calls[0][0] as string;
|
||||||
|
expect(url).toContain('container=audio');
|
||||||
|
expect(url).not.toContain('prefix=');
|
||||||
|
expect(url).not.toContain('limit=');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('info', () => {
|
||||||
|
it('fetches blob metadata', async () => {
|
||||||
|
const infoResponse = {
|
||||||
|
name: 'testapp/user1/photo.jpg',
|
||||||
|
container: 'attachments',
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
size: 2048,
|
||||||
|
lastModified: '2026-03-01T00:00:00.000Z',
|
||||||
|
url: 'https://storage.blob.core.windows.net/attachments/testapp/user1/photo.jpg',
|
||||||
|
metadata: {},
|
||||||
|
};
|
||||||
|
mockFetch.mockResolvedValueOnce(jsonResponse(infoResponse));
|
||||||
|
|
||||||
|
const client = blobClient();
|
||||||
|
const result = await client.info('attachments', 'testapp/user1/photo.jpg');
|
||||||
|
|
||||||
|
expect(result).toEqual(infoResponse);
|
||||||
|
const url = mockFetch.mock.calls[0][0] as string;
|
||||||
|
expect(url).toContain('/blob/info/attachments/testapp%2Fuser1%2Fphoto.jpg');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
289
vendor/bytelyst/blob-client/src/index.ts
vendored
Normal file
289
vendor/bytelyst/blob-client/src/index.ts
vendored
Normal file
@ -0,0 +1,289 @@
|
|||||||
|
/**
|
||||||
|
* Browser/React Native-safe blob storage client.
|
||||||
|
*
|
||||||
|
* Wraps the platform-service blob endpoints to provide:
|
||||||
|
* - SAS URL generation for direct upload/download
|
||||||
|
* - Direct blob upload via SAS URL (PUT with raw body)
|
||||||
|
* - Direct blob download via SAS URL
|
||||||
|
* - Blob listing and metadata
|
||||||
|
*
|
||||||
|
* Requires a fetch-compatible environment (browser, React Native, Node 18+).
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* import { createBlobClient } from '@bytelyst/blob-client';
|
||||||
|
*
|
||||||
|
* const blob = createBlobClient({
|
||||||
|
* baseUrl: 'http://localhost:4003/api',
|
||||||
|
* productId: 'nomgap',
|
||||||
|
* getAccessToken: () => authClient.getAccessToken(),
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Upload a file
|
||||||
|
* const { url } = await blob.upload('attachments', file, {
|
||||||
|
* contentType: 'image/jpeg',
|
||||||
|
* blobName: 'nomgap/user123/photos/meal.jpg',
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Download a file
|
||||||
|
* const data = await blob.download('attachments', 'nomgap/user123/photos/meal.jpg');
|
||||||
|
*
|
||||||
|
* // List blobs
|
||||||
|
* const { blobs } = await blob.list('attachments', { prefix: 'nomgap/user123/' });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Types ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface BlobClientConfig {
|
||||||
|
/** Platform-service base URL (e.g. "http://localhost:4003/api"). */
|
||||||
|
baseUrl: string;
|
||||||
|
|
||||||
|
/** Product identifier sent as x-product-id header. */
|
||||||
|
productId: string;
|
||||||
|
|
||||||
|
/** Function that returns the current access token, or null. */
|
||||||
|
getAccessToken: () => string | null;
|
||||||
|
|
||||||
|
/** Request timeout in milliseconds for API calls. Default: 15000. */
|
||||||
|
timeoutMs?: number;
|
||||||
|
|
||||||
|
/** Upload timeout in milliseconds for direct blob uploads. Default: 120000. */
|
||||||
|
uploadTimeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SasUrlResponse {
|
||||||
|
sasUrl: string;
|
||||||
|
container: string;
|
||||||
|
blobName: string;
|
||||||
|
permissions: string;
|
||||||
|
expiresInMinutes: number;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlobInfo {
|
||||||
|
name: string;
|
||||||
|
container: string;
|
||||||
|
contentType?: string;
|
||||||
|
size: number;
|
||||||
|
lastModified?: string;
|
||||||
|
url: string;
|
||||||
|
metadata: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListBlobsResponse {
|
||||||
|
blobs: BlobInfo[];
|
||||||
|
count: number;
|
||||||
|
container: string;
|
||||||
|
prefix: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadOptions {
|
||||||
|
/** Content-Type of the blob (e.g. "image/jpeg", "application/pdf"). */
|
||||||
|
contentType: string;
|
||||||
|
|
||||||
|
/** Full blob path. If omitted, auto-generated as `<productId>/<userId>/<timestamp>-<random>`. */
|
||||||
|
blobName?: string;
|
||||||
|
|
||||||
|
/** SAS token expiry in minutes. Default: 30. */
|
||||||
|
expiresInMinutes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadResult {
|
||||||
|
sasUrl: string;
|
||||||
|
container: string;
|
||||||
|
blobName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlobClient {
|
||||||
|
/** Get a SAS URL for direct upload or download. */
|
||||||
|
getSasUrl(
|
||||||
|
container: string,
|
||||||
|
blobName: string,
|
||||||
|
permissions?: 'r' | 'w' | 'rw' | 'rwc',
|
||||||
|
expiresInMinutes?: number
|
||||||
|
): Promise<SasUrlResponse>;
|
||||||
|
|
||||||
|
/** Upload a blob via SAS URL (requests SAS, then PUTs directly to Azure). */
|
||||||
|
upload(
|
||||||
|
container: string,
|
||||||
|
data: Blob | ArrayBuffer | Uint8Array | string,
|
||||||
|
options: UploadOptions
|
||||||
|
): Promise<UploadResult>;
|
||||||
|
|
||||||
|
/** Download a blob via SAS URL. Returns the Response for streaming. */
|
||||||
|
download(container: string, blobName: string): Promise<Response>;
|
||||||
|
|
||||||
|
/** List blobs in a container. */
|
||||||
|
list(
|
||||||
|
container: string,
|
||||||
|
options?: { prefix?: string; limit?: number }
|
||||||
|
): Promise<ListBlobsResponse>;
|
||||||
|
|
||||||
|
/** Get blob metadata/info. */
|
||||||
|
info(container: string, blobName: string): Promise<BlobInfo>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Errors ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class BlobApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly status: number,
|
||||||
|
public readonly body: unknown,
|
||||||
|
message?: string
|
||||||
|
) {
|
||||||
|
super(message ?? `Blob API error ${status}`);
|
||||||
|
this.name = 'BlobApiError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BlobUploadError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly status: number,
|
||||||
|
message?: string
|
||||||
|
) {
|
||||||
|
super(message ?? `Blob upload failed with status ${status}`);
|
||||||
|
this.name = 'BlobUploadError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UUID helper ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function uuid(): string {
|
||||||
|
if (typeof globalThis.crypto?.randomUUID === 'function') {
|
||||||
|
return globalThis.crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
||||||
|
const r = (Math.random() * 16) | 0;
|
||||||
|
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Factory ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function createBlobClient(config: BlobClientConfig): BlobClient {
|
||||||
|
const {
|
||||||
|
baseUrl,
|
||||||
|
productId,
|
||||||
|
getAccessToken,
|
||||||
|
timeoutMs = 15_000,
|
||||||
|
uploadTimeoutMs = 120_000,
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
function authHeaders(): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-product-id': productId,
|
||||||
|
'x-request-id': uuid(),
|
||||||
|
};
|
||||||
|
const token = getAccessToken();
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiRequest<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
const url = `${baseUrl}${path}`;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await globalThis.fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: authHeaders(),
|
||||||
|
body: body != null ? JSON.stringify(body) : undefined,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = await res.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new BlobApiError(
|
||||||
|
res.status,
|
||||||
|
json,
|
||||||
|
(json as Record<string, string>).message ?? `HTTP ${res.status}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json as T;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSasUrl(
|
||||||
|
container: string,
|
||||||
|
blobName: string,
|
||||||
|
permissions: 'r' | 'w' | 'rw' | 'rwc' = 'r',
|
||||||
|
expiresInMinutes = 60
|
||||||
|
): Promise<SasUrlResponse> {
|
||||||
|
return apiRequest<SasUrlResponse>('POST', '/blob/sas', {
|
||||||
|
container,
|
||||||
|
blobName,
|
||||||
|
permissions,
|
||||||
|
expiresInMinutes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upload(
|
||||||
|
container: string,
|
||||||
|
data: Blob | ArrayBuffer | Uint8Array | string,
|
||||||
|
options: UploadOptions
|
||||||
|
): Promise<UploadResult> {
|
||||||
|
const blobName = options.blobName ?? `${productId}/${Date.now()}-${uuid().slice(0, 8)}`;
|
||||||
|
|
||||||
|
const sas = await getSasUrl(container, blobName, 'w', options.expiresInMinutes ?? 30);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), uploadTimeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await globalThis.fetch(sas.sasUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'x-ms-blob-type': 'BlockBlob',
|
||||||
|
'Content-Type': options.contentType,
|
||||||
|
},
|
||||||
|
body: data as BodyInit,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new BlobUploadError(res.status, `Upload to Azure failed: HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sasUrl: sas.sasUrl.split('?')[0], container, blobName };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function download(container: string, blobName: string): Promise<Response> {
|
||||||
|
const sas = await getSasUrl(container, blobName, 'r', 15);
|
||||||
|
|
||||||
|
const res = await globalThis.fetch(sas.sasUrl);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new BlobApiError(res.status, null, `Download failed: HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function list(
|
||||||
|
container: string,
|
||||||
|
options?: { prefix?: string; limit?: number }
|
||||||
|
): Promise<ListBlobsResponse> {
|
||||||
|
const params = new URLSearchParams({ container });
|
||||||
|
if (options?.prefix) params.set('prefix', options.prefix);
|
||||||
|
if (options?.limit) params.set('limit', String(options.limit));
|
||||||
|
|
||||||
|
return apiRequest<ListBlobsResponse>('GET', `/blob/list?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function info(container: string, blobName: string): Promise<BlobInfo> {
|
||||||
|
return apiRequest<BlobInfo>(
|
||||||
|
'GET',
|
||||||
|
`/blob/info/${encodeURIComponent(container)}/${encodeURIComponent(blobName)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getSasUrl, upload, download, list, info };
|
||||||
|
}
|
||||||
10
vendor/bytelyst/blob-client/tsconfig.json
vendored
Normal file
10
vendor/bytelyst/blob-client/tsconfig.json
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"lib": ["ES2022", "DOM"]
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
|
}
|
||||||
27
vendor/bytelyst/blob/package.json
vendored
Normal file
27
vendor/bytelyst/blob/package.json
vendored
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "@bytelyst/blob",
|
||||||
|
"version": "0.2.5",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"pretest": "pnpm --dir ../.. --filter @bytelyst/storage build",
|
||||||
|
"build": "tsc",
|
||||||
|
"test": "vitest run --pool forks"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@bytelyst/storage": "workspace:*"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/"
|
||||||
|
}
|
||||||
|
}
|
||||||
108
vendor/bytelyst/blob/src/__tests__/blob.test.ts
vendored
Normal file
108
vendor/bytelyst/blob/src/__tests__/blob.test.ts
vendored
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { setStorage, MemoryStorageProvider } from '@bytelyst/storage';
|
||||||
|
|
||||||
|
import {
|
||||||
|
_resetBlobClient,
|
||||||
|
generateSasUrl,
|
||||||
|
getBucket,
|
||||||
|
getStorageProvider,
|
||||||
|
isBlobStorageConfigured,
|
||||||
|
BLOB_CONTAINERS,
|
||||||
|
} from '../index.js';
|
||||||
|
|
||||||
|
describe('blob', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
_resetBlobClient();
|
||||||
|
delete process.env.AZURE_BLOB_CONNECTION_STRING;
|
||||||
|
delete process.env.AZURE_BLOB_ACCOUNT_NAME;
|
||||||
|
delete process.env.AZURE_BLOB_ACCOUNT_KEY;
|
||||||
|
delete process.env.STORAGE_PROVIDER;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
_resetBlobClient();
|
||||||
|
delete process.env.AZURE_BLOB_CONNECTION_STRING;
|
||||||
|
delete process.env.AZURE_BLOB_ACCOUNT_NAME;
|
||||||
|
delete process.env.AZURE_BLOB_ACCOUNT_KEY;
|
||||||
|
delete process.env.STORAGE_PROVIDER;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isBlobStorageConfigured', () => {
|
||||||
|
it('is false when unset', () => {
|
||||||
|
expect(isBlobStorageConfigured()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is true when connection string is set', () => {
|
||||||
|
process.env.AZURE_BLOB_CONNECTION_STRING = 'AccountName=x;AccountKey=y;';
|
||||||
|
expect(isBlobStorageConfigured()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is true when account name + key are set', () => {
|
||||||
|
process.env.AZURE_BLOB_ACCOUNT_NAME = 'acc';
|
||||||
|
process.env.AZURE_BLOB_ACCOUNT_KEY = 'key==';
|
||||||
|
expect(isBlobStorageConfigured()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is true when provider is memory', () => {
|
||||||
|
process.env.STORAGE_PROVIDER = 'memory';
|
||||||
|
expect(isBlobStorageConfigured()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with memory provider', () => {
|
||||||
|
let memoryProvider: MemoryStorageProvider;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
memoryProvider = new MemoryStorageProvider();
|
||||||
|
setStorage(memoryProvider);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getStorageProvider returns the provider', async () => {
|
||||||
|
const provider = await getStorageProvider();
|
||||||
|
expect(provider).toBe(memoryProvider);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getBucket returns a bucket by name', async () => {
|
||||||
|
const bucket = await getBucket('audio');
|
||||||
|
expect(bucket).toBeDefined();
|
||||||
|
// Upload and download to verify it works
|
||||||
|
await bucket.upload('test.wav', Buffer.from('hello'));
|
||||||
|
const data = await bucket.download('test.wav');
|
||||||
|
expect(data.toString()).toBe('hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generateSasUrl returns a signed URL', async () => {
|
||||||
|
const url = await generateSasUrl('audio', 'path/file.wav', 'r', 10);
|
||||||
|
expect(url).toContain('audio');
|
||||||
|
expect(url).toContain('path/file.wav');
|
||||||
|
expect(url).toContain('signed=true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generateSasUrl defaults to read permissions', async () => {
|
||||||
|
const url = await generateSasUrl('audio', 'file.wav');
|
||||||
|
expect(url).toContain('signed=true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('_resetBlobClient resets the storage singleton', async () => {
|
||||||
|
const p1 = await getStorageProvider();
|
||||||
|
_resetBlobClient();
|
||||||
|
// After reset, inject a new provider
|
||||||
|
const newProvider = new MemoryStorageProvider();
|
||||||
|
setStorage(newProvider);
|
||||||
|
const p2 = await getStorageProvider();
|
||||||
|
expect(p1).not.toBe(p2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BLOB_CONTAINERS', () => {
|
||||||
|
it('has expected container names', () => {
|
||||||
|
expect(BLOB_CONTAINERS.audio).toBe('audio');
|
||||||
|
expect(BLOB_CONTAINERS.transcripts).toBe('transcripts');
|
||||||
|
expect(BLOB_CONTAINERS.attachments).toBe('attachments');
|
||||||
|
expect(BLOB_CONTAINERS.avatars).toBe('avatars');
|
||||||
|
expect(BLOB_CONTAINERS.releases).toBe('releases');
|
||||||
|
expect(BLOB_CONTAINERS.backups).toBe('backups');
|
||||||
|
expect(BLOB_CONTAINERS.feedbackScreenshots).toBe('feedback-screenshots');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
94
vendor/bytelyst/blob/src/blob.ts
vendored
Normal file
94
vendor/bytelyst/blob/src/blob.ts
vendored
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Shared Blob Storage utilities.
|
||||||
|
*
|
||||||
|
* Delegates to @bytelyst/storage for provider-agnostic blob operations.
|
||||||
|
* Keeps the same exported API surface for backward compatibility.
|
||||||
|
*
|
||||||
|
* Expected env vars:
|
||||||
|
* STORAGE_PROVIDER — 'azure' (default) | 'memory'
|
||||||
|
* AZURE_BLOB_CONNECTION_STRING — full connection string (preferred, when provider=azure)
|
||||||
|
* — OR —
|
||||||
|
* AZURE_BLOB_ACCOUNT_NAME + AZURE_BLOB_ACCOUNT_KEY
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
getStorage,
|
||||||
|
_resetStorage,
|
||||||
|
type StorageProvider,
|
||||||
|
type StorageBucket,
|
||||||
|
} from '@bytelyst/storage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Known blob containers and their purposes.
|
||||||
|
*
|
||||||
|
* Note: This is a convenience list (not enforced). Products can add their own
|
||||||
|
* containers as needed.
|
||||||
|
*/
|
||||||
|
export const BLOB_CONTAINERS = {
|
||||||
|
audio: 'audio', // Dictation audio recordings
|
||||||
|
transcripts: 'transcripts', // Exported transcript files (PDF, DOCX, TXT)
|
||||||
|
attachments: 'attachments', // Tracker item attachments (screenshots, docs)
|
||||||
|
avatars: 'avatars', // User profile images
|
||||||
|
releases: 'releases', // Desktop app update binaries
|
||||||
|
backups: 'backups', // Cosmos DB JSON backups
|
||||||
|
feedbackScreenshots: 'feedback-screenshots', // User feedback screenshot attachments
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type BlobContainerName = (typeof BLOB_CONTAINERS)[keyof typeof BLOB_CONTAINERS];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the storage provider singleton.
|
||||||
|
*/
|
||||||
|
export async function getStorageProvider(): Promise<StorageProvider> {
|
||||||
|
return getStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a bucket (container) by name.
|
||||||
|
*/
|
||||||
|
export async function getBucket(containerName: string): Promise<StorageBucket> {
|
||||||
|
const storage = await getStorage();
|
||||||
|
return storage.getBucket(containerName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a signed URL for direct browser upload (or download).
|
||||||
|
*
|
||||||
|
* @param containerName - Target container
|
||||||
|
* @param blobName - Full blob path (e.g., "product/user123/audio/recording.wav")
|
||||||
|
* @param permissions - SAS permissions (default: read)
|
||||||
|
* @param expiresInMinutes - Token lifetime (default: 60)
|
||||||
|
* @returns Full signed URL for the blob
|
||||||
|
*/
|
||||||
|
export async function generateSasUrl(
|
||||||
|
containerName: string,
|
||||||
|
blobName: string,
|
||||||
|
permissions: 'r' | 'w' | 'rw' | 'rwc' | 'rwd' = 'r',
|
||||||
|
expiresInMinutes = 60
|
||||||
|
): Promise<string> {
|
||||||
|
const bucket = await getBucket(containerName);
|
||||||
|
const perm = permissions.includes('w') ? ('write' as const) : ('read' as const);
|
||||||
|
return bucket.getSignedUrl(blobName, {
|
||||||
|
permissions: perm,
|
||||||
|
expiresIn: expiresInMinutes * 60,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if blob storage is configured.
|
||||||
|
*/
|
||||||
|
export function isBlobStorageConfigured(): boolean {
|
||||||
|
const provider = process.env.STORAGE_PROVIDER || 'azure';
|
||||||
|
if (provider === 'memory') return true;
|
||||||
|
return !!(
|
||||||
|
process.env.AZURE_BLOB_CONNECTION_STRING ||
|
||||||
|
(process.env.AZURE_BLOB_ACCOUNT_NAME && process.env.AZURE_BLOB_ACCOUNT_KEY)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test helper: reset module singletons/caches.
|
||||||
|
*/
|
||||||
|
export function _resetBlobClient(): void {
|
||||||
|
_resetStorage();
|
||||||
|
}
|
||||||
1
vendor/bytelyst/blob/src/index.ts
vendored
Normal file
1
vendor/bytelyst/blob/src/index.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './blob.js';
|
||||||
9
vendor/bytelyst/blob/tsconfig.json
vendored
Normal file
9
vendor/bytelyst/blob/tsconfig.json
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
|
}
|
||||||
227
vendor/bytelyst/broadcast-client/README.md
vendored
Normal file
227
vendor/bytelyst/broadcast-client/README.md
vendored
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
# @bytelyst/broadcast-client
|
||||||
|
|
||||||
|
TypeScript client for the ByteLyst Broadcast & Messaging platform. Provides in-app message polling, read receipts, and push notification token management.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @bytelyst/broadcast-client
|
||||||
|
# or
|
||||||
|
pnpm add @bytelyst/broadcast-client
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createBroadcastClient } from '@bytelyst/broadcast-client';
|
||||||
|
|
||||||
|
const client = createBroadcastClient({
|
||||||
|
baseURL: 'https://api.bytelyst.io/v1',
|
||||||
|
productId: 'lysnrai',
|
||||||
|
getAuthToken: async () => {
|
||||||
|
// Return your JWT token
|
||||||
|
return localStorage.getItem('token');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start polling for messages (every 60 seconds)
|
||||||
|
client.startPolling(60000, (messages) => {
|
||||||
|
console.log('New messages:', messages);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### `createBroadcastClient(config)`
|
||||||
|
|
||||||
|
Creates a new broadcast client instance.
|
||||||
|
|
||||||
|
**Config:**
|
||||||
|
| Option | Type | Required | Description |
|
||||||
|
|--------|------|----------|-------------|
|
||||||
|
| `baseURL` | string | Yes | API base URL |
|
||||||
|
| `productId` | string | Yes | Product identifier |
|
||||||
|
| `getAuthToken` | () => Promise<string> | Yes | Function to retrieve JWT token |
|
||||||
|
|
||||||
|
### Methods
|
||||||
|
|
||||||
|
#### `getMessages()`
|
||||||
|
Fetch active in-app messages for the current user.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { data, error } = await client.getMessages();
|
||||||
|
// Returns: { messages: InAppMessage[] }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `markRead(messageId: string)`
|
||||||
|
Mark a message as read.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await client.markRead('msg_123');
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `markDismissed(messageId: string)`
|
||||||
|
Dismiss a message.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await client.markDismissed('msg_123');
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `trackClick(messageId: string)`
|
||||||
|
Track when user clicks/taps a message CTA.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await client.trackClick('msg_123');
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `startPolling(intervalMs: number, callback: (messages) => void)`
|
||||||
|
Start polling for new messages.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
client.startPolling(60000, (messages) => {
|
||||||
|
// Called every 60 seconds with current messages
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `stopPolling()`
|
||||||
|
Stop message polling.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
client.stopPolling();
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `registerDeviceToken(token: string, platform: 'ios' | 'android' | 'web')`
|
||||||
|
Register push notification device token.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await client.registerDeviceToken('fcm_token_xyz', 'android');
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `unregisterDeviceToken(token: string)`
|
||||||
|
Unregister device token (e.g., on logout).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await client.unregisterDeviceToken('fcm_token_xyz');
|
||||||
|
```
|
||||||
|
|
||||||
|
## React Integration
|
||||||
|
|
||||||
|
### Hook Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useBroadcastClient } from './hooks/useBroadcastClient';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const { messages, unreadCount, markRead, markDismissed } = useBroadcastClient({
|
||||||
|
pollingInterval: 60000
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{messages.map(msg => (
|
||||||
|
<Banner
|
||||||
|
key={msg.id}
|
||||||
|
title={msg.title}
|
||||||
|
body={msg.body}
|
||||||
|
onDismiss={() => markDismissed(msg.id)}
|
||||||
|
onClick={() => markRead(msg.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Provider Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// BroadcastProvider.tsx
|
||||||
|
import { createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
import { createBroadcastClient, BroadcastClient, InAppMessage } from '@bytelyst/broadcast-client';
|
||||||
|
|
||||||
|
const BroadcastContext = createContext<BroadcastContextType | null>(null);
|
||||||
|
|
||||||
|
export function BroadcastProvider({ children, config }: { children: React.ReactNode; config: BroadcastConfig }) {
|
||||||
|
const [client] = useState(() => createBroadcastClient(config));
|
||||||
|
const [messages, setMessages] = useState<InAppMessage[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
client.startPolling(60000, setMessages);
|
||||||
|
return () => client.stopPolling();
|
||||||
|
}, [client]);
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
messages,
|
||||||
|
unreadCount: messages.filter(m => m.status === 'unread').length,
|
||||||
|
markRead: client.markRead.bind(client),
|
||||||
|
markDismissed: client.markDismissed.bind(client),
|
||||||
|
trackClick: client.trackClick.bind(client),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BroadcastContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</BroadcastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBroadcast = () => {
|
||||||
|
const ctx = useContext(BroadcastContext);
|
||||||
|
if (!ctx) throw new Error('useBroadcast must be used within BroadcastProvider');
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface InAppMessage {
|
||||||
|
id: string;
|
||||||
|
broadcastId: string;
|
||||||
|
title: string;
|
||||||
|
body?: string;
|
||||||
|
style: 'banner' | 'modal' | 'fullscreen' | 'toast';
|
||||||
|
priority: 'low' | 'normal' | 'high' | 'urgent';
|
||||||
|
ctaText?: string;
|
||||||
|
ctaUrl?: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
deepLink?: {
|
||||||
|
screen: string;
|
||||||
|
params: Record<string, string>;
|
||||||
|
};
|
||||||
|
status: 'unread' | 'read' | 'dismissed';
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BroadcastConfig {
|
||||||
|
baseURL: string;
|
||||||
|
productId: string;
|
||||||
|
getAuthToken: () => Promise<string>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
All methods return a result tuple `[data, error]`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [data, error] = await client.getMessages();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Failed to fetch messages:', error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use data.messages
|
||||||
|
```
|
||||||
|
|
||||||
|
## Browser Support
|
||||||
|
|
||||||
|
- Chrome 90+
|
||||||
|
- Firefox 88+
|
||||||
|
- Safari 14+
|
||||||
|
- Edge 90+
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT © ByteLyst
|
||||||
24
vendor/bytelyst/broadcast-client/package.json
vendored
Normal file
24
vendor/bytelyst/broadcast-client/package.json
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "@bytelyst/broadcast-client",
|
||||||
|
"version": "0.1.5",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Browser/React Native-safe broadcast messaging client for platform-service",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"test": "vitest run --pool forks"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/"
|
||||||
|
}
|
||||||
|
}
|
||||||
165
vendor/bytelyst/broadcast-client/src/deep-link.ts
vendored
Normal file
165
vendor/bytelyst/broadcast-client/src/deep-link.ts
vendored
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* Deep Link Router — TypeScript
|
||||||
|
* Handles routing from push notification deep links to app screens
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface DeepLinkRoute {
|
||||||
|
screen: string;
|
||||||
|
params?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeepLinkHandler = (route: DeepLinkRoute) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep Link Router class
|
||||||
|
*/
|
||||||
|
export class DeepLinkRouter {
|
||||||
|
private handlers = new Map<string, DeepLinkHandler>();
|
||||||
|
private fallbackHandler?: DeepLinkHandler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a handler for a specific screen
|
||||||
|
*/
|
||||||
|
register(screen: string, handler: DeepLinkHandler): void {
|
||||||
|
this.handlers.set(screen, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a fallback handler for unregistered screens
|
||||||
|
*/
|
||||||
|
setFallback(handler: DeepLinkHandler): void {
|
||||||
|
this.fallbackHandler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a deep link URL and extract route
|
||||||
|
*/
|
||||||
|
parseDeepLink(url: string): DeepLinkRoute | null {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
|
||||||
|
// Handle app-specific URLs: myapp://screen/params
|
||||||
|
if (urlObj.protocol !== 'http:' && urlObj.protocol !== 'https:') {
|
||||||
|
const routeParts = [urlObj.host, ...urlObj.pathname.split('/').filter(Boolean)].filter(
|
||||||
|
Boolean
|
||||||
|
);
|
||||||
|
const screen = routeParts[0] || 'home';
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
|
||||||
|
// Parse query params
|
||||||
|
urlObj.searchParams.forEach((value, key) => {
|
||||||
|
params[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { screen, params };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle web URLs with deep link params
|
||||||
|
const deepLinkParam = urlObj.searchParams.get('dl');
|
||||||
|
if (deepLinkParam) {
|
||||||
|
return this.parseDeepLink(deepLinkParam);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle path-based routing: /screen/params
|
||||||
|
const pathParts = urlObj.pathname.split('/').filter(Boolean);
|
||||||
|
if (pathParts.length > 0) {
|
||||||
|
const screen = pathParts[0];
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
|
||||||
|
urlObj.searchParams.forEach((value, key) => {
|
||||||
|
params[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { screen, params };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a deep link route
|
||||||
|
*/
|
||||||
|
handle(route: DeepLinkRoute): boolean {
|
||||||
|
const handler = this.handlers.get(route.screen);
|
||||||
|
|
||||||
|
if (handler) {
|
||||||
|
handler(route);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.fallbackHandler) {
|
||||||
|
this.fallbackHandler(route);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-console -- Deep-link consumers need an opt-in diagnostic when a route is dropped.
|
||||||
|
console.warn(`[DeepLink] No handler for screen: ${route.screen}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a deep link URL end-to-end
|
||||||
|
*/
|
||||||
|
process(url: string): boolean {
|
||||||
|
const route = this.parseDeepLink(url);
|
||||||
|
if (!route) {
|
||||||
|
// eslint-disable-next-line no-console -- Parse failures are intentionally surfaced to host apps during integration.
|
||||||
|
console.warn(`[DeepLink] Failed to parse: ${url}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return this.handle(route);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a broadcast deep link URL
|
||||||
|
*/
|
||||||
|
export function createBroadcastDeepLink(
|
||||||
|
baseUrl: string,
|
||||||
|
screen: string,
|
||||||
|
params?: Record<string, string>,
|
||||||
|
broadcastId?: string
|
||||||
|
): string {
|
||||||
|
const url = new URL(baseUrl);
|
||||||
|
url.pathname = `/${screen}`;
|
||||||
|
|
||||||
|
if (params) {
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
url.searchParams.set(key, value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (broadcastId) {
|
||||||
|
url.searchParams.set('broadcastId', broadcastId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Common deep link screens for broadcast/survey flows
|
||||||
|
*/
|
||||||
|
export const DeepLinkScreens = {
|
||||||
|
// Broadcasts
|
||||||
|
BROADCAST_DETAIL: 'broadcast',
|
||||||
|
ANNOUNCEMENTS: 'announcements',
|
||||||
|
|
||||||
|
// Surveys
|
||||||
|
SURVEY: 'survey',
|
||||||
|
SURVEY_LIST: 'surveys',
|
||||||
|
|
||||||
|
// Product-specific (examples)
|
||||||
|
SETTINGS: 'settings',
|
||||||
|
PROFILE: 'profile',
|
||||||
|
UPGRADE: 'upgrade',
|
||||||
|
SUPPORT: 'support',
|
||||||
|
|
||||||
|
// Fallback
|
||||||
|
HOME: 'home',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Singleton instance for app-wide use
|
||||||
|
export const deepLinkRouter = new DeepLinkRouter();
|
||||||
219
vendor/bytelyst/broadcast-client/src/index.test.ts
vendored
Normal file
219
vendor/bytelyst/broadcast-client/src/index.test.ts
vendored
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
DeepLinkRouter,
|
||||||
|
DeepLinkScreens,
|
||||||
|
createBroadcastClient,
|
||||||
|
createBroadcastDeepLink,
|
||||||
|
createUseBroadcast,
|
||||||
|
} from './index.js';
|
||||||
|
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, status = 200) {
|
||||||
|
return {
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
json: () => Promise.resolve(body),
|
||||||
|
text: () => Promise.resolve(JSON.stringify(body)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createBroadcastClient', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock.mockReset();
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists messages with default segment and auth headers', async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce(jsonResponse({ messages: [{ id: 'm1' }] }));
|
||||||
|
|
||||||
|
const client = createBroadcastClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
getAuthToken: () => 'token-123',
|
||||||
|
platform: 'web',
|
||||||
|
appVersion: '1.2.3',
|
||||||
|
osVersion: 'macOS 15',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await client.listMessages();
|
||||||
|
|
||||||
|
expect(result).toEqual({ messages: [{ id: 'm1' }] });
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
'http://localhost:4003/api/broadcasts',
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
Authorization: 'Bearer token-123',
|
||||||
|
'x-product-id': 'testapp',
|
||||||
|
'x-platform': 'web',
|
||||||
|
'x-app-version': '1.2.3',
|
||||||
|
'x-os-version': 'macOS 15',
|
||||||
|
'x-user-segments': 'free',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports async auth token resolution and optional headers', async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce(jsonResponse({ messages: [] }));
|
||||||
|
|
||||||
|
const client = createBroadcastClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
getAuthToken: async () => 'async-token',
|
||||||
|
platform: 'ios',
|
||||||
|
appVersion: '2.0.0',
|
||||||
|
osVersion: '18.0',
|
||||||
|
countryCode: 'US',
|
||||||
|
regionCode: 'CA',
|
||||||
|
userSegments: ['pro', 'beta'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.listMessages();
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
'http://localhost:4003/api/broadcasts',
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
Authorization: 'Bearer async-token',
|
||||||
|
'x-country-code': 'US',
|
||||||
|
'x-region-code': 'CA',
|
||||||
|
'x-user-segments': 'pro,beta',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws a descriptive error when the API fails', async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
text: () => Promise.resolve('boom'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = createBroadcastClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
getAuthToken: () => 'token-123',
|
||||||
|
platform: 'web',
|
||||||
|
appVersion: '1.2.3',
|
||||||
|
osVersion: 'macOS 15',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(client.markRead('message-1')).rejects.toThrow('Broadcast API error: 500 boom');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('polls messages and returns a cleanup function', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
fetchMock.mockResolvedValue(jsonResponse({ messages: [] }));
|
||||||
|
|
||||||
|
const client = createBroadcastClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
getAuthToken: () => 'token-123',
|
||||||
|
platform: 'web',
|
||||||
|
appVersion: '1.2.3',
|
||||||
|
osVersion: 'macOS 15',
|
||||||
|
});
|
||||||
|
|
||||||
|
const stopPolling = client.pollMessages(1000);
|
||||||
|
await vi.advanceTimersByTimeAsync(3000);
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
|
stopPolling();
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the same client from createUseBroadcast', () => {
|
||||||
|
const client = createBroadcastClient({
|
||||||
|
baseUrl: 'http://localhost:4003/api',
|
||||||
|
productId: 'testapp',
|
||||||
|
getAuthToken: () => 'token-123',
|
||||||
|
platform: 'web',
|
||||||
|
appVersion: '1.2.3',
|
||||||
|
osVersion: 'macOS 15',
|
||||||
|
});
|
||||||
|
|
||||||
|
const useBroadcast = createUseBroadcast(client);
|
||||||
|
|
||||||
|
expect(useBroadcast()).toEqual({ client });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DeepLinkRouter', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses custom app deep links using the host as the screen', () => {
|
||||||
|
const router = new DeepLinkRouter();
|
||||||
|
|
||||||
|
expect(router.parseDeepLink('myapp://broadcast?broadcastId=b1&variant=test')).toEqual({
|
||||||
|
screen: 'broadcast',
|
||||||
|
params: {
|
||||||
|
broadcastId: 'b1',
|
||||||
|
variant: 'test',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses nested deep links from web URLs', () => {
|
||||||
|
const router = new DeepLinkRouter();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
router.parseDeepLink('https://app.bytelyst.dev/open?dl=myapp%3A%2F%2Fsurvey%3Fid%3Ds1')
|
||||||
|
).toEqual({
|
||||||
|
screen: 'survey',
|
||||||
|
params: { id: 's1' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches to registered handlers and falls back when needed', () => {
|
||||||
|
const router = new DeepLinkRouter();
|
||||||
|
const primaryHandler = vi.fn();
|
||||||
|
const fallbackHandler = vi.fn();
|
||||||
|
|
||||||
|
router.register(DeepLinkScreens.BROADCAST_DETAIL, primaryHandler);
|
||||||
|
router.setFallback(fallbackHandler);
|
||||||
|
|
||||||
|
expect(router.handle({ screen: DeepLinkScreens.BROADCAST_DETAIL, params: { id: 'b1' } })).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
expect(primaryHandler).toHaveBeenCalledWith({
|
||||||
|
screen: DeepLinkScreens.BROADCAST_DETAIL,
|
||||||
|
params: { id: 'b1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(router.handle({ screen: 'unknown' })).toBe(true);
|
||||||
|
expect(fallbackHandler).toHaveBeenCalledWith({ screen: 'unknown' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false and warns when processing an invalid URL without a fallback', () => {
|
||||||
|
const router = new DeepLinkRouter();
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
|
||||||
|
expect(router.process('not-a-url')).toBe(false);
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith('[DeepLink] Failed to parse: not-a-url');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createBroadcastDeepLink', () => {
|
||||||
|
it('builds deep links with params and broadcastId', () => {
|
||||||
|
expect(
|
||||||
|
createBroadcastDeepLink(
|
||||||
|
'https://app.bytelyst.dev',
|
||||||
|
DeepLinkScreens.ANNOUNCEMENTS,
|
||||||
|
{ tab: 'latest' },
|
||||||
|
'b42'
|
||||||
|
)
|
||||||
|
).toBe('https://app.bytelyst.dev/announcements?tab=latest&broadcastId=b42');
|
||||||
|
});
|
||||||
|
});
|
||||||
185
vendor/bytelyst/broadcast-client/src/index.ts
vendored
Normal file
185
vendor/bytelyst/broadcast-client/src/index.ts
vendored
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
/**
|
||||||
|
* Broadcast Client — Browser/React Native-safe broadcast messaging client
|
||||||
|
* @module @bytelyst/broadcast-client
|
||||||
|
*/
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface Broadcast {
|
||||||
|
id: string;
|
||||||
|
productId: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
bodyMarkdown?: string;
|
||||||
|
ctaText?: string;
|
||||||
|
ctaUrl?: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
channels: ('push' | 'in_app' | 'email')[];
|
||||||
|
status: 'draft' | 'scheduled' | 'sending' | 'sent' | 'paused';
|
||||||
|
scheduledAt?: string;
|
||||||
|
sentAt?: string;
|
||||||
|
variant?: 'control' | 'treatment';
|
||||||
|
experimentId?: string;
|
||||||
|
parentBroadcastId?: string;
|
||||||
|
metrics: BroadcastMetrics;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
createdBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BroadcastMetrics {
|
||||||
|
targetedCount: number;
|
||||||
|
sentCount: number;
|
||||||
|
deliveredCount: number;
|
||||||
|
openedCount: number;
|
||||||
|
clickedCount: number;
|
||||||
|
dismissedCount: number;
|
||||||
|
convertedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InAppMessage {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
productId: string;
|
||||||
|
broadcastId: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
bodyMarkdown?: string;
|
||||||
|
ctaText?: string;
|
||||||
|
ctaUrl?: string;
|
||||||
|
priority: 'low' | 'normal' | 'high' | 'urgent';
|
||||||
|
style: 'banner' | 'modal' | 'toast' | 'fullscreen';
|
||||||
|
dismissible: boolean;
|
||||||
|
expiresAt?: string;
|
||||||
|
status: 'unread' | 'read' | 'dismissed';
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BroadcastClientConfig {
|
||||||
|
/** Platform service base URL */
|
||||||
|
baseUrl: string;
|
||||||
|
/** Product ID */
|
||||||
|
productId: string;
|
||||||
|
/** Auth token provider (async or sync) */
|
||||||
|
getAuthToken: (() => string) | (() => Promise<string>);
|
||||||
|
/** Platform identifier */
|
||||||
|
platform: 'web' | 'ios' | 'android' | 'macos' | 'windows';
|
||||||
|
/** App version */
|
||||||
|
appVersion: string;
|
||||||
|
/** OS version */
|
||||||
|
osVersion: string;
|
||||||
|
/** Optional country code */
|
||||||
|
countryCode?: string;
|
||||||
|
/** Optional region code */
|
||||||
|
regionCode?: string;
|
||||||
|
/** User segments (default: ['free']) */
|
||||||
|
userSegments?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Client Factory
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface BroadcastClient {
|
||||||
|
/** List active in-app messages for current user */
|
||||||
|
listMessages(): Promise<{ messages: InAppMessage[] }>;
|
||||||
|
/** Mark message as read */
|
||||||
|
markRead(messageId: string): Promise<void>;
|
||||||
|
/** Mark message as dismissed */
|
||||||
|
markDismissed(messageId: string): Promise<void>;
|
||||||
|
/** Track CTA click */
|
||||||
|
trackClick(messageId: string): Promise<{ redirectUrl?: string }>;
|
||||||
|
/** Poll for new messages (use with setInterval) */
|
||||||
|
pollMessages(intervalMs?: number): () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBroadcastClient(config: BroadcastClientConfig): BroadcastClient {
|
||||||
|
const headers = async () => ({
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${await Promise.resolve(config.getAuthToken())}`,
|
||||||
|
'x-product-id': config.productId,
|
||||||
|
'x-platform': config.platform,
|
||||||
|
'x-app-version': config.appVersion,
|
||||||
|
'x-os-version': config.osVersion,
|
||||||
|
...(config.countryCode && { 'x-country-code': config.countryCode }),
|
||||||
|
...(config.regionCode && { 'x-region-code': config.regionCode }),
|
||||||
|
'x-user-segments': (config.userSegments ?? ['free']).join(','),
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = async <T>(path: string, options?: RequestInit): Promise<T> => {
|
||||||
|
const res = await fetch(`${config.baseUrl}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...(await headers()),
|
||||||
|
...(options?.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text();
|
||||||
|
throw new Error(`Broadcast API error: ${res.status} ${err}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
async listMessages() {
|
||||||
|
return request<{ messages: InAppMessage[] }>('/broadcasts');
|
||||||
|
},
|
||||||
|
|
||||||
|
async markRead(messageId: string) {
|
||||||
|
await request<void>(`/broadcasts/${messageId}/read`, { method: 'POST' });
|
||||||
|
},
|
||||||
|
|
||||||
|
async markDismissed(messageId: string) {
|
||||||
|
await request<void>(`/broadcasts/${messageId}/dismiss`, { method: 'POST' });
|
||||||
|
},
|
||||||
|
|
||||||
|
async trackClick(messageId: string) {
|
||||||
|
return request<{ redirectUrl?: string }>(`/broadcasts/${messageId}/click`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
pollMessages(intervalMs = 60000) {
|
||||||
|
if (pollInterval) clearInterval(pollInterval);
|
||||||
|
pollInterval = setInterval(() => {
|
||||||
|
this.listMessages().catch(() => {});
|
||||||
|
}, intervalMs);
|
||||||
|
return () => {
|
||||||
|
if (pollInterval) {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
pollInterval = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// React Hook (optional)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function createUseBroadcast(client: BroadcastClient) {
|
||||||
|
return function useBroadcast() {
|
||||||
|
return { client };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Deep Link Router
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export {
|
||||||
|
DeepLinkRouter,
|
||||||
|
deepLinkRouter,
|
||||||
|
DeepLinkScreens,
|
||||||
|
createBroadcastDeepLink,
|
||||||
|
type DeepLinkRoute,
|
||||||
|
type DeepLinkHandler,
|
||||||
|
} from './deep-link.js';
|
||||||
|
|
||||||
10
vendor/bytelyst/broadcast-client/tsconfig.json
vendored
Normal file
10
vendor/bytelyst/broadcast-client/tsconfig.json
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"declaration": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
19
vendor/bytelyst/celebrations/package.json
vendored
Normal file
19
vendor/bytelyst/celebrations/package.json
vendored
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "@bytelyst/celebrations",
|
||||||
|
"version": "0.1.5",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
25
vendor/bytelyst/celebrations/src/index.ts
vendored
Normal file
25
vendor/bytelyst/celebrations/src/index.ts
vendored
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
export interface Celebration {
|
||||||
|
emoji: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CELEBRATION: Celebration = {
|
||||||
|
emoji: '👏',
|
||||||
|
title: 'Great Job!',
|
||||||
|
};
|
||||||
|
|
||||||
|
const BY_TYPE: Record<string, Celebration> = {
|
||||||
|
session_completed: { emoji: '🎉', title: 'Fast Complete!' },
|
||||||
|
task_completed: { emoji: '✅', title: 'Well Done!' },
|
||||||
|
streak_milestone: { emoji: '🔥', title: 'Streak Milestone!' },
|
||||||
|
achievement_unlocked: { emoji: '🏆', title: 'Achievement Unlocked!' },
|
||||||
|
level_up: { emoji: '⬆️', title: 'Level Up!' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createCelebrationEngine() {
|
||||||
|
return {
|
||||||
|
getCelebration(type: string): Celebration {
|
||||||
|
return BY_TYPE[type] ?? DEFAULT_CELEBRATION;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
9
vendor/bytelyst/celebrations/tsconfig.json
vendored
Normal file
9
vendor/bytelyst/celebrations/tsconfig.json
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"declaration": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
26
vendor/bytelyst/client-encrypt/package.json
vendored
Normal file
26
vendor/bytelyst/client-encrypt/package.json
vendored
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "@bytelyst/client-encrypt",
|
||||||
|
"version": "0.1.5",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"test": "vitest run --pool forks"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vitest": "^3.0.0"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/"
|
||||||
|
}
|
||||||
|
}
|
||||||
181
vendor/bytelyst/client-encrypt/src/aes-gcm.test.ts
vendored
Normal file
181
vendor/bytelyst/client-encrypt/src/aes-gcm.test.ts
vendored
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
encryptField,
|
||||||
|
decryptField,
|
||||||
|
generateKey,
|
||||||
|
keyFromHex,
|
||||||
|
keyToHex,
|
||||||
|
deriveKey,
|
||||||
|
} from './aes-gcm.js';
|
||||||
|
import { isEncryptedField } from './guards.js';
|
||||||
|
import { toHex, fromHex } from './hex.js';
|
||||||
|
|
||||||
|
describe('encryptField / decryptField', () => {
|
||||||
|
it('roundtrip', async () => {
|
||||||
|
const key = await generateKey();
|
||||||
|
const encrypted = await encryptField('Hello, World!', key, 'dek_test');
|
||||||
|
const decrypted = await decryptField(encrypted, key);
|
||||||
|
expect(decrypted).toBe('Hello, World!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empty string', async () => {
|
||||||
|
const key = await generateKey();
|
||||||
|
const encrypted = await encryptField('', key, 'dek_test');
|
||||||
|
const decrypted = await decryptField(encrypted, key);
|
||||||
|
expect(decrypted).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unicode', async () => {
|
||||||
|
const key = await generateKey();
|
||||||
|
const text = 'こんにちは世界 🌍 مرحبا Ñoño';
|
||||||
|
const encrypted = await encryptField(text, key, 'dek_test');
|
||||||
|
const decrypted = await decryptField(encrypted, key);
|
||||||
|
expect(decrypted).toBe(text);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('large payload', async () => {
|
||||||
|
const key = await generateKey();
|
||||||
|
const text = 'A'.repeat(100_000);
|
||||||
|
const encrypted = await encryptField(text, key, 'dek_test');
|
||||||
|
const decrypted = await decryptField(encrypted, key);
|
||||||
|
expect(decrypted).toBe(text);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('EncryptedField structure', () => {
|
||||||
|
it('has correct sentinel fields', async () => {
|
||||||
|
const key = await generateKey();
|
||||||
|
const encrypted = await encryptField('test', key, 'dek_test');
|
||||||
|
expect(encrypted.__encrypted).toBe(true);
|
||||||
|
expect(encrypted.v).toBe(1);
|
||||||
|
expect(encrypted.alg).toBe('aes-256-gcm');
|
||||||
|
expect(encrypted.dekId).toBe('dek_test');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has correct hex lengths', async () => {
|
||||||
|
const key = await generateKey();
|
||||||
|
const encrypted = await encryptField('test', key, 'dek_test');
|
||||||
|
expect(encrypted.iv.length).toBe(24); // 12 bytes = 24 hex
|
||||||
|
expect(encrypted.tag.length).toBe(32); // 16 bytes = 32 hex
|
||||||
|
expect(encrypted.ct.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unique IVs per encryption', async () => {
|
||||||
|
const key = await generateKey();
|
||||||
|
const a = await encryptField('same', key, 'dek_test');
|
||||||
|
const b = await encryptField('same', key, 'dek_test');
|
||||||
|
expect(a.iv).not.toBe(b.iv);
|
||||||
|
expect(a.ct).not.toBe(b.ct);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AAD (Additional Authenticated Data)', () => {
|
||||||
|
it('roundtrip with AAD', async () => {
|
||||||
|
const key = await generateKey();
|
||||||
|
const encrypted = await encryptField('secret', key, 'dek_test', 'user:ctx');
|
||||||
|
const decrypted = await decryptField(encrypted, key, 'user:ctx');
|
||||||
|
expect(decrypted).toBe('secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wrong AAD fails', async () => {
|
||||||
|
const key = await generateKey();
|
||||||
|
const encrypted = await encryptField('secret', key, 'dek_test', 'correct');
|
||||||
|
await expect(decryptField(encrypted, key, 'wrong')).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('missing AAD fails', async () => {
|
||||||
|
const key = await generateKey();
|
||||||
|
const encrypted = await encryptField('secret', key, 'dek_test', 'some-aad');
|
||||||
|
await expect(decryptField(encrypted, key)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('wrong key', () => {
|
||||||
|
it('decrypt with wrong key fails', async () => {
|
||||||
|
const key = await generateKey();
|
||||||
|
const wrongKey = await generateKey();
|
||||||
|
const encrypted = await encryptField('secret', key, 'dek_test');
|
||||||
|
await expect(decryptField(encrypted, wrongKey)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('keyFromHex / keyToHex', () => {
|
||||||
|
it('roundtrip', async () => {
|
||||||
|
const key = await generateKey();
|
||||||
|
const hex = await keyToHex(key);
|
||||||
|
expect(hex.length).toBe(64); // 32 bytes = 64 hex chars
|
||||||
|
const restored = await keyFromHex(hex);
|
||||||
|
const encrypted = await encryptField('test', key, 'dek_test');
|
||||||
|
const decrypted = await decryptField(encrypted, restored);
|
||||||
|
expect(decrypted).toBe('test');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid length', async () => {
|
||||||
|
await expect(keyFromHex('aabb')).rejects.toThrow('32-byte key');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deriveKey', () => {
|
||||||
|
it('derives consistent key from passphrase + salt', async () => {
|
||||||
|
const salt = new Uint8Array(16);
|
||||||
|
globalThis.crypto.getRandomValues(salt);
|
||||||
|
const key1 = await deriveKey('my-passphrase', salt, 1000, true);
|
||||||
|
const key2 = await deriveKey('my-passphrase', salt, 1000, true);
|
||||||
|
const hex1 = await keyToHex(key1);
|
||||||
|
const hex2 = await keyToHex(key2);
|
||||||
|
expect(hex1).toBe(hex2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('different passphrases produce different keys', async () => {
|
||||||
|
const salt = new Uint8Array(16);
|
||||||
|
globalThis.crypto.getRandomValues(salt);
|
||||||
|
const key1 = await deriveKey('pass-1', salt, 1000, true);
|
||||||
|
const key2 = await deriveKey('pass-2', salt, 1000, true);
|
||||||
|
const hex1 = await keyToHex(key1);
|
||||||
|
const hex2 = await keyToHex(key2);
|
||||||
|
expect(hex1).not.toBe(hex2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derived key can encrypt/decrypt', async () => {
|
||||||
|
const salt = new Uint8Array(16);
|
||||||
|
globalThis.crypto.getRandomValues(salt);
|
||||||
|
const key = await deriveKey('test', salt, 1000, true);
|
||||||
|
const encrypted = await encryptField('hello', key, 'dek_test');
|
||||||
|
const decrypted = await decryptField(encrypted, key);
|
||||||
|
expect(decrypted).toBe('hello');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isEncryptedField', () => {
|
||||||
|
it('true for valid EncryptedField', async () => {
|
||||||
|
const key = await generateKey();
|
||||||
|
const encrypted = await encryptField('test', key, 'dek_test');
|
||||||
|
expect(isEncryptedField(encrypted)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('false for plain string', () => {
|
||||||
|
expect(isEncryptedField('just a string')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('false for null', () => {
|
||||||
|
expect(isEncryptedField(null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('false for incomplete object', () => {
|
||||||
|
expect(isEncryptedField({ __encrypted: true, v: 1 })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hex utilities', () => {
|
||||||
|
it('toHex / fromHex roundtrip', () => {
|
||||||
|
const bytes = new Uint8Array([0x00, 0x0f, 0xff, 0xab, 0xcd]);
|
||||||
|
const hex = toHex(bytes);
|
||||||
|
expect(hex).toBe('000fffabcd');
|
||||||
|
const restored = fromHex(hex);
|
||||||
|
expect(restored).toEqual(bytes);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fromHex rejects odd length', () => {
|
||||||
|
expect(() => fromHex('a')).toThrow('even length');
|
||||||
|
});
|
||||||
|
});
|
||||||
215
vendor/bytelyst/client-encrypt/src/aes-gcm.ts
vendored
Normal file
215
vendor/bytelyst/client-encrypt/src/aes-gcm.ts
vendored
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* @bytelyst/client-encrypt — AES-256-GCM via Web Crypto API
|
||||||
|
*
|
||||||
|
* Works in browsers (window.crypto.subtle) and React Native (expo-crypto polyfill).
|
||||||
|
* Produces EncryptedField objects wire-compatible with:
|
||||||
|
* - @bytelyst/field-encrypt (Node.js server)
|
||||||
|
* - BLFieldEncrypt (Swift CryptoKit / Kotlin javax.crypto)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { EncryptedField } from './types.js';
|
||||||
|
import { toHex, fromHex } from './hex.js';
|
||||||
|
|
||||||
|
const ALGORITHM = 'AES-GCM';
|
||||||
|
const KEY_SIZE_BITS = 256;
|
||||||
|
const IV_BYTES = 12;
|
||||||
|
const TAG_BITS = 128;
|
||||||
|
|
||||||
|
/** Get the SubtleCrypto instance (browser or globalThis). */
|
||||||
|
function getSubtle(): SubtleCrypto {
|
||||||
|
if (typeof globalThis !== 'undefined' && globalThis.crypto?.subtle) {
|
||||||
|
return globalThis.crypto.subtle;
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
'@bytelyst/client-encrypt requires Web Crypto API (SubtleCrypto). ' +
|
||||||
|
'Use a polyfill in React Native (e.g., expo-crypto).'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the crypto object for random bytes. */
|
||||||
|
function getCrypto(): Crypto {
|
||||||
|
if (typeof globalThis !== 'undefined' && globalThis.crypto) {
|
||||||
|
return globalThis.crypto;
|
||||||
|
}
|
||||||
|
throw new Error('@bytelyst/client-encrypt requires globalThis.crypto for random bytes.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt a plaintext string with AES-256-GCM using Web Crypto API.
|
||||||
|
*
|
||||||
|
* @param plaintext - UTF-8 string to encrypt
|
||||||
|
* @param key - CryptoKey (AES-GCM, 256-bit)
|
||||||
|
* @param dekId - DEK identifier stored in the output
|
||||||
|
* @param aad - Optional additional authenticated data
|
||||||
|
* @returns EncryptedField with hex-encoded ciphertext, IV, and tag
|
||||||
|
*/
|
||||||
|
export async function encryptField(
|
||||||
|
plaintext: string,
|
||||||
|
key: CryptoKey,
|
||||||
|
dekId: string,
|
||||||
|
aad?: string
|
||||||
|
): Promise<EncryptedField> {
|
||||||
|
const subtle = getSubtle();
|
||||||
|
const crypto = getCrypto();
|
||||||
|
|
||||||
|
const iv = new Uint8Array(IV_BYTES);
|
||||||
|
crypto.getRandomValues(iv);
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const plaintextBytes = encoder.encode(plaintext);
|
||||||
|
|
||||||
|
const params: AesGcmParams = {
|
||||||
|
name: ALGORITHM,
|
||||||
|
iv: iv.buffer as ArrayBuffer,
|
||||||
|
tagLength: TAG_BITS,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (aad) {
|
||||||
|
params.additionalData = encoder.encode(aad).buffer as ArrayBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Web Crypto returns ciphertext || tag concatenated
|
||||||
|
const ciphertextWithTag = new Uint8Array(
|
||||||
|
await subtle.encrypt(params, key, plaintextBytes.buffer as ArrayBuffer)
|
||||||
|
);
|
||||||
|
|
||||||
|
const tagOffset = ciphertextWithTag.length - TAG_BITS / 8;
|
||||||
|
const ct = ciphertextWithTag.slice(0, tagOffset);
|
||||||
|
const tag = ciphertextWithTag.slice(tagOffset);
|
||||||
|
|
||||||
|
return {
|
||||||
|
__encrypted: true,
|
||||||
|
v: 1,
|
||||||
|
alg: 'aes-256-gcm',
|
||||||
|
ct: toHex(ct),
|
||||||
|
iv: toHex(iv),
|
||||||
|
tag: toHex(tag),
|
||||||
|
dekId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt an EncryptedField back to plaintext.
|
||||||
|
*
|
||||||
|
* @param field - EncryptedField object
|
||||||
|
* @param key - CryptoKey (must match the key used to encrypt)
|
||||||
|
* @param aad - Optional AAD (must match the AAD used during encryption)
|
||||||
|
* @returns Decrypted UTF-8 string
|
||||||
|
* @throws DOMException if authentication tag verification fails
|
||||||
|
*/
|
||||||
|
export async function decryptField(
|
||||||
|
field: EncryptedField,
|
||||||
|
key: CryptoKey,
|
||||||
|
aad?: string
|
||||||
|
): Promise<string> {
|
||||||
|
const subtle = getSubtle();
|
||||||
|
|
||||||
|
const iv = fromHex(field.iv);
|
||||||
|
const ct = fromHex(field.ct);
|
||||||
|
const tag = fromHex(field.tag);
|
||||||
|
|
||||||
|
// Web Crypto expects ciphertext || tag concatenated
|
||||||
|
const ciphertextWithTag = new Uint8Array(ct.length + tag.length);
|
||||||
|
ciphertextWithTag.set(ct, 0);
|
||||||
|
ciphertextWithTag.set(tag, ct.length);
|
||||||
|
|
||||||
|
const params: AesGcmParams = {
|
||||||
|
name: ALGORITHM,
|
||||||
|
iv: iv.buffer as ArrayBuffer,
|
||||||
|
tagLength: TAG_BITS,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (aad) {
|
||||||
|
params.additionalData = new TextEncoder().encode(aad).buffer as ArrayBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
const plaintextBytes = new Uint8Array(
|
||||||
|
await subtle.decrypt(params, key, ciphertextWithTag.buffer as ArrayBuffer)
|
||||||
|
);
|
||||||
|
|
||||||
|
return new TextDecoder().decode(plaintextBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a random AES-256-GCM CryptoKey.
|
||||||
|
*
|
||||||
|
* @param extractable - Whether the key material can be exported (default: true).
|
||||||
|
* Set to `false` for non-extractable keys stored in IndexedDB.
|
||||||
|
*/
|
||||||
|
export async function generateKey(extractable = true): Promise<CryptoKey> {
|
||||||
|
const subtle = getSubtle();
|
||||||
|
return subtle.generateKey({ name: ALGORITHM, length: KEY_SIZE_BITS }, extractable, [
|
||||||
|
'encrypt',
|
||||||
|
'decrypt',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import a hex-encoded key string as a CryptoKey.
|
||||||
|
*
|
||||||
|
* @param hex - 64 hex chars = 32 bytes
|
||||||
|
* @param extractable - Whether the imported key can be exported (default: true)
|
||||||
|
*/
|
||||||
|
export async function keyFromHex(hex: string, extractable = true): Promise<CryptoKey> {
|
||||||
|
const subtle = getSubtle();
|
||||||
|
const keyBytes = fromHex(hex);
|
||||||
|
if (keyBytes.length !== KEY_SIZE_BITS / 8) {
|
||||||
|
throw new Error(`AES-256-GCM requires a 32-byte key, got ${keyBytes.length}`);
|
||||||
|
}
|
||||||
|
return subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
keyBytes.buffer as ArrayBuffer,
|
||||||
|
{ name: ALGORITHM, length: KEY_SIZE_BITS },
|
||||||
|
extractable,
|
||||||
|
['encrypt', 'decrypt']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export a CryptoKey to a hex-encoded string.
|
||||||
|
* Only works if the key was created with `extractable: true`.
|
||||||
|
*/
|
||||||
|
export async function keyToHex(key: CryptoKey): Promise<string> {
|
||||||
|
const subtle = getSubtle();
|
||||||
|
const raw = new Uint8Array(await subtle.exportKey('raw', key));
|
||||||
|
return toHex(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive an AES-256 key from a passphrase using PBKDF2.
|
||||||
|
*
|
||||||
|
* @param passphrase - User passphrase
|
||||||
|
* @param salt - Random salt (at least 16 bytes recommended)
|
||||||
|
* @param iterations - PBKDF2 iterations (default: 600,000 per OWASP 2023)
|
||||||
|
* @param extractable - Whether derived key can be exported (default: false)
|
||||||
|
*/
|
||||||
|
export async function deriveKey(
|
||||||
|
passphrase: string,
|
||||||
|
salt: Uint8Array,
|
||||||
|
iterations = 600_000,
|
||||||
|
extractable = false
|
||||||
|
): Promise<CryptoKey> {
|
||||||
|
const subtle = getSubtle();
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
const baseKey = await subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
encoder.encode(passphrase).buffer as ArrayBuffer,
|
||||||
|
'PBKDF2',
|
||||||
|
false,
|
||||||
|
['deriveKey']
|
||||||
|
);
|
||||||
|
|
||||||
|
return subtle.deriveKey(
|
||||||
|
{
|
||||||
|
name: 'PBKDF2',
|
||||||
|
salt: salt.buffer as ArrayBuffer,
|
||||||
|
iterations,
|
||||||
|
hash: 'SHA-256',
|
||||||
|
},
|
||||||
|
baseKey,
|
||||||
|
{ name: ALGORITHM, length: KEY_SIZE_BITS },
|
||||||
|
extractable,
|
||||||
|
['encrypt', 'decrypt']
|
||||||
|
);
|
||||||
|
}
|
||||||
22
vendor/bytelyst/client-encrypt/src/guards.ts
vendored
Normal file
22
vendor/bytelyst/client-encrypt/src/guards.ts
vendored
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* @bytelyst/client-encrypt — Type guards
|
||||||
|
*
|
||||||
|
* Compatible with @bytelyst/field-encrypt isEncryptedField() on the server.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { EncryptedField } from './types.js';
|
||||||
|
|
||||||
|
/** Check if a value is an EncryptedField object. */
|
||||||
|
export function isEncryptedField(value: unknown): value is EncryptedField {
|
||||||
|
if (typeof value !== 'object' || value === null) return false;
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
obj.__encrypted === true &&
|
||||||
|
obj.v !== undefined &&
|
||||||
|
obj.alg !== undefined &&
|
||||||
|
typeof obj.ct === 'string' &&
|
||||||
|
typeof obj.iv === 'string' &&
|
||||||
|
typeof obj.tag === 'string' &&
|
||||||
|
typeof obj.dekId === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
28
vendor/bytelyst/client-encrypt/src/hex.ts
vendored
Normal file
28
vendor/bytelyst/client-encrypt/src/hex.ts
vendored
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* @bytelyst/client-encrypt — Hex encoding utilities
|
||||||
|
*
|
||||||
|
* Converts between Uint8Array and hex strings.
|
||||||
|
* Compatible with the hex encoding used by @bytelyst/field-encrypt (Node.js)
|
||||||
|
* and BLFieldEncrypt (Swift/Kotlin).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Encode a Uint8Array to a lowercase hex string. */
|
||||||
|
export function toHex(bytes: Uint8Array): string {
|
||||||
|
const parts: string[] = new Array(bytes.length);
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
parts[i] = bytes[i].toString(16).padStart(2, '0');
|
||||||
|
}
|
||||||
|
return parts.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decode a hex string to a Uint8Array. */
|
||||||
|
export function fromHex(hex: string): Uint8Array {
|
||||||
|
if (hex.length % 2 !== 0) {
|
||||||
|
throw new Error(`Hex string must have even length, got ${hex.length}`);
|
||||||
|
}
|
||||||
|
const bytes = new Uint8Array(hex.length / 2);
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
36
vendor/bytelyst/client-encrypt/src/index.ts
vendored
Normal file
36
vendor/bytelyst/client-encrypt/src/index.ts
vendored
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* @bytelyst/client-encrypt
|
||||||
|
*
|
||||||
|
* Client-side AES-256-GCM field encryption using Web Crypto API.
|
||||||
|
* Works in browsers and React Native (with SubtleCrypto polyfill).
|
||||||
|
* Wire-compatible with @bytelyst/field-encrypt (server) and
|
||||||
|
* BLFieldEncrypt (Swift/Kotlin native SDKs).
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* import { generateKey, encryptField, decryptField } from '@bytelyst/client-encrypt';
|
||||||
|
*
|
||||||
|
* const key = await generateKey();
|
||||||
|
* const encrypted = await encryptField('sensitive data', key, 'dek_user1_notes');
|
||||||
|
* const plaintext = await decryptField(encrypted, key);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Main API ────────────────────────────────────────
|
||||||
|
export {
|
||||||
|
encryptField,
|
||||||
|
decryptField,
|
||||||
|
generateKey,
|
||||||
|
keyFromHex,
|
||||||
|
keyToHex,
|
||||||
|
deriveKey,
|
||||||
|
} from './aes-gcm.js';
|
||||||
|
|
||||||
|
// ── Type guards ─────────────────────────────────────
|
||||||
|
export { isEncryptedField } from './guards.js';
|
||||||
|
|
||||||
|
// ── Hex utilities ───────────────────────────────────
|
||||||
|
export { toHex, fromHex } from './hex.js';
|
||||||
|
|
||||||
|
// ── Types ───────────────────────────────────────────
|
||||||
|
export type { EncryptedField, ClientEncryptContext } from './types.js';
|
||||||
33
vendor/bytelyst/client-encrypt/src/types.ts
vendored
Normal file
33
vendor/bytelyst/client-encrypt/src/types.ts
vendored
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* @bytelyst/client-encrypt — Types
|
||||||
|
*
|
||||||
|
* Shared type definitions for client-side field encryption.
|
||||||
|
* Wire-compatible with @bytelyst/field-encrypt (server) and
|
||||||
|
* BLFieldEncrypt (Swift/Kotlin native SDKs).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Encrypted field stored in Cosmos DB or API responses. */
|
||||||
|
export interface EncryptedField {
|
||||||
|
/** Sentinel — always true for encrypted fields. */
|
||||||
|
readonly __encrypted: true;
|
||||||
|
/** Schema version for future algorithm changes. */
|
||||||
|
readonly v: 1;
|
||||||
|
/** Algorithm identifier. */
|
||||||
|
readonly alg: 'aes-256-gcm';
|
||||||
|
/** Ciphertext (hex-encoded). */
|
||||||
|
readonly ct: string;
|
||||||
|
/** Initialization vector (hex-encoded, 12 bytes / 24 hex chars). */
|
||||||
|
readonly iv: string;
|
||||||
|
/** GCM authentication tag (hex-encoded, 16 bytes / 32 hex chars). */
|
||||||
|
readonly tag: string;
|
||||||
|
/** DEK identifier — identifies which key to use for decryption. */
|
||||||
|
readonly dekId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for encrypt/decrypt operations. */
|
||||||
|
export interface ClientEncryptContext {
|
||||||
|
/** Scope for DEK isolation (typically userId). */
|
||||||
|
readonly userId: string;
|
||||||
|
/** Additional context for DEK naming and AAD (e.g., 'transcripts', 'notes'). */
|
||||||
|
readonly context: string;
|
||||||
|
}
|
||||||
10
vendor/bytelyst/client-encrypt/tsconfig.json
vendored
Normal file
10
vendor/bytelyst/client-encrypt/tsconfig.json
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"lib": ["ES2022", "DOM"]
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
|
}
|
||||||
26
vendor/bytelyst/create-app/package.json
vendored
Normal file
26
vendor/bytelyst/create-app/package.json
vendored
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "@bytelyst/create-app",
|
||||||
|
"version": "0.1.3",
|
||||||
|
"description": "CLI tools for scaffolding ByteLyst product repos and code",
|
||||||
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"create-app": "./dist/scaffolder.js",
|
||||||
|
"gen-api-route": "./dist/generators/api-routes.js",
|
||||||
|
"gen-agents-md": "./dist/generators/agents-md.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"test": "vitest run --pool forks",
|
||||||
|
"create-app": "tsx src/scaffolder.ts",
|
||||||
|
"gen:api-route": "tsx src/generators/api-routes.ts",
|
||||||
|
"gen:agents-md": "tsx src/generators/agents-md.ts"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vitest": "^3.0.5"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/"
|
||||||
|
}
|
||||||
|
}
|
||||||
136
vendor/bytelyst/create-app/src/__tests__/scaffolder.test.ts
vendored
Normal file
136
vendor/bytelyst/create-app/src/__tests__/scaffolder.test.ts
vendored
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { generateFiles, type ProductManifest } from '../scaffolder.js';
|
||||||
|
|
||||||
|
function makeManifest(overrides: Partial<ProductManifest> = {}): ProductManifest {
|
||||||
|
return {
|
||||||
|
productId: 'testapp',
|
||||||
|
displayName: 'TestApp',
|
||||||
|
tagline: 'A test application',
|
||||||
|
domain: 'testapp.dev',
|
||||||
|
backendPort: 4050,
|
||||||
|
primarySurface: 'web',
|
||||||
|
platforms: ['web'],
|
||||||
|
features: ['auth', 'telemetry'],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('generateFiles', () => {
|
||||||
|
it('generates root files', () => {
|
||||||
|
const files = generateFiles(makeManifest());
|
||||||
|
const paths = files.map(f => f.path);
|
||||||
|
|
||||||
|
expect(paths).toContain('shared/product.json');
|
||||||
|
expect(paths).toContain('.gitignore');
|
||||||
|
expect(paths).toContain('.env.example');
|
||||||
|
expect(paths).toContain('README.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('always generates backend files', () => {
|
||||||
|
const files = generateFiles(makeManifest());
|
||||||
|
const paths = files.map(f => f.path);
|
||||||
|
|
||||||
|
expect(paths).toContain('backend/package.json');
|
||||||
|
expect(paths).toContain('backend/src/server.ts');
|
||||||
|
expect(paths).toContain('backend/src/lib/config.ts');
|
||||||
|
expect(paths).toContain('backend/src/lib/auth.ts');
|
||||||
|
expect(paths).toContain('backend/src/lib/datastore.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates web files when platform includes web', () => {
|
||||||
|
const files = generateFiles(makeManifest({ platforms: ['web'] }));
|
||||||
|
const paths = files.map(f => f.path);
|
||||||
|
|
||||||
|
expect(paths).toContain('web/package.json');
|
||||||
|
expect(paths).toContain('web/next.config.ts');
|
||||||
|
expect(paths).toContain('web/src/app/layout.tsx');
|
||||||
|
expect(paths).toContain('web/src/app/page.tsx');
|
||||||
|
expect(paths).toContain('web/src/lib/product-config.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not generate web files when platform excludes web', () => {
|
||||||
|
const files = generateFiles(makeManifest({ platforms: ['mobile'] }));
|
||||||
|
const paths = files.map(f => f.path);
|
||||||
|
|
||||||
|
expect(paths).not.toContain('web/package.json');
|
||||||
|
expect(paths).not.toContain('web/src/app/page.tsx');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates mobile files when platform includes mobile', () => {
|
||||||
|
const files = generateFiles(makeManifest({ platforms: ['mobile'] }));
|
||||||
|
const paths = files.map(f => f.path);
|
||||||
|
|
||||||
|
expect(paths).toContain('mobile/package.json');
|
||||||
|
expect(paths).toContain('mobile/app.json');
|
||||||
|
expect(paths).toContain('mobile/src/app/index.tsx');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not generate mobile files when platform excludes mobile', () => {
|
||||||
|
const files = generateFiles(makeManifest({ platforms: ['web'] }));
|
||||||
|
const paths = files.map(f => f.path);
|
||||||
|
|
||||||
|
expect(paths).not.toContain('mobile/package.json');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces product ID in generated content', () => {
|
||||||
|
const files = generateFiles(makeManifest({ productId: 'myproduct' }));
|
||||||
|
const productJson = files.find(f => f.path === 'shared/product.json')!;
|
||||||
|
expect(productJson.content).toContain('"productId": "myproduct"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces display name in generated content', () => {
|
||||||
|
const files = generateFiles(makeManifest({ displayName: 'AwesomeApp' }));
|
||||||
|
const readme = files.find(f => f.path === 'README.md')!;
|
||||||
|
expect(readme.content).toContain('# AwesomeApp');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces backend port in config', () => {
|
||||||
|
const files = generateFiles(makeManifest({ backendPort: 4099 }));
|
||||||
|
const config = files.find(f => f.path === 'backend/src/lib/config.ts')!;
|
||||||
|
expect(config.content).toContain('4099');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes ios bundle ID when ios platform selected', () => {
|
||||||
|
const files = generateFiles(makeManifest({ platforms: ['web', 'ios'], productId: 'myapp' }));
|
||||||
|
const productJson = files.find(f => f.path === 'shared/product.json')!;
|
||||||
|
expect(productJson.content).toContain('com.bytelyst.myapp');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes android bundle ID when android platform selected', () => {
|
||||||
|
const files = generateFiles(
|
||||||
|
makeManifest({ platforms: ['web', 'android'], productId: 'myapp' })
|
||||||
|
);
|
||||||
|
const productJson = files.find(f => f.path === 'shared/product.json')!;
|
||||||
|
expect(productJson.content).toContain('com.myapp.app');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes backend env vars in .env.example', () => {
|
||||||
|
const files = generateFiles(makeManifest({ backendPort: 4050 }));
|
||||||
|
const env = files.find(f => f.path === '.env.example')!;
|
||||||
|
expect(env.content).toContain('PORT=4050');
|
||||||
|
expect(env.content).toContain('JWT_SECRET');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates correct web product-config', () => {
|
||||||
|
const files = generateFiles(makeManifest({ productId: 'testprod', backendPort: 4077 }));
|
||||||
|
const webConfig = files.find(f => f.path === 'web/src/lib/product-config.ts')!;
|
||||||
|
expect(webConfig.content).toContain("productId: 'testprod'");
|
||||||
|
expect(webConfig.content).toContain('4077');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates all platforms when all selected', () => {
|
||||||
|
const files = generateFiles(makeManifest({ platforms: ['web', 'mobile', 'ios', 'android'] }));
|
||||||
|
const paths = files.map(f => f.path);
|
||||||
|
|
||||||
|
expect(paths).toContain('web/package.json');
|
||||||
|
expect(paths).toContain('mobile/package.json');
|
||||||
|
// Backend is always included
|
||||||
|
expect(paths).toContain('backend/package.json');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('server.ts includes display name in log message', () => {
|
||||||
|
const files = generateFiles(makeManifest({ displayName: 'CoolProduct' }));
|
||||||
|
const server = files.find(f => f.path === 'backend/src/server.ts')!;
|
||||||
|
expect(server.content).toContain('CoolProduct backend listening');
|
||||||
|
});
|
||||||
|
});
|
||||||
77
vendor/bytelyst/create-app/src/__tests__/template-engine.test.ts
vendored
Normal file
77
vendor/bytelyst/create-app/src/__tests__/template-engine.test.ts
vendored
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { renderTemplate } from '../lib/template-engine.js';
|
||||||
|
|
||||||
|
describe('renderTemplate', () => {
|
||||||
|
it('replaces simple variables', () => {
|
||||||
|
const result = renderTemplate('Hello {{NAME}}!', { NAME: 'World' });
|
||||||
|
expect(result).toBe('Hello World!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces multiple variables', () => {
|
||||||
|
const result = renderTemplate('{{A}} and {{B}}', { A: 'foo', B: 'bar' });
|
||||||
|
expect(result).toBe('foo and bar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces numeric variables', () => {
|
||||||
|
const result = renderTemplate('Port: {{PORT}}', { PORT: 4017 });
|
||||||
|
expect(result).toBe('Port: 4017');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves unknown variables intact', () => {
|
||||||
|
const result = renderTemplate('{{KNOWN}} {{UNKNOWN}}', { KNOWN: 'yes' });
|
||||||
|
expect(result).toBe('yes {{UNKNOWN}}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes IF block when truthy', () => {
|
||||||
|
const result = renderTemplate('{{#IF HAS_WEB}}web here{{/IF HAS_WEB}}', { HAS_WEB: true });
|
||||||
|
expect(result).toBe('web here');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes IF block when falsy', () => {
|
||||||
|
const result = renderTemplate('before{{#IF HAS_WEB}}web here{{/IF HAS_WEB}}after', {
|
||||||
|
HAS_WEB: false,
|
||||||
|
});
|
||||||
|
expect(result).toBe('beforeafter');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes UNLESS block when falsy', () => {
|
||||||
|
const result = renderTemplate('{{#UNLESS HAS_WEB}}no web{{/UNLESS HAS_WEB}}', {
|
||||||
|
HAS_WEB: false,
|
||||||
|
});
|
||||||
|
expect(result).toBe('no web');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes UNLESS block when truthy', () => {
|
||||||
|
const result = renderTemplate('{{#UNLESS HAS_WEB}}no web{{/UNLESS HAS_WEB}}', {
|
||||||
|
HAS_WEB: true,
|
||||||
|
});
|
||||||
|
expect(result).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles nested variables inside IF blocks', () => {
|
||||||
|
const result = renderTemplate('{{#IF HAS_WEB}}Port: {{PORT}}{{/IF HAS_WEB}}', {
|
||||||
|
HAS_WEB: true,
|
||||||
|
PORT: 3000,
|
||||||
|
});
|
||||||
|
expect(result).toBe('Port: 3000');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multiline IF blocks', () => {
|
||||||
|
const tmpl = `start
|
||||||
|
{{#IF HAS_BACKEND}}backend line 1
|
||||||
|
backend line 2
|
||||||
|
{{/IF HAS_BACKEND}}end`;
|
||||||
|
const result = renderTemplate(tmpl, { HAS_BACKEND: true });
|
||||||
|
expect(result).toContain('backend line 1');
|
||||||
|
expect(result).toContain('backend line 2');
|
||||||
|
expect(result).toContain('start');
|
||||||
|
expect(result).toContain('end');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multiple IF blocks', () => {
|
||||||
|
const tmpl = '{{#IF A}}aaa{{/IF A}}|{{#IF B}}bbb{{/IF B}}';
|
||||||
|
expect(renderTemplate(tmpl, { A: true, B: false })).toBe('aaa|');
|
||||||
|
expect(renderTemplate(tmpl, { A: false, B: true })).toBe('|bbb');
|
||||||
|
expect(renderTemplate(tmpl, { A: true, B: true })).toBe('aaa|bbb');
|
||||||
|
});
|
||||||
|
});
|
||||||
605
vendor/bytelyst/create-app/src/generators/agents-md.ts
vendored
Normal file
605
vendor/bytelyst/create-app/src/generators/agents-md.ts
vendored
Normal file
@ -0,0 +1,605 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* AGENTS.md Auto-Generator
|
||||||
|
*
|
||||||
|
* Generates AGENTS.md from product.json + repo directory scan.
|
||||||
|
* Also creates/updates symlinks: CLAUDE.md, .cursorrules, .windsurfrules
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npx tsx agents-md.ts --repo /path/to/product-repo
|
||||||
|
* npx tsx agents-md.ts --repo /path/to/product-repo --dry-run
|
||||||
|
* npx tsx agents-md.ts --repo /path/to/product-repo --update # preserves <!-- CUSTOM --> sections
|
||||||
|
*
|
||||||
|
* @module @bytelyst/create-app/generators/agents-md
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* eslint-disable no-console -- This generator is a CLI; console output is its user interface. */
|
||||||
|
|
||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
|
||||||
|
// ── CLI ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
repo: string;
|
||||||
|
dryRun: boolean;
|
||||||
|
update: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(): Options {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const options: Options = { repo: '.', dryRun: false, update: false };
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const arg = args[i];
|
||||||
|
if (arg === '--repo' || arg === '-r') options.repo = args[++i];
|
||||||
|
else if (arg === '--dry-run' || arg === '-d') options.dryRun = true;
|
||||||
|
else if (arg === '--update' || arg === '-u') options.update = true;
|
||||||
|
else if (arg === '--help' || arg === '-h') {
|
||||||
|
showHelp();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showHelp(): void {
|
||||||
|
console.log(`
|
||||||
|
AGENTS.md Auto-Generator
|
||||||
|
|
||||||
|
Generates AGENTS.md from product.json + repo scan, plus symlinks for
|
||||||
|
CLAUDE.md, .cursorrules, and .windsurfrules.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
npx tsx agents-md.ts --repo <path> [--dry-run] [--update]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--repo, -r Path to product repo root (default: ".")
|
||||||
|
--dry-run, -d Preview without writing files
|
||||||
|
--update, -u Preserve <!-- CUSTOM --> sections in existing AGENTS.md
|
||||||
|
--help, -h Show this help
|
||||||
|
|
||||||
|
Custom Sections:
|
||||||
|
Wrap any hand-written content in <!-- CUSTOM:key --> / <!-- /CUSTOM:key -->
|
||||||
|
markers. The --update flag preserves these sections when regenerating.
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Product.json Loader ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ProductManifest {
|
||||||
|
productId: string;
|
||||||
|
displayName: string;
|
||||||
|
tagline?: string;
|
||||||
|
domain?: string;
|
||||||
|
backendPort?: number;
|
||||||
|
primarySurface?: string;
|
||||||
|
mobileCompanion?: boolean;
|
||||||
|
bundleIds?: Record<string, string>;
|
||||||
|
bundleId?: string;
|
||||||
|
version?: string;
|
||||||
|
description?: string;
|
||||||
|
licensePrefix?: string;
|
||||||
|
configDirName?: string;
|
||||||
|
envVarPrefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProductJson(repoPath: string): Promise<ProductManifest> {
|
||||||
|
const candidates = [
|
||||||
|
path.join(repoPath, 'shared', 'product.json'),
|
||||||
|
path.join(repoPath, 'product.json'),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const p of candidates) {
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(p, 'utf-8');
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
// try next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('product.json not found in shared/ or repo root');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Repo Scanner ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface RepoInfo {
|
||||||
|
repoName: string;
|
||||||
|
hasFastifyBackend: boolean;
|
||||||
|
hasNextWeb: boolean;
|
||||||
|
hasExpoMobile: boolean;
|
||||||
|
hasSwiftIos: boolean;
|
||||||
|
hasKotlinAndroid: boolean;
|
||||||
|
hasKmpShared: boolean;
|
||||||
|
backendModules: string[];
|
||||||
|
backendTestCount: number;
|
||||||
|
webTestCount: number;
|
||||||
|
mobileTestCount: number;
|
||||||
|
backendLibFiles: string[];
|
||||||
|
webLibFiles: string[];
|
||||||
|
cosmosContainers: string[];
|
||||||
|
techStack: { layer: string; tech: string }[];
|
||||||
|
buildCommands: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dirExists(p: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const st = await fs.stat(p);
|
||||||
|
return st.isDirectory();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fileExists(p: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await fs.access(p);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function countTestsInFiles(dir: string): number {
|
||||||
|
try {
|
||||||
|
const output = execSync(
|
||||||
|
`grep -r "\\b\\(it\\|test\\)(" "${dir}" --include="*.test.ts" --include="*.test.tsx" --include="*.spec.ts" 2>/dev/null | wc -l`,
|
||||||
|
{ encoding: 'utf-8', timeout: 5000 }
|
||||||
|
);
|
||||||
|
return parseInt(output.trim()) || 0;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listDirs(dir: string): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||||
|
return entries.filter(e => e.isDirectory()).map(e => e.name);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listFiles(dir: string): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||||
|
return entries.filter(e => e.isFile()).map(e => e.name);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanRepo(repoPath: string, manifest: ProductManifest): Promise<RepoInfo> {
|
||||||
|
const repoName = path.basename(repoPath);
|
||||||
|
|
||||||
|
// Detect surfaces
|
||||||
|
const hasFastifyBackend = await dirExists(path.join(repoPath, 'backend', 'src'));
|
||||||
|
const hasNextWeb =
|
||||||
|
(await fileExists(path.join(repoPath, 'web', 'next.config.ts'))) ||
|
||||||
|
(await fileExists(path.join(repoPath, 'web', 'next.config.js'))) ||
|
||||||
|
(await fileExists(path.join(repoPath, 'mindlyst-native', 'web', 'next.config.ts')));
|
||||||
|
const hasExpoMobile =
|
||||||
|
(await fileExists(path.join(repoPath, 'mobile', 'app.json'))) ||
|
||||||
|
(await fileExists(path.join(repoPath, 'app.json')));
|
||||||
|
const hasSwiftIos = await dirExists(path.join(repoPath, 'ios'));
|
||||||
|
const hasKotlinAndroid = await dirExists(path.join(repoPath, 'android'));
|
||||||
|
const hasKmpShared = await dirExists(path.join(repoPath, 'shared', 'src'));
|
||||||
|
|
||||||
|
// Backend modules
|
||||||
|
const modulesDir = path.join(repoPath, 'backend', 'src', 'modules');
|
||||||
|
const backendModules = await listDirs(modulesDir);
|
||||||
|
|
||||||
|
// Backend lib files
|
||||||
|
const libDir = path.join(repoPath, 'backend', 'src', 'lib');
|
||||||
|
const backendLibFiles = (await listFiles(libDir)).filter(
|
||||||
|
f => f.endsWith('.ts') && !f.endsWith('.test.ts')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Web lib files
|
||||||
|
let webLibDir = path.join(repoPath, 'web', 'src', 'lib');
|
||||||
|
if (!(await dirExists(webLibDir))) {
|
||||||
|
webLibDir = path.join(repoPath, 'web', 'src', 'components', 'lib');
|
||||||
|
}
|
||||||
|
const webLibFiles = (await listFiles(webLibDir)).filter(
|
||||||
|
f => f.endsWith('.ts') && !f.endsWith('.test.ts')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test counts
|
||||||
|
const backendTestCount = hasFastifyBackend
|
||||||
|
? countTestsInFiles(path.join(repoPath, 'backend', 'src'))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
let webTestDir = path.join(repoPath, 'web', 'src');
|
||||||
|
if (!(await dirExists(webTestDir))) {
|
||||||
|
webTestDir = path.join(repoPath, 'mindlyst-native', 'web', 'src');
|
||||||
|
}
|
||||||
|
const webTestCount = hasNextWeb ? countTestsInFiles(webTestDir) : 0;
|
||||||
|
|
||||||
|
const mobileTestDir = hasExpoMobile
|
||||||
|
? (await dirExists(path.join(repoPath, 'mobile')))
|
||||||
|
? path.join(repoPath, 'mobile')
|
||||||
|
: repoPath
|
||||||
|
: '';
|
||||||
|
const mobileTestCount = mobileTestDir ? countTestsInFiles(mobileTestDir) : 0;
|
||||||
|
|
||||||
|
// Cosmos containers — scan backend types files
|
||||||
|
const cosmosContainers: string[] = [];
|
||||||
|
for (const mod of backendModules) {
|
||||||
|
const typesFile = path.join(modulesDir, mod, 'types.ts');
|
||||||
|
if (await fileExists(typesFile)) {
|
||||||
|
cosmosContainers.push(mod.replace(/-/g, '_'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tech stack
|
||||||
|
const techStack: { layer: string; tech: string }[] = [];
|
||||||
|
if (hasFastifyBackend)
|
||||||
|
techStack.push({
|
||||||
|
layer: 'Backend',
|
||||||
|
tech: `Fastify 5, TypeScript ESM, Zod, jose (JWT), @bytelyst/datastore`,
|
||||||
|
});
|
||||||
|
if (hasNextWeb)
|
||||||
|
techStack.push({ layer: 'Web', tech: 'Next.js 16 (App Router), React 19, TypeScript' });
|
||||||
|
if (hasExpoMobile)
|
||||||
|
techStack.push({ layer: 'Mobile', tech: 'React Native (Expo), TypeScript, expo-router' });
|
||||||
|
if (hasSwiftIos) techStack.push({ layer: 'iOS', tech: 'SwiftUI (iOS 17+)' });
|
||||||
|
if (hasKotlinAndroid)
|
||||||
|
techStack.push({ layer: 'Android', tech: 'Jetpack Compose, Material 3, Kotlin' });
|
||||||
|
if (hasKmpShared) techStack.push({ layer: 'Shared', tech: 'Kotlin Multiplatform (KMP)' });
|
||||||
|
techStack.push({
|
||||||
|
layer: 'Platform',
|
||||||
|
tech: 'platform-service (port 4003) for auth, flags, telemetry, billing',
|
||||||
|
});
|
||||||
|
techStack.push({
|
||||||
|
layer: 'Database',
|
||||||
|
tech: `Azure Cosmos DB via @bytelyst/datastore — productId: "${manifest.productId}"`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build commands
|
||||||
|
const buildCommands: string[] = [];
|
||||||
|
if (hasFastifyBackend) {
|
||||||
|
const port = manifest.backendPort ?? 4000;
|
||||||
|
buildCommands.push(`cd backend && npm run dev # Dev server (port ${port})`);
|
||||||
|
buildCommands.push(`cd backend && npm run typecheck # tsc --noEmit`);
|
||||||
|
buildCommands.push(`cd backend && npm test # Vitest tests`);
|
||||||
|
}
|
||||||
|
if (hasNextWeb) {
|
||||||
|
buildCommands.push(`cd web && npm run dev # Dev server`);
|
||||||
|
buildCommands.push(`cd web && npm run typecheck # tsc --noEmit`);
|
||||||
|
buildCommands.push(`cd web && npm run build # Production build`);
|
||||||
|
}
|
||||||
|
if (hasExpoMobile) {
|
||||||
|
buildCommands.push(`cd mobile && npm start # Expo dev server`);
|
||||||
|
buildCommands.push(`cd mobile && npm run typecheck # tsc --noEmit`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
repoName,
|
||||||
|
hasFastifyBackend,
|
||||||
|
hasNextWeb,
|
||||||
|
hasExpoMobile,
|
||||||
|
hasSwiftIos,
|
||||||
|
hasKotlinAndroid,
|
||||||
|
hasKmpShared,
|
||||||
|
backendModules,
|
||||||
|
backendTestCount,
|
||||||
|
webTestCount,
|
||||||
|
mobileTestCount,
|
||||||
|
backendLibFiles,
|
||||||
|
webLibFiles,
|
||||||
|
cosmosContainers,
|
||||||
|
techStack,
|
||||||
|
buildCommands,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Markdown Generator ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function generateAgentsMd(manifest: ProductManifest, info: RepoInfo): string {
|
||||||
|
const { productId, displayName, domain, tagline } = manifest;
|
||||||
|
const repoDesc = tagline ?? `${displayName} product`;
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
// ── Header
|
||||||
|
lines.push(`# AGENTS.md — AI Coding Agent Instructions`);
|
||||||
|
lines.push('');
|
||||||
|
lines.push(
|
||||||
|
`> **For:** Claude Code, OpenAI Codex, Cursor, GitHub Copilot, Windsurf Cascade, and any AI coding agent.`
|
||||||
|
);
|
||||||
|
lines.push(`> **Repo:** \`${info.repoName}\` — ${repoDesc}.`);
|
||||||
|
lines.push('');
|
||||||
|
lines.push('---');
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
// ── 1. Project Identity
|
||||||
|
lines.push('## 1. Project Identity');
|
||||||
|
lines.push('');
|
||||||
|
lines.push('| Key | Value |');
|
||||||
|
lines.push('|-----|-------|');
|
||||||
|
lines.push(`| **Product** | ${displayName} |`);
|
||||||
|
lines.push(`| **Product ID** | \`${productId}\` |`);
|
||||||
|
if (domain) lines.push(`| **Domain** | ${domain} |`);
|
||||||
|
lines.push(`| **Repo** | \`${info.repoName}\` |`);
|
||||||
|
lines.push(`| **Ecosystem** | ByteLyst (shares platform-service with other ByteLyst products) |`);
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
// ── 2. Repo Layout (simplified tree)
|
||||||
|
lines.push('## 2. Repo Layout');
|
||||||
|
lines.push('');
|
||||||
|
lines.push('```');
|
||||||
|
lines.push(`${info.repoName}/`);
|
||||||
|
if (info.hasFastifyBackend) {
|
||||||
|
const port = manifest.backendPort ?? 4000;
|
||||||
|
lines.push(
|
||||||
|
`├── backend/ # Fastify 5 + TypeScript ESM backend (port ${port})`
|
||||||
|
);
|
||||||
|
lines.push(`│ ├── src/`);
|
||||||
|
if (info.backendLibFiles.length > 0) {
|
||||||
|
lines.push(`│ │ ├── lib/ # Shared backend wiring`);
|
||||||
|
for (const f of info.backendLibFiles.slice(0, 8)) {
|
||||||
|
lines.push(`│ │ │ ├── ${f}`);
|
||||||
|
}
|
||||||
|
if (info.backendLibFiles.length > 8) {
|
||||||
|
lines.push(`│ │ │ └── ... (${info.backendLibFiles.length - 8} more)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (info.backendModules.length > 0) {
|
||||||
|
lines.push(`│ │ ├── modules/`);
|
||||||
|
for (const m of info.backendModules) {
|
||||||
|
lines.push(`│ │ │ ├── ${m}/`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push(`│ │ └── server.ts`);
|
||||||
|
lines.push(`│ ├── package.json`);
|
||||||
|
lines.push(`│ └── tsconfig.json`);
|
||||||
|
lines.push('│');
|
||||||
|
}
|
||||||
|
if (info.hasNextWeb) {
|
||||||
|
lines.push(`├── web/ # Next.js 16 + React 19 (App Router)`);
|
||||||
|
lines.push(`│ ├── src/`);
|
||||||
|
lines.push(`│ │ ├── app/ # App Router pages`);
|
||||||
|
if (info.webLibFiles.length > 0) {
|
||||||
|
lines.push(`│ │ └── lib/ # Pure TS clients + config`);
|
||||||
|
}
|
||||||
|
lines.push(`│ ├── package.json`);
|
||||||
|
lines.push(`│ └── tsconfig.json`);
|
||||||
|
lines.push('│');
|
||||||
|
}
|
||||||
|
if (info.hasExpoMobile) {
|
||||||
|
lines.push(`├── mobile/ # React Native + Expo`);
|
||||||
|
lines.push('│');
|
||||||
|
}
|
||||||
|
if (info.hasSwiftIos) {
|
||||||
|
lines.push(`├── ios/ # SwiftUI native app`);
|
||||||
|
lines.push('│');
|
||||||
|
}
|
||||||
|
if (info.hasKotlinAndroid) {
|
||||||
|
lines.push(`├── android/ # Jetpack Compose`);
|
||||||
|
lines.push('│');
|
||||||
|
}
|
||||||
|
lines.push(`├── shared/`);
|
||||||
|
lines.push(`│ └── product.json # Canonical product identity`);
|
||||||
|
lines.push(`├── AGENTS.md # This file`);
|
||||||
|
lines.push(`└── README.md`);
|
||||||
|
lines.push('```');
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
// ── 3. Tech Stack
|
||||||
|
lines.push('## 3. Tech Stack');
|
||||||
|
lines.push('');
|
||||||
|
lines.push('| Layer | Technology |');
|
||||||
|
lines.push('|-------|-----------|');
|
||||||
|
for (const { layer, tech } of info.techStack) {
|
||||||
|
lines.push(`| **${layer}** | ${tech} |`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test counts
|
||||||
|
const totalTests = info.backendTestCount + info.webTestCount + info.mobileTestCount;
|
||||||
|
if (totalTests > 0) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (info.backendTestCount > 0) parts.push(`${info.backendTestCount} backend`);
|
||||||
|
if (info.webTestCount > 0) parts.push(`${info.webTestCount} web`);
|
||||||
|
if (info.mobileTestCount > 0) parts.push(`${info.mobileTestCount} mobile`);
|
||||||
|
lines.push(`| **Tests** | Vitest — ~${totalTests} tests (${parts.join(' + ')}) |`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
// ── 4. Coding Conventions
|
||||||
|
lines.push('## 4. Coding Conventions');
|
||||||
|
lines.push('');
|
||||||
|
lines.push('### MUST follow');
|
||||||
|
lines.push('');
|
||||||
|
lines.push(`- Every Cosmos document MUST include a \`productId: "${productId}"\` field`);
|
||||||
|
if (info.hasFastifyBackend) {
|
||||||
|
lines.push('- Backend modules follow `types.ts` → `repository.ts` → `routes.ts` pattern');
|
||||||
|
lines.push(
|
||||||
|
'- All repositories use `@bytelyst/datastore` getCollection() — never direct Cosmos SDK calls'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (info.hasNextWeb) {
|
||||||
|
lines.push('- Web engine logic in `web/src/lib/` — pure TS, no React imports');
|
||||||
|
lines.push('- Web components in `web/src/components/` — React UI only');
|
||||||
|
}
|
||||||
|
if (info.hasExpoMobile) {
|
||||||
|
lines.push('- Mobile engine logic in `mobile/src/lib/` — pure TS, no React Native imports');
|
||||||
|
}
|
||||||
|
lines.push(
|
||||||
|
'- Commit messages: `type(scope): description` — types: `feat`, `fix`, `docs`, `refactor`, `test`, `chore`'
|
||||||
|
);
|
||||||
|
lines.push('');
|
||||||
|
lines.push('### MUST NOT do');
|
||||||
|
lines.push('');
|
||||||
|
lines.push(
|
||||||
|
'- Never use `console.log` in production code — use `req.log` or `app.log` in Fastify'
|
||||||
|
);
|
||||||
|
lines.push('- Never use `any` type — use Zod inference or explicit types');
|
||||||
|
lines.push('- Never hardcode colors — use theme tokens');
|
||||||
|
lines.push('- Never hardcode API URLs — use env vars or config');
|
||||||
|
lines.push(`- Never hardcode product ID — use \`productConfig.productId\``);
|
||||||
|
lines.push('- Never modify tests to make them pass — fix the actual code');
|
||||||
|
lines.push('- Never delete existing comments or documentation unless explicitly asked');
|
||||||
|
lines.push('- Never add emojis to code unless explicitly asked');
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
// ── 5. Build & Test Commands
|
||||||
|
if (info.buildCommands.length > 0) {
|
||||||
|
lines.push('## 5. Build & Test Commands');
|
||||||
|
lines.push('');
|
||||||
|
lines.push('```bash');
|
||||||
|
for (const cmd of info.buildCommands) {
|
||||||
|
lines.push(cmd);
|
||||||
|
}
|
||||||
|
lines.push('```');
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 6. Backend API Modules (if applicable)
|
||||||
|
if (info.hasFastifyBackend && info.backendModules.length > 0) {
|
||||||
|
lines.push('## 6. Backend Modules');
|
||||||
|
lines.push('');
|
||||||
|
lines.push('| Module | Container | Description |');
|
||||||
|
lines.push('|--------|-----------|-------------|');
|
||||||
|
for (const mod of info.backendModules) {
|
||||||
|
const container = mod.replace(/-/g, '_');
|
||||||
|
lines.push(`| \`${mod}\` | \`${container}\` | ${mod.replace(/-/g, ' ')} |`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Custom section placeholder
|
||||||
|
lines.push('<!-- CUSTOM:extra -->');
|
||||||
|
lines.push('<!-- /CUSTOM:extra -->');
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Custom Section Preservation ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractCustomSections(content: string): Map<string, string> {
|
||||||
|
const sections = new Map<string, string>();
|
||||||
|
const regex = /<!-- CUSTOM:(\w+) -->\n([\s\S]*?)<!-- \/CUSTOM:\1 -->/g;
|
||||||
|
let match;
|
||||||
|
while ((match = regex.exec(content)) !== null) {
|
||||||
|
sections.set(match[1], match[2]);
|
||||||
|
}
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeCustomSections(newContent: string, existing: Map<string, string>): string {
|
||||||
|
let result = newContent;
|
||||||
|
for (const [key, value] of existing) {
|
||||||
|
const placeholder = `<!-- CUSTOM:${key} -->\n<!-- /CUSTOM:${key} -->`;
|
||||||
|
const replacement = `<!-- CUSTOM:${key} -->\n${value}<!-- /CUSTOM:${key} -->`;
|
||||||
|
result = result.replace(placeholder, replacement);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Symlink Manager ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function ensureSymlinks(repoPath: string, dryRun: boolean): Promise<void> {
|
||||||
|
const targets = ['CLAUDE.md', '.cursorrules', '.windsurfrules'];
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
const linkPath = path.join(repoPath, target);
|
||||||
|
const exists = await fileExists(linkPath);
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
try {
|
||||||
|
const stat = await fs.lstat(linkPath);
|
||||||
|
if (stat.isSymbolicLink()) {
|
||||||
|
const linkTarget = await fs.readlink(linkPath);
|
||||||
|
if (linkTarget === 'AGENTS.md') {
|
||||||
|
continue; // already correct
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// not a symlink
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
console.log(` 📝 Would create symlink: ${target} → AGENTS.md`);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
if (exists) await fs.unlink(linkPath);
|
||||||
|
await fs.symlink('AGENTS.md', linkPath);
|
||||||
|
console.log(` ✅ ${target} → AGENTS.md`);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(
|
||||||
|
` ⚠️ Could not create symlink ${target}: ${err instanceof Error ? err.message : String(err)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const { repo, dryRun, update } = parseArgs();
|
||||||
|
const repoPath = path.resolve(repo);
|
||||||
|
|
||||||
|
console.log(`\n📄 AGENTS.md Generator`);
|
||||||
|
console.log(` Repo: ${repoPath}`);
|
||||||
|
if (dryRun) console.log(' ⚠️ DRY RUN — no files will be written\n');
|
||||||
|
if (update) console.log(' 🔄 UPDATE mode — preserving custom sections\n');
|
||||||
|
|
||||||
|
// Load product.json
|
||||||
|
const manifest = await loadProductJson(repoPath);
|
||||||
|
console.log(` Product: ${manifest.displayName} (${manifest.productId})`);
|
||||||
|
|
||||||
|
// Scan repo
|
||||||
|
const info = await scanRepo(repoPath, manifest);
|
||||||
|
console.log(` Backend modules: ${info.backendModules.length}`);
|
||||||
|
console.log(` Tests: ~${info.backendTestCount + info.webTestCount + info.mobileTestCount}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Generate content
|
||||||
|
let content = generateAgentsMd(manifest, info);
|
||||||
|
|
||||||
|
// Merge custom sections if updating
|
||||||
|
if (update) {
|
||||||
|
const agentsPath = path.join(repoPath, 'AGENTS.md');
|
||||||
|
try {
|
||||||
|
const existing = await fs.readFile(agentsPath, 'utf-8');
|
||||||
|
const customSections = extractCustomSections(existing);
|
||||||
|
if (customSections.size > 0) {
|
||||||
|
console.log(` 🔄 Preserving ${customSections.size} custom section(s)`);
|
||||||
|
content = mergeCustomSections(content, customSections);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.log(' ℹ️ No existing AGENTS.md to preserve custom sections from');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
console.log('── AGENTS.md ──────────────────────────────────────');
|
||||||
|
console.log(content);
|
||||||
|
console.log('\n── Symlinks ──────────────────────────────────────');
|
||||||
|
await ensureSymlinks(repoPath, true);
|
||||||
|
console.log('\n✨ Dry run complete.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write AGENTS.md
|
||||||
|
const agentsPath = path.join(repoPath, 'AGENTS.md');
|
||||||
|
await fs.writeFile(agentsPath, content, 'utf-8');
|
||||||
|
console.log(` ✅ AGENTS.md written`);
|
||||||
|
|
||||||
|
// Ensure symlinks
|
||||||
|
await ensureSymlinks(repoPath, false);
|
||||||
|
|
||||||
|
console.log(`\n✨ AGENTS.md generated for ${manifest.displayName}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => {
|
||||||
|
console.error('❌ Error:', err instanceof Error ? err.message : String(err));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
770
vendor/bytelyst/create-app/src/generators/api-routes.ts
vendored
Normal file
770
vendor/bytelyst/create-app/src/generators/api-routes.ts
vendored
Normal file
@ -0,0 +1,770 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* API Route Generator — Next.js App Router
|
||||||
|
*
|
||||||
|
* Generates two files:
|
||||||
|
* src/app/api/<name>/route.ts — GET (list) + POST (create)
|
||||||
|
* src/app/api/<name>/[id]/route.ts — GET (detail) + PATCH (update) + DELETE
|
||||||
|
*
|
||||||
|
* Follows the pattern used across ByteLyst product dashboards:
|
||||||
|
* - Named exports (export const GET, POST, PATCH, DELETE)
|
||||||
|
* - withErrorHandler HOF wrapper
|
||||||
|
* - Auth via getCurrentUser / getAccessToken
|
||||||
|
* - Zod validation on POST/PATCH bodies
|
||||||
|
* - NextRequest + NextResponse
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npx tsx src/generators/api-routes.ts --name tasks --fields "title:string,status:enum(pending,active,done),priority:number?" --target ../some-web/src
|
||||||
|
* npx tsx src/generators/api-routes.ts --name tasks --fields "title:string" --mode proxy --target ../some-web/src
|
||||||
|
*
|
||||||
|
* Modes:
|
||||||
|
* --mode direct (default) Direct Cosmos DB access via repository functions
|
||||||
|
* --mode proxy Proxy to product backend via fetch (for thin web clients)
|
||||||
|
*
|
||||||
|
* @module @bytelyst/create-app/generators/api-routes
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* eslint-disable no-console -- This generator is a CLI; console output is its user interface. */
|
||||||
|
|
||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
// ── CLI ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
name: string;
|
||||||
|
fields: string;
|
||||||
|
target: string;
|
||||||
|
mode: 'direct' | 'proxy';
|
||||||
|
methods: string[];
|
||||||
|
withHandler: boolean;
|
||||||
|
dryRun: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(): Options {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const options: Options = {
|
||||||
|
name: '',
|
||||||
|
fields: '',
|
||||||
|
target: './src',
|
||||||
|
mode: 'direct',
|
||||||
|
methods: ['GET', 'POST', 'PATCH', 'DELETE'],
|
||||||
|
withHandler: true,
|
||||||
|
dryRun: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const arg = args[i];
|
||||||
|
if (arg === '--name' || arg === '-n') options.name = args[++i];
|
||||||
|
else if (arg === '--fields' || arg === '-f') options.fields = args[++i];
|
||||||
|
else if (arg === '--target' || arg === '-t') options.target = args[++i];
|
||||||
|
else if (arg === '--mode' || arg === '-m') options.mode = args[++i] as 'direct' | 'proxy';
|
||||||
|
else if (arg === '--methods')
|
||||||
|
options.methods = args[++i].split(',').map(m => m.trim().toUpperCase());
|
||||||
|
else if (arg === '--no-handler') options.withHandler = false;
|
||||||
|
else if (arg === '--dry-run' || arg === '-d') options.dryRun = true;
|
||||||
|
else if (arg === '--help' || arg === '-h') {
|
||||||
|
showHelp();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.name) {
|
||||||
|
console.error('Error: --name is required');
|
||||||
|
showHelp();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^[a-z][a-z0-9-]*$/.test(options.name)) {
|
||||||
|
console.error('Error: --name must be lowercase alphanumeric with optional hyphens');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['direct', 'proxy'].includes(options.mode)) {
|
||||||
|
console.error('Error: --mode must be "direct" or "proxy"');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showHelp(): void {
|
||||||
|
console.log(`
|
||||||
|
API Route Generator — Next.js App Router
|
||||||
|
|
||||||
|
Generates CRUD API route files for a Next.js App Router project.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
npx tsx api-routes.ts --name <name> [--fields "<field:type,...>"] [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--name, -n Entity name (e.g., "tasks", "sessions") [required]
|
||||||
|
--fields, -f Comma-separated field definitions [optional for proxy mode]
|
||||||
|
--target, -t Target src/ directory (default: "./src")
|
||||||
|
--mode, -m "direct" (Cosmos DB) or "proxy" (backend fetch) [default: direct]
|
||||||
|
--methods HTTP methods to generate (default: GET,POST,PATCH,DELETE)
|
||||||
|
--no-handler Skip withErrorHandler wrapper
|
||||||
|
--dry-run, -d Preview without writing files
|
||||||
|
--help, -h Show this help
|
||||||
|
|
||||||
|
Field Types (same as gen-module):
|
||||||
|
string z.string()
|
||||||
|
number z.number()
|
||||||
|
boolean z.boolean()
|
||||||
|
date z.string().datetime()
|
||||||
|
enum(a,b,c) z.enum(["a","b","c"])
|
||||||
|
Append ? for optional fields
|
||||||
|
|
||||||
|
Modes:
|
||||||
|
direct — Generates route files that call repository functions (direct Cosmos DB).
|
||||||
|
Also generates a lib/repositories/<name>.ts and lib/schemas/<name>.ts.
|
||||||
|
proxy — Generates route files that proxy to a product backend via fetch.
|
||||||
|
Requires lib/api-helpers.ts to exist (createApiClient pattern).
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# Direct mode (full CRUD with Cosmos)
|
||||||
|
npx tsx api-routes.ts --name tasks \\
|
||||||
|
--fields "title:string,status:enum(pending,active,done),priority:number?" \\
|
||||||
|
--target ./src
|
||||||
|
|
||||||
|
# Proxy mode (thin web client forwarding to backend)
|
||||||
|
npx tsx api-routes.ts --name tasks --mode proxy --target ./src
|
||||||
|
|
||||||
|
# Preview only
|
||||||
|
npx tsx api-routes.ts --name tasks --fields "title:string" --dry-run
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Field Parser ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ParsedField {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
optional: boolean;
|
||||||
|
zodType: string;
|
||||||
|
tsType: string;
|
||||||
|
enumValues: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitFields(str: string): string[] {
|
||||||
|
const parts: string[] = [];
|
||||||
|
let depth = 0;
|
||||||
|
let current = '';
|
||||||
|
for (const ch of str) {
|
||||||
|
if (ch === '(') depth++;
|
||||||
|
if (ch === ')') depth--;
|
||||||
|
if (ch === ',' && depth === 0) {
|
||||||
|
parts.push(current.trim());
|
||||||
|
current = '';
|
||||||
|
} else {
|
||||||
|
current += ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current.trim()) parts.push(current.trim());
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFields(fieldsStr: string): ParsedField[] {
|
||||||
|
if (!fieldsStr) return [];
|
||||||
|
const fields: ParsedField[] = [];
|
||||||
|
const fieldDefs = splitFields(fieldsStr);
|
||||||
|
|
||||||
|
for (const raw of fieldDefs) {
|
||||||
|
const def = raw.trim();
|
||||||
|
if (!def) continue;
|
||||||
|
|
||||||
|
const optional = def.endsWith('?');
|
||||||
|
const cleaned = optional ? def.slice(0, -1) : def;
|
||||||
|
const colonIdx = cleaned.indexOf(':');
|
||||||
|
if (colonIdx === -1) throw new Error(`Invalid field (missing type): ${def}`);
|
||||||
|
|
||||||
|
const name = cleaned.slice(0, colonIdx).trim();
|
||||||
|
const type = cleaned.slice(colonIdx + 1).trim();
|
||||||
|
|
||||||
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
||||||
|
throw new Error(`Invalid field name: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let zodType: string;
|
||||||
|
let tsType: string;
|
||||||
|
let enumValues: string[] | null = null;
|
||||||
|
|
||||||
|
if (type.startsWith('enum(') && type.endsWith(')')) {
|
||||||
|
enumValues = type
|
||||||
|
.slice(5, -1)
|
||||||
|
.split(',')
|
||||||
|
.map(v => v.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (enumValues.length === 0) throw new Error(`Empty enum: ${def}`);
|
||||||
|
zodType = `z.enum([${enumValues.map(v => `'${v}'`).join(', ')}])`;
|
||||||
|
tsType = enumValues.map(v => `'${v}'`).join(' | ');
|
||||||
|
} else {
|
||||||
|
const MAP: Record<string, { zod: string; ts: string }> = {
|
||||||
|
string: { zod: 'z.string().min(1)', ts: 'string' },
|
||||||
|
number: { zod: 'z.number()', ts: 'number' },
|
||||||
|
boolean: { zod: 'z.boolean()', ts: 'boolean' },
|
||||||
|
date: { zod: 'z.string().datetime()', ts: 'string' },
|
||||||
|
datetime: { zod: 'z.string().datetime()', ts: 'string' },
|
||||||
|
'string[]': { zod: 'z.array(z.string())', ts: 'string[]' },
|
||||||
|
'number[]': { zod: 'z.array(z.number())', ts: 'number[]' },
|
||||||
|
};
|
||||||
|
const mapping = MAP[type];
|
||||||
|
if (!mapping) throw new Error(`Unknown field type "${type}" in: ${def}`);
|
||||||
|
zodType = mapping.zod;
|
||||||
|
tsType = mapping.ts;
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.push({ name, type, optional, zodType, tsType, enumValues });
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function pascal(s: string): string {
|
||||||
|
return s.replace(/(^|-)([a-z])/g, (_, __, c: string) => c.toUpperCase());
|
||||||
|
}
|
||||||
|
// ── Proxy Mode Templates ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function genProxyListRoute(name: string, methods: string[], withHandler: boolean): string {
|
||||||
|
const hasGet = methods.includes('GET');
|
||||||
|
const hasPost = methods.includes('POST');
|
||||||
|
const handlerImport = withHandler
|
||||||
|
? `import { withErrorHandler } from '@/lib/api-handler';\n`
|
||||||
|
: '';
|
||||||
|
const wrap = (code: string) => (withHandler ? `withErrorHandler(${code})` : code);
|
||||||
|
|
||||||
|
let out = `import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getAccessToken } from '@/lib/api-helpers';
|
||||||
|
${handlerImport}
|
||||||
|
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? 'http://localhost:4000';
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (hasGet) {
|
||||||
|
out += `
|
||||||
|
export const GET = ${wrap(`async (req: NextRequest) => {
|
||||||
|
const token = await getAccessToken(req);
|
||||||
|
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const params = url.searchParams.toString();
|
||||||
|
const res = await fetch(\`\${BACKEND_URL}/api/${name}\${params ? '?' + params : ''}\`, {
|
||||||
|
headers: { Authorization: \`Bearer \${token}\` },
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
return NextResponse.json(data, { status: res.status });
|
||||||
|
}`)};
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPost) {
|
||||||
|
out += `
|
||||||
|
export const POST = ${wrap(`async (req: NextRequest) => {
|
||||||
|
const token = await getAccessToken(req);
|
||||||
|
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const res = await fetch(\`\${BACKEND_URL}/api/${name}\`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: \`Bearer \${token}\`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
return NextResponse.json(data, { status: res.status });
|
||||||
|
}`)};
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function genProxyDetailRoute(name: string, methods: string[], withHandler: boolean): string {
|
||||||
|
const hasGet = methods.includes('GET');
|
||||||
|
const hasPatch = methods.includes('PATCH');
|
||||||
|
const hasDelete = methods.includes('DELETE');
|
||||||
|
const handlerImport = withHandler
|
||||||
|
? `import { withErrorHandler } from '@/lib/api-handler';\n`
|
||||||
|
: '';
|
||||||
|
const wrap = (code: string) => (withHandler ? `withErrorHandler(${code})` : code);
|
||||||
|
|
||||||
|
let out = `import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getAccessToken } from '@/lib/api-helpers';
|
||||||
|
${handlerImport}
|
||||||
|
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? 'http://localhost:4000';
|
||||||
|
|
||||||
|
type RouteContext = { params: Promise<{ id: string }> };
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (hasGet) {
|
||||||
|
out += `
|
||||||
|
export const GET = ${wrap(`async (req: NextRequest, { params }: RouteContext) => {
|
||||||
|
const token = await getAccessToken(req);
|
||||||
|
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const res = await fetch(\`\${BACKEND_URL}/api/${name}/\${id}\`, {
|
||||||
|
headers: { Authorization: \`Bearer \${token}\` },
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
return NextResponse.json(data, { status: res.status });
|
||||||
|
}`)};
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPatch) {
|
||||||
|
out += `
|
||||||
|
export const PATCH = ${wrap(`async (req: NextRequest, { params }: RouteContext) => {
|
||||||
|
const token = await getAccessToken(req);
|
||||||
|
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await req.json();
|
||||||
|
const res = await fetch(\`\${BACKEND_URL}/api/${name}/\${id}\`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Authorization: \`Bearer \${token}\`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
return NextResponse.json(data, { status: res.status });
|
||||||
|
}`)};
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasDelete) {
|
||||||
|
out += `
|
||||||
|
export const DELETE = ${wrap(`async (req: NextRequest, { params }: RouteContext) => {
|
||||||
|
const token = await getAccessToken(req);
|
||||||
|
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const res = await fetch(\`\${BACKEND_URL}/api/${name}/\${id}\`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: \`Bearer \${token}\` },
|
||||||
|
});
|
||||||
|
if (res.status === 204) return new NextResponse(null, { status: 204 });
|
||||||
|
const data = await res.json();
|
||||||
|
return NextResponse.json(data, { status: res.status });
|
||||||
|
}`)};
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Direct Mode Templates ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function genSchemaFile(name: string, fields: ParsedField[]): string {
|
||||||
|
const P = pascal(name);
|
||||||
|
const createFields = fields
|
||||||
|
.map(f => ` ${f.name}: ${f.zodType}${f.optional ? '.optional()' : ''},`)
|
||||||
|
.join('\n');
|
||||||
|
const updateFields = fields.map(f => ` ${f.name}: ${f.zodType}.optional(),`).join('\n');
|
||||||
|
|
||||||
|
return `import { z } from 'zod';
|
||||||
|
|
||||||
|
export const Create${P}Schema = z.object({
|
||||||
|
${createFields}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Update${P}Schema = z.object({
|
||||||
|
${updateFields}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Create${P}Input = z.infer<typeof Create${P}Schema>;
|
||||||
|
export type Update${P}Input = z.infer<typeof Update${P}Schema>;
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function genRepositoryFile(name: string, fields: ParsedField[]): string {
|
||||||
|
const P = pascal(name);
|
||||||
|
const docFields = fields.map(f => ` ${f.name}${f.optional ? '?' : ''}: ${f.tsType};`).join('\n');
|
||||||
|
|
||||||
|
return `import { randomUUID } from 'node:crypto';
|
||||||
|
import { getCosmosContainer, PRODUCT_ID } from '@/lib/datastore';
|
||||||
|
import type { Create${P}Input, Update${P}Input } from '@/lib/schemas/${name}';
|
||||||
|
|
||||||
|
export interface ${P}Doc {
|
||||||
|
id: string;
|
||||||
|
productId: string;
|
||||||
|
userId: string;
|
||||||
|
${docFields}
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContainer() {
|
||||||
|
return getCosmosContainer('${name}');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function list${P}(userId: string, limit = 50, offset = 0): Promise<${P}Doc[]> {
|
||||||
|
const { resources } = await getContainer()
|
||||||
|
.items.query<${P}Doc>({
|
||||||
|
query: 'SELECT * FROM c WHERE c.userId = @uid AND c.productId = @pid ORDER BY c.createdAt DESC OFFSET @off LIMIT @lim',
|
||||||
|
parameters: [
|
||||||
|
{ name: '@uid', value: userId },
|
||||||
|
{ name: '@pid', value: PRODUCT_ID },
|
||||||
|
{ name: '@off', value: offset },
|
||||||
|
{ name: '@lim', value: limit },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.fetchAll();
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get${P}(id: string, userId: string): Promise<${P}Doc | null> {
|
||||||
|
try {
|
||||||
|
const { resource } = await getContainer().item(id, userId).read<${P}Doc>();
|
||||||
|
return resource ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function create${P}(userId: string, input: Create${P}Input): Promise<${P}Doc> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const doc: ${P}Doc = {
|
||||||
|
id: \`${name.slice(0, 3)}_\${randomUUID()}\`,
|
||||||
|
productId: PRODUCT_ID,
|
||||||
|
userId,
|
||||||
|
...input,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
await getContainer().items.create(doc);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function update${P}(id: string, userId: string, updates: Update${P}Input): Promise<${P}Doc | null> {
|
||||||
|
const existing = await get${P}(id, userId);
|
||||||
|
if (!existing) return null;
|
||||||
|
|
||||||
|
const updated: ${P}Doc = {
|
||||||
|
...existing,
|
||||||
|
...updates,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await getContainer().item(id, userId).replace(updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function delete${P}(id: string, userId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await getContainer().item(id, userId).delete();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function genDirectListRoute(
|
||||||
|
name: string,
|
||||||
|
fields: ParsedField[],
|
||||||
|
methods: string[],
|
||||||
|
withHandler: boolean
|
||||||
|
): string {
|
||||||
|
const P = pascal(name);
|
||||||
|
const hasGet = methods.includes('GET');
|
||||||
|
const hasPost = methods.includes('POST');
|
||||||
|
const handlerImport = withHandler
|
||||||
|
? `import { withErrorHandler } from '@/lib/api-handler';\n`
|
||||||
|
: '';
|
||||||
|
const wrap = (code: string) => (withHandler ? `withErrorHandler(${code})` : code);
|
||||||
|
|
||||||
|
const imports: string[] = [];
|
||||||
|
if (hasGet) imports.push(`list${P}`);
|
||||||
|
if (hasPost) imports.push(`create${P}`);
|
||||||
|
|
||||||
|
let out = `import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth-server';
|
||||||
|
${handlerImport}`;
|
||||||
|
|
||||||
|
if (imports.length > 0) {
|
||||||
|
out += `import { ${imports.join(', ')} } from '@/lib/repositories/${name}';\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPost) {
|
||||||
|
out += `import { Create${P}Schema } from '@/lib/schemas/${name}';\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasGet) {
|
||||||
|
out += `
|
||||||
|
export const GET = ${wrap(`async (req: NextRequest) => {
|
||||||
|
const user = await getCurrentUser(req.headers.get('authorization'));
|
||||||
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const limit = parseInt(url.searchParams.get('limit') ?? '50');
|
||||||
|
const offset = parseInt(url.searchParams.get('offset') ?? '0');
|
||||||
|
|
||||||
|
const items = await list${P}(user.id, limit, offset);
|
||||||
|
return NextResponse.json({ items });
|
||||||
|
}`)};
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPost) {
|
||||||
|
out += `
|
||||||
|
export const POST = ${wrap(`async (req: NextRequest) => {
|
||||||
|
const user = await getCurrentUser(req.headers.get('authorization'));
|
||||||
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const input = Create${P}Schema.parse(body);
|
||||||
|
const item = await create${P}(user.id, input);
|
||||||
|
return NextResponse.json(item, { status: 201 });
|
||||||
|
}`)};
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function genDirectDetailRoute(
|
||||||
|
name: string,
|
||||||
|
_fields: ParsedField[],
|
||||||
|
methods: string[],
|
||||||
|
withHandler: boolean
|
||||||
|
): string {
|
||||||
|
const P = pascal(name);
|
||||||
|
const hasGet = methods.includes('GET');
|
||||||
|
const hasPatch = methods.includes('PATCH');
|
||||||
|
const hasDelete = methods.includes('DELETE');
|
||||||
|
const handlerImport = withHandler
|
||||||
|
? `import { withErrorHandler } from '@/lib/api-handler';\n`
|
||||||
|
: '';
|
||||||
|
const wrap = (code: string) => (withHandler ? `withErrorHandler(${code})` : code);
|
||||||
|
|
||||||
|
const imports: string[] = [];
|
||||||
|
if (hasGet) imports.push(`get${P}`);
|
||||||
|
if (hasPatch) imports.push(`update${P}`);
|
||||||
|
if (hasDelete) imports.push(`delete${P}`);
|
||||||
|
|
||||||
|
let out = `import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth-server';
|
||||||
|
${handlerImport}`;
|
||||||
|
|
||||||
|
if (imports.length > 0) {
|
||||||
|
out += `import { ${imports.join(', ')} } from '@/lib/repositories/${name}';\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPatch) {
|
||||||
|
out += `import { Update${P}Schema } from '@/lib/schemas/${name}';\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
out += `
|
||||||
|
type RouteContext = { params: Promise<{ id: string }> };
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (hasGet) {
|
||||||
|
out += `
|
||||||
|
export const GET = ${wrap(`async (
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: RouteContext,
|
||||||
|
) => {
|
||||||
|
const user = await getCurrentUser(req.headers.get('authorization'));
|
||||||
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const item = await get${P}(id, user.id);
|
||||||
|
if (!item) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
|
||||||
|
return NextResponse.json(item);
|
||||||
|
}`)};
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPatch) {
|
||||||
|
out += `
|
||||||
|
export const PATCH = ${wrap(`async (
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: RouteContext,
|
||||||
|
) => {
|
||||||
|
const user = await getCurrentUser(req.headers.get('authorization'));
|
||||||
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await req.json();
|
||||||
|
const updates = Update${P}Schema.parse(body);
|
||||||
|
const item = await update${P}(id, user.id, updates);
|
||||||
|
if (!item) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
|
||||||
|
return NextResponse.json(item);
|
||||||
|
}`)};
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasDelete) {
|
||||||
|
out += `
|
||||||
|
export const DELETE = ${wrap(`async (
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: RouteContext,
|
||||||
|
) => {
|
||||||
|
const user = await getCurrentUser(req.headers.get('authorization'));
|
||||||
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const deleted = await delete${P}(id, user.id);
|
||||||
|
if (!deleted) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}`)};
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test Template ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function genSchemaTest(name: string, fields: ParsedField[]): string {
|
||||||
|
const P = pascal(name);
|
||||||
|
const requiredFields = fields.filter(f => !f.optional);
|
||||||
|
|
||||||
|
function sampleValue(f: ParsedField): string {
|
||||||
|
if (f.enumValues) return `'${f.enumValues[0]}'`;
|
||||||
|
if (f.tsType === 'string') return `'test'`;
|
||||||
|
if (f.tsType === 'number') return '42';
|
||||||
|
if (f.tsType === 'boolean') return 'true';
|
||||||
|
return `'2026-01-01T00:00:00.000Z'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validPayload = requiredFields.map(f => ` ${f.name}: ${sampleValue(f)},`).join('\n');
|
||||||
|
|
||||||
|
return `import { describe, it, expect } from 'vitest';
|
||||||
|
import { Create${P}Schema, Update${P}Schema } from './schemas/${name}';
|
||||||
|
|
||||||
|
describe('Create${P}Schema', () => {
|
||||||
|
it('accepts valid input', () => {
|
||||||
|
const result = Create${P}Schema.parse({
|
||||||
|
${validPayload}
|
||||||
|
});
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty object', () => {
|
||||||
|
expect(() => Create${P}Schema.parse({})).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Update${P}Schema', () => {
|
||||||
|
it('accepts empty object (all optional)', () => {
|
||||||
|
const result = Update${P}Schema.parse({});
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts partial update', () => {
|
||||||
|
const result = Update${P}Schema.parse({ ${requiredFields[0]?.name ?? 'id'}: ${sampleValue(requiredFields[0] ?? fields[0])} });
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const { name, fields, target, mode, methods, withHandler, dryRun } = parseArgs();
|
||||||
|
|
||||||
|
console.log(`\n🚀 Generating API routes: ${name}`);
|
||||||
|
console.log(` Mode: ${mode}`);
|
||||||
|
console.log(` Methods: ${methods.join(', ')}`);
|
||||||
|
console.log(` Target: ${target}`);
|
||||||
|
if (fields) console.log(` Fields: ${fields}`);
|
||||||
|
if (dryRun) console.log(' ⚠️ DRY RUN — no files will be written\n');
|
||||||
|
|
||||||
|
const parsedFields = parseFields(fields);
|
||||||
|
|
||||||
|
// Determine which files to generate
|
||||||
|
const files: { path: string; content: string }[] = [];
|
||||||
|
|
||||||
|
if (mode === 'proxy') {
|
||||||
|
files.push({
|
||||||
|
path: `app/api/${name}/route.ts`,
|
||||||
|
content: genProxyListRoute(name, methods, withHandler),
|
||||||
|
});
|
||||||
|
if (methods.some(m => ['GET', 'PATCH', 'DELETE'].includes(m))) {
|
||||||
|
files.push({
|
||||||
|
path: `app/api/${name}/[id]/route.ts`,
|
||||||
|
content: genProxyDetailRoute(name, methods, withHandler),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Direct mode — also generate schema + repository
|
||||||
|
if (parsedFields.length === 0) {
|
||||||
|
console.error('Error: --fields is required in direct mode');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
files.push({
|
||||||
|
path: `lib/schemas/${name}.ts`,
|
||||||
|
content: genSchemaFile(name, parsedFields),
|
||||||
|
});
|
||||||
|
files.push({
|
||||||
|
path: `lib/repositories/${name}.ts`,
|
||||||
|
content: genRepositoryFile(name, parsedFields),
|
||||||
|
});
|
||||||
|
files.push({
|
||||||
|
path: `app/api/${name}/route.ts`,
|
||||||
|
content: genDirectListRoute(name, parsedFields, methods, withHandler),
|
||||||
|
});
|
||||||
|
if (methods.some(m => ['GET', 'PATCH', 'DELETE'].includes(m))) {
|
||||||
|
files.push({
|
||||||
|
path: `app/api/${name}/[id]/route.ts`,
|
||||||
|
content: genDirectDetailRoute(name, parsedFields, methods, withHandler),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
files.push({
|
||||||
|
path: `lib/__tests__/${name}.test.ts`,
|
||||||
|
content: genSchemaTest(name, parsedFields),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
console.log('📄 Generated files:\n');
|
||||||
|
for (const file of files) {
|
||||||
|
console.log(`── ${file.path} ──────────────────────────────────────`);
|
||||||
|
console.log(file.content);
|
||||||
|
}
|
||||||
|
console.log(`\n✨ Dry run complete. Re-run without --dry-run to write files.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write files
|
||||||
|
for (const file of files) {
|
||||||
|
const fullPath = path.join(target, file.path);
|
||||||
|
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
||||||
|
|
||||||
|
// Check if file already exists
|
||||||
|
try {
|
||||||
|
await fs.access(fullPath);
|
||||||
|
console.log(` ⚠️ SKIP ${file.path} (already exists)`);
|
||||||
|
continue;
|
||||||
|
} catch {
|
||||||
|
// Good — doesn't exist
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.writeFile(fullPath, file.content, 'utf-8');
|
||||||
|
console.log(` ✅ ${file.path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n✨ API routes generated for "${name}".`);
|
||||||
|
if (mode === 'direct') {
|
||||||
|
console.log(`\nPrerequisites (if not already present):`);
|
||||||
|
console.log(` - lib/auth-server.ts — getCurrentUser(authHeader) function`);
|
||||||
|
console.log(` - lib/api-handler.ts — withErrorHandler HOF`);
|
||||||
|
console.log(` - lib/datastore.ts — getCosmosContainer + PRODUCT_ID`);
|
||||||
|
console.log(` - zod installed — npm install zod`);
|
||||||
|
} else {
|
||||||
|
console.log(`\nPrerequisites (if not already present):`);
|
||||||
|
console.log(` - lib/api-helpers.ts — getAccessToken(req) function`);
|
||||||
|
console.log(` - lib/api-handler.ts — withErrorHandler HOF`);
|
||||||
|
console.log(` - NEXT_PUBLIC_BACKEND_URL env var (or defaults to localhost:4000)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => {
|
||||||
|
console.error('❌ Error:', err instanceof Error ? err.message : String(err));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
9
vendor/bytelyst/create-app/src/index.ts
vendored
Normal file
9
vendor/bytelyst/create-app/src/index.ts
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* @bytelyst/create-app — CLI tools for scaffolding ByteLyst product repos.
|
||||||
|
*
|
||||||
|
* Generators:
|
||||||
|
* - api-routes.ts — Next.js App Router API route generator
|
||||||
|
* - agents-md.ts — AGENTS.md auto-generator from product.json + repo scan
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {};
|
||||||
34
vendor/bytelyst/create-app/src/lib/template-engine.ts
vendored
Normal file
34
vendor/bytelyst/create-app/src/lib/template-engine.ts
vendored
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Simple template engine for scaffolding.
|
||||||
|
* Supports {{VARIABLE}} replacement and {{#IF FEATURE}}...{{/IF FEATURE}} conditional blocks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type TemplateVars = Record<string, string | boolean | number>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace {{VARIABLE}} placeholders and process {{#IF FEATURE}}...{{/IF FEATURE}} blocks.
|
||||||
|
*/
|
||||||
|
export function renderTemplate(template: string, vars: TemplateVars): string {
|
||||||
|
let result = template;
|
||||||
|
|
||||||
|
// Process conditional blocks: {{#IF KEY}}...{{/IF KEY}}
|
||||||
|
const ifRegex = /\{\{#IF (\w+)\}\}([\s\S]*?)\{\{\/IF \1\}\}/g;
|
||||||
|
result = result.replace(ifRegex, (_, key: string, content: string) => {
|
||||||
|
return vars[key] ? content : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process negative conditional blocks: {{#UNLESS KEY}}...{{/UNLESS KEY}}
|
||||||
|
const unlessRegex = /\{\{#UNLESS (\w+)\}\}([\s\S]*?)\{\{\/UNLESS \1\}\}/g;
|
||||||
|
result = result.replace(unlessRegex, (_, key: string, content: string) => {
|
||||||
|
return !vars[key] ? content : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Replace {{VARIABLE}} placeholders
|
||||||
|
result = result.replace(/\{\{(\w+)\}\}/g, (match, key: string) => {
|
||||||
|
const val = vars[key];
|
||||||
|
if (val === undefined) return match;
|
||||||
|
return String(val);
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
457
vendor/bytelyst/create-app/src/lib/templates.ts
vendored
Normal file
457
vendor/bytelyst/create-app/src/lib/templates.ts
vendored
Normal file
@ -0,0 +1,457 @@
|
|||||||
|
/**
|
||||||
|
* Inline templates for product repo scaffolding.
|
||||||
|
* Each template uses {{VARIABLE}} and {{#IF FEATURE}}...{{/IF FEATURE}} syntax.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── product.json ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const PRODUCT_JSON = `{
|
||||||
|
"productId": "{{PRODUCT_ID}}",
|
||||||
|
"displayName": "{{DISPLAY_NAME}}",
|
||||||
|
"tagline": "{{TAGLINE}}",
|
||||||
|
"domain": "{{DOMAIN}}",
|
||||||
|
"backendPort": {{BACKEND_PORT}},
|
||||||
|
"primarySurface": "{{PRIMARY_SURFACE}}",
|
||||||
|
"bundleIds": {
|
||||||
|
"web": "{{DOMAIN}}"{{#IF HAS_IOS}},
|
||||||
|
"ios": "com.bytelyst.{{PRODUCT_ID}}"{{/IF HAS_IOS}}{{#IF HAS_ANDROID}},
|
||||||
|
"android": "com.{{PRODUCT_ID}}.app"{{/IF HAS_ANDROID}}
|
||||||
|
},
|
||||||
|
"appStore": {
|
||||||
|
"category": "Productivity",
|
||||||
|
"privacyUrl": "https://{{DOMAIN}}/privacy",
|
||||||
|
"termsUrl": "https://{{DOMAIN}}/terms",
|
||||||
|
"supportUrl": "https://{{DOMAIN}}/support"
|
||||||
|
},
|
||||||
|
"version": "0.1.0"
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── .gitignore ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const GITIGNORE = `node_modules/
|
||||||
|
dist/
|
||||||
|
.next/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
coverage/
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── .env.example ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const ENV_EXAMPLE = `# {{DISPLAY_NAME}} environment variables
|
||||||
|
NODE_ENV=development
|
||||||
|
{{#IF HAS_BACKEND}}
|
||||||
|
# Backend
|
||||||
|
PORT={{BACKEND_PORT}}
|
||||||
|
HOST=0.0.0.0
|
||||||
|
JWT_SECRET=dev-secret-change-me
|
||||||
|
DB_PROVIDER=memory
|
||||||
|
COSMOS_ENDPOINT=
|
||||||
|
COSMOS_KEY=
|
||||||
|
COSMOS_DATABASE=bytelyst
|
||||||
|
PLATFORM_SERVICE_URL=http://localhost:4003
|
||||||
|
{{/IF HAS_BACKEND}}
|
||||||
|
{{#IF HAS_WEB}}
|
||||||
|
# Web
|
||||||
|
NEXT_PUBLIC_BACKEND_URL=http://localhost:{{BACKEND_PORT}}
|
||||||
|
{{/IF HAS_WEB}}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── README.md ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const README = `# {{DISPLAY_NAME}}
|
||||||
|
|
||||||
|
> {{TAGLINE}}
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
{{#IF HAS_BACKEND}}# Backend
|
||||||
|
cd backend && npm install && npm run dev
|
||||||
|
{{/IF HAS_BACKEND}}{{#IF HAS_WEB}}# Web
|
||||||
|
cd web && npm install && npm run dev
|
||||||
|
{{/IF HAS_WEB}}{{#IF HAS_MOBILE}}# Mobile
|
||||||
|
cd mobile && npm install && npm start
|
||||||
|
{{/IF HAS_MOBILE}}\`\`\`
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|-----------|
|
||||||
|
{{#IF HAS_BACKEND}}| Backend | Fastify 5 + TypeScript ESM (port {{BACKEND_PORT}}) |
|
||||||
|
{{/IF HAS_BACKEND}}{{#IF HAS_WEB}}| Web | Next.js 16 (App Router) + React 19 |
|
||||||
|
{{/IF HAS_WEB}}{{#IF HAS_MOBILE}}| Mobile | React Native (Expo) |
|
||||||
|
{{/IF HAS_MOBILE}}| Platform | platform-service (port 4003) |
|
||||||
|
| Database | Azure Cosmos DB (\`productId: "{{PRODUCT_ID}}"\`) |
|
||||||
|
|
||||||
|
## Product Identity
|
||||||
|
|
||||||
|
- **Product ID:** \`{{PRODUCT_ID}}\`
|
||||||
|
- **Domain:** {{DOMAIN}}
|
||||||
|
- **Backend Port:** {{BACKEND_PORT}}
|
||||||
|
|
||||||
|
See [AGENTS.md](AGENTS.md) for AI agent instructions.
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── Backend templates ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const BACKEND_PACKAGE_JSON = `{
|
||||||
|
"name": "@{{PRODUCT_ID}}/backend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/server.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/server.js",
|
||||||
|
"test": "vitest run",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@bytelyst/config": "file:../../learning_ai_common_plat/packages/config",
|
||||||
|
"@bytelyst/cosmos": "file:../../learning_ai_common_plat/packages/cosmos",
|
||||||
|
"@bytelyst/datastore": "file:../../learning_ai_common_plat/packages/datastore",
|
||||||
|
"@bytelyst/errors": "file:../../learning_ai_common_plat/packages/errors",
|
||||||
|
"@bytelyst/fastify-core": "file:../../learning_ai_common_plat/packages/fastify-core",
|
||||||
|
"fastify": "^5.3.3",
|
||||||
|
"jose": "^6.0.11",
|
||||||
|
"zod": "^3.24.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vitest": "^3.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const BACKEND_TSCONFIG = `{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["dist", "src/**/*.test.ts"]
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const BACKEND_CONFIG = `import { z } from 'zod';
|
||||||
|
import { PRODUCT_ID } from './product-config.js';
|
||||||
|
|
||||||
|
const envSchema = z.object({
|
||||||
|
PORT: z.coerce.number().default({{BACKEND_PORT}}),
|
||||||
|
HOST: z.string().default('0.0.0.0'),
|
||||||
|
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||||
|
CORS_ORIGIN: z.string().optional(),
|
||||||
|
SERVICE_NAME: z.string().default('{{PRODUCT_ID}}-backend'),
|
||||||
|
COSMOS_ENDPOINT: z.string().optional(),
|
||||||
|
COSMOS_KEY: z.string().optional(),
|
||||||
|
COSMOS_DATABASE: z.string().default('bytelyst'),
|
||||||
|
JWT_SECRET: z.string().min(1, 'JWT_SECRET is required'),
|
||||||
|
DB_PROVIDER: z.enum(['cosmos', 'memory']).default('cosmos'),
|
||||||
|
PRODUCT_ID: z.string().default(PRODUCT_ID),
|
||||||
|
PLATFORM_SERVICE_URL: z.string().default('http://localhost:4003'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const config = envSchema.parse(process.env);
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const BACKEND_PRODUCT_CONFIG = `import { readFileSync } from 'node:fs';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const raw = readFileSync(join(__dirname, '../../../shared/product.json'), 'utf-8');
|
||||||
|
const manifest = JSON.parse(raw);
|
||||||
|
export const PRODUCT_ID: string = manifest.productId;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const BACKEND_AUTH = `import { jwtVerify, createRemoteJWKSet } from 'jose';
|
||||||
|
import type { FastifyRequest } from 'fastify';
|
||||||
|
import { config } from './config.js';
|
||||||
|
|
||||||
|
const secret = new TextEncoder().encode(config.JWT_SECRET);
|
||||||
|
|
||||||
|
export interface JwtPayload {
|
||||||
|
sub: string;
|
||||||
|
email?: string;
|
||||||
|
role?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyToken(token: string): Promise<JwtPayload | null> {
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify(token, secret);
|
||||||
|
return payload as unknown as JwtPayload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractToken(req: FastifyRequest): string | null {
|
||||||
|
const auth = req.headers.authorization;
|
||||||
|
if (!auth?.startsWith('Bearer ')) return null;
|
||||||
|
return auth.slice(7);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const BACKEND_REQUEST_CONTEXT = `import type { FastifyRequest } from 'fastify';
|
||||||
|
import { verifyToken, extractToken, type JwtPayload } from './auth.js';
|
||||||
|
import { config } from './config.js';
|
||||||
|
|
||||||
|
export async function getUserPayload(req: FastifyRequest): Promise<JwtPayload | null> {
|
||||||
|
const token = extractToken(req);
|
||||||
|
if (!token) return null;
|
||||||
|
return verifyToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserId(req: FastifyRequest & { user?: JwtPayload }): string {
|
||||||
|
if (!req.user?.sub) throw new Error('Unauthenticated');
|
||||||
|
return req.user.sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRequestProductId(_req: FastifyRequest): string {
|
||||||
|
return config.PRODUCT_ID;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const BACKEND_ERRORS = `export { BadRequestError, NotFoundError, ConflictError, ForbiddenError } from '@bytelyst/errors';
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const BACKEND_DATASTORE = `import { config } from './config.js';
|
||||||
|
|
||||||
|
const collections = new Map<string, Map<string, unknown>>();
|
||||||
|
|
||||||
|
export function getCollection<T = unknown>(name: string): Map<string, T> {
|
||||||
|
if (!collections.has(name)) {
|
||||||
|
collections.set(name, new Map());
|
||||||
|
}
|
||||||
|
return collections.get(name) as Map<string, T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PRODUCT_ID = config.PRODUCT_ID;
|
||||||
|
export const DB_PROVIDER = config.DB_PROVIDER;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const BACKEND_SERVER = `import Fastify from 'fastify';
|
||||||
|
import { config } from './lib/config.js';
|
||||||
|
|
||||||
|
const app = Fastify({
|
||||||
|
logger: {
|
||||||
|
level: config.NODE_ENV === 'test' ? 'silent' : 'info',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// CORS
|
||||||
|
if (config.CORS_ORIGIN) {
|
||||||
|
app.addHook('onRequest', async (request, reply) => {
|
||||||
|
reply.header('Access-Control-Allow-Origin', config.CORS_ORIGIN);
|
||||||
|
reply.header('Access-Control-Allow-Methods', 'GET,POST,PATCH,PUT,DELETE,OPTIONS');
|
||||||
|
reply.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||||
|
if (request.method === 'OPTIONS') {
|
||||||
|
reply.status(204).send();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get('/health', async () => ({
|
||||||
|
status: 'ok',
|
||||||
|
service: config.SERVICE_NAME,
|
||||||
|
productId: config.PRODUCT_ID,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// TODO: Register your route modules here
|
||||||
|
// import { routes as exampleRoutes } from './modules/example/routes.js';
|
||||||
|
// app.register(exampleRoutes, { prefix: '/api' });
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
try {
|
||||||
|
await app.listen({ port: config.PORT, host: config.HOST });
|
||||||
|
app.log.info(\`{{DISPLAY_NAME}} backend listening on port \${config.PORT}\`);
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start();
|
||||||
|
|
||||||
|
export { app };
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── Web templates ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const WEB_PACKAGE_JSON = `{
|
||||||
|
"name": "@{{PRODUCT_ID}}/web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build --webpack",
|
||||||
|
"start": "next start",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "^16.0.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.16.0",
|
||||||
|
"@types/react": "^19.2.0",
|
||||||
|
"@types/react-dom": "^19.2.0",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const WEB_TSCONFIG = `{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const WEB_NEXT_CONFIG = `import type { NextConfig } from 'next';
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const WEB_LAYOUT = `import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: '{{DISPLAY_NAME}}',
|
||||||
|
description: '{{TAGLINE}}',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const WEB_PAGE = `export default function Home() {
|
||||||
|
return (
|
||||||
|
<main style={{ padding: '2rem', maxWidth: '800px', margin: '0 auto', fontFamily: 'system-ui' }}>
|
||||||
|
<h1>{{DISPLAY_NAME}}</h1>
|
||||||
|
<p>{{TAGLINE}}</p>
|
||||||
|
<p>Edit <code>src/app/page.tsx</code> to get started.</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const WEB_PRODUCT_CONFIG = `const manifest = {
|
||||||
|
productId: '{{PRODUCT_ID}}',
|
||||||
|
displayName: '{{DISPLAY_NAME}}',
|
||||||
|
domain: '{{DOMAIN}}',
|
||||||
|
backendPort: {{BACKEND_PORT}},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PRODUCT_ID = manifest.productId;
|
||||||
|
|
||||||
|
export function getBackendURL(): string {
|
||||||
|
return process.env.NEXT_PUBLIC_BACKEND_URL ?? 'http://localhost:{{BACKEND_PORT}}';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default manifest;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── Mobile (Expo) templates ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const MOBILE_PACKAGE_JSON = `{
|
||||||
|
"name": "@{{PRODUCT_ID}}/mobile",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"main": "expo-router/entry",
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"android": "expo start --android",
|
||||||
|
"ios": "expo start --ios",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"expo": "~55.0.0",
|
||||||
|
"expo-router": "~5.0.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-native": "^0.79.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.2.0",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const MOBILE_APP_JSON = `{
|
||||||
|
"expo": {
|
||||||
|
"name": "{{DISPLAY_NAME}}",
|
||||||
|
"slug": "{{PRODUCT_ID}}",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scheme": "{{PRODUCT_ID}}",
|
||||||
|
"platforms": ["ios", "android"],
|
||||||
|
"ios": {
|
||||||
|
"bundleIdentifier": "com.bytelyst.{{PRODUCT_ID}}"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"package": "com.{{PRODUCT_ID}}.app"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const MOBILE_INDEX = `import { View, Text, StyleSheet } from 'react-native';
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.title}>{{DISPLAY_NAME}}</Text>
|
||||||
|
<Text style={styles.subtitle}>{{TAGLINE}}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 20 },
|
||||||
|
title: { fontSize: 28, fontWeight: '700', marginBottom: 8 },
|
||||||
|
subtitle: { fontSize: 16, color: '#666' },
|
||||||
|
});
|
||||||
|
`;
|
||||||
315
vendor/bytelyst/create-app/src/scaffolder.ts
vendored
Normal file
315
vendor/bytelyst/create-app/src/scaffolder.ts
vendored
Normal file
@ -0,0 +1,315 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* CLI Scaffolder — generates a fully wired ByteLyst product repo.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npx tsx scaffolder.ts # Interactive prompts
|
||||||
|
* npx tsx scaffolder.ts --from product.json # From existing manifest
|
||||||
|
* npx tsx scaffolder.ts --from product.json --dry-run # Preview
|
||||||
|
*
|
||||||
|
* @module @bytelyst/create-app/scaffolder
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import readline from 'node:readline';
|
||||||
|
import { renderTemplate, type TemplateVars } from './lib/template-engine.js';
|
||||||
|
import * as T from './lib/templates.js';
|
||||||
|
|
||||||
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ProductManifest {
|
||||||
|
productId: string;
|
||||||
|
displayName: string;
|
||||||
|
tagline: string;
|
||||||
|
domain: string;
|
||||||
|
backendPort: number;
|
||||||
|
primarySurface: string;
|
||||||
|
platforms: ('web' | 'mobile' | 'ios' | 'android')[];
|
||||||
|
features: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CliOptions {
|
||||||
|
from: string | null;
|
||||||
|
outDir: string | null;
|
||||||
|
dryRun: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CLI Arg Parsing ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function parseCliArgs(): CliOptions {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const options: CliOptions = { from: null, outDir: null, dryRun: false };
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const arg = args[i];
|
||||||
|
if (arg === '--from' || arg === '-f') options.from = args[++i];
|
||||||
|
else if (arg === '--out' || arg === '-o') options.outDir = args[++i];
|
||||||
|
else if (arg === '--dry-run' || arg === '-d') options.dryRun = true;
|
||||||
|
else if (arg === '--help' || arg === '-h') {
|
||||||
|
showHelp();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showHelp(): void {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`
|
||||||
|
@bytelyst/create-app — Product Repo Scaffolder
|
||||||
|
|
||||||
|
Generates a fully wired ByteLyst product repo with backend, web, and/or mobile.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
npx tsx scaffolder.ts # Interactive prompts
|
||||||
|
npx tsx scaffolder.ts --from product.json # From existing manifest
|
||||||
|
npx tsx scaffolder.ts --from product.json -d # Dry run
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--from, -f Path to existing product.json (skip prompts)
|
||||||
|
--out, -o Output directory (default: ./<productId>)
|
||||||
|
--dry-run, -d Preview files without writing
|
||||||
|
--help, -h Show this help
|
||||||
|
|
||||||
|
Interactive Prompts:
|
||||||
|
1. Product name + ID + tagline + domain
|
||||||
|
2. Backend port
|
||||||
|
3. Platform selection: web, mobile (Expo), iOS, Android
|
||||||
|
4. Feature selection: auth, billing, telemetry, flags, sync, push
|
||||||
|
|
||||||
|
Output:
|
||||||
|
<productId>/
|
||||||
|
├── shared/product.json
|
||||||
|
├── backend/ (if selected)
|
||||||
|
├── web/ (if selected)
|
||||||
|
├── mobile/ (if selected)
|
||||||
|
├── .gitignore
|
||||||
|
├── .env.example
|
||||||
|
├── README.md
|
||||||
|
└── AGENTS.md
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Interactive Prompts ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function createPrompt(): (question: string) => Promise<string> {
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (question: string) =>
|
||||||
|
new Promise(resolve => {
|
||||||
|
rl.question(question, answer => {
|
||||||
|
resolve(answer.trim());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gatherManifestInteractively(): Promise<ProductManifest> {
|
||||||
|
const ask = createPrompt();
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('\n📦 ByteLyst Product Scaffolder\n');
|
||||||
|
|
||||||
|
const displayName = (await ask('Product name (e.g., FlowMonk): ')) || 'MyApp';
|
||||||
|
const defaultId = displayName.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
|
const productId = (await ask(`Product ID [${defaultId}]: `)) || defaultId;
|
||||||
|
const tagline = (await ask('Tagline: ')) || `${displayName} — a ByteLyst product`;
|
||||||
|
const defaultDomain = `${productId}.app`;
|
||||||
|
const domain = (await ask(`Domain [${defaultDomain}]: `)) || defaultDomain;
|
||||||
|
const backendPort = parseInt(await ask('Backend port [4020]: ')) || 4020;
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('\nPlatforms (comma-separated: web, mobile, ios, android)');
|
||||||
|
const platformStr = (await ask('Platforms [web]: ')) || 'web';
|
||||||
|
const platforms = platformStr
|
||||||
|
.split(',')
|
||||||
|
.map(p => p.trim().toLowerCase()) as ProductManifest['platforms'];
|
||||||
|
|
||||||
|
const primarySurface = platforms.includes('web') ? 'web' : platforms[0];
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('\nFeatures (comma-separated: auth, billing, telemetry, flags, sync, push)');
|
||||||
|
const featureStr = (await ask('Features [auth,telemetry,flags]: ')) || 'auth,telemetry,flags';
|
||||||
|
const features = featureStr.split(',').map(f => f.trim().toLowerCase());
|
||||||
|
|
||||||
|
// Close readline
|
||||||
|
process.stdin.unref();
|
||||||
|
|
||||||
|
return {
|
||||||
|
productId,
|
||||||
|
displayName,
|
||||||
|
tagline,
|
||||||
|
domain,
|
||||||
|
backendPort,
|
||||||
|
primarySurface,
|
||||||
|
platforms,
|
||||||
|
features,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadManifestFromFile(filePath: string): Promise<ProductManifest> {
|
||||||
|
const raw = await fs.readFile(filePath, 'utf-8');
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
|
||||||
|
return {
|
||||||
|
productId: data.productId || 'myapp',
|
||||||
|
displayName: data.displayName || data.productId || 'MyApp',
|
||||||
|
tagline: data.tagline || `${data.displayName} — a ByteLyst product`,
|
||||||
|
domain: data.domain || `${data.productId}.app`,
|
||||||
|
backendPort: data.backendPort || 4020,
|
||||||
|
primarySurface: data.primarySurface || 'web',
|
||||||
|
platforms: data.platforms || ['web'],
|
||||||
|
features: data.features || ['auth', 'telemetry', 'flags'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── File Generator ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface GeneratedFile {
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateFiles(manifest: ProductManifest): GeneratedFile[] {
|
||||||
|
const vars: TemplateVars = {
|
||||||
|
PRODUCT_ID: manifest.productId,
|
||||||
|
DISPLAY_NAME: manifest.displayName,
|
||||||
|
TAGLINE: manifest.tagline,
|
||||||
|
DOMAIN: manifest.domain,
|
||||||
|
BACKEND_PORT: manifest.backendPort,
|
||||||
|
PRIMARY_SURFACE: manifest.primarySurface,
|
||||||
|
HAS_BACKEND: true, // Always generate backend
|
||||||
|
HAS_WEB: manifest.platforms.includes('web'),
|
||||||
|
HAS_MOBILE: manifest.platforms.includes('mobile'),
|
||||||
|
HAS_IOS: manifest.platforms.includes('ios'),
|
||||||
|
HAS_ANDROID: manifest.platforms.includes('android'),
|
||||||
|
HAS_AUTH: manifest.features.includes('auth'),
|
||||||
|
HAS_BILLING: manifest.features.includes('billing'),
|
||||||
|
HAS_TELEMETRY: manifest.features.includes('telemetry'),
|
||||||
|
HAS_FLAGS: manifest.features.includes('flags'),
|
||||||
|
HAS_SYNC: manifest.features.includes('sync'),
|
||||||
|
HAS_PUSH: manifest.features.includes('push'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const render = (tmpl: string) => renderTemplate(tmpl, vars);
|
||||||
|
const files: GeneratedFile[] = [];
|
||||||
|
|
||||||
|
// ── Root files
|
||||||
|
files.push({ path: 'shared/product.json', content: render(T.PRODUCT_JSON) });
|
||||||
|
files.push({ path: '.gitignore', content: render(T.GITIGNORE) });
|
||||||
|
files.push({ path: '.env.example', content: render(T.ENV_EXAMPLE) });
|
||||||
|
files.push({ path: 'README.md', content: render(T.README) });
|
||||||
|
|
||||||
|
// ── Backend (always generated)
|
||||||
|
files.push({ path: 'backend/package.json', content: render(T.BACKEND_PACKAGE_JSON) });
|
||||||
|
files.push({ path: 'backend/tsconfig.json', content: render(T.BACKEND_TSCONFIG) });
|
||||||
|
files.push({ path: 'backend/src/lib/config.ts', content: render(T.BACKEND_CONFIG) });
|
||||||
|
files.push({
|
||||||
|
path: 'backend/src/lib/product-config.ts',
|
||||||
|
content: render(T.BACKEND_PRODUCT_CONFIG),
|
||||||
|
});
|
||||||
|
files.push({ path: 'backend/src/lib/auth.ts', content: render(T.BACKEND_AUTH) });
|
||||||
|
files.push({
|
||||||
|
path: 'backend/src/lib/request-context.ts',
|
||||||
|
content: render(T.BACKEND_REQUEST_CONTEXT),
|
||||||
|
});
|
||||||
|
files.push({ path: 'backend/src/lib/errors.ts', content: render(T.BACKEND_ERRORS) });
|
||||||
|
files.push({ path: 'backend/src/lib/datastore.ts', content: render(T.BACKEND_DATASTORE) });
|
||||||
|
files.push({ path: 'backend/src/server.ts', content: render(T.BACKEND_SERVER) });
|
||||||
|
|
||||||
|
// ── Web (if selected)
|
||||||
|
if (manifest.platforms.includes('web')) {
|
||||||
|
files.push({ path: 'web/package.json', content: render(T.WEB_PACKAGE_JSON) });
|
||||||
|
files.push({ path: 'web/tsconfig.json', content: render(T.WEB_TSCONFIG) });
|
||||||
|
files.push({ path: 'web/next.config.ts', content: render(T.WEB_NEXT_CONFIG) });
|
||||||
|
files.push({ path: 'web/src/app/layout.tsx', content: render(T.WEB_LAYOUT) });
|
||||||
|
files.push({ path: 'web/src/app/page.tsx', content: render(T.WEB_PAGE) });
|
||||||
|
files.push({ path: 'web/src/lib/product-config.ts', content: render(T.WEB_PRODUCT_CONFIG) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mobile (if selected)
|
||||||
|
if (manifest.platforms.includes('mobile')) {
|
||||||
|
files.push({ path: 'mobile/package.json', content: render(T.MOBILE_PACKAGE_JSON) });
|
||||||
|
files.push({ path: 'mobile/app.json', content: render(T.MOBILE_APP_JSON) });
|
||||||
|
files.push({ path: 'mobile/src/app/index.tsx', content: render(T.MOBILE_INDEX) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const cliOpts = parseCliArgs();
|
||||||
|
|
||||||
|
const manifest = cliOpts.from
|
||||||
|
? await loadManifestFromFile(cliOpts.from)
|
||||||
|
: await gatherManifestInteractively();
|
||||||
|
|
||||||
|
const outDir = cliOpts.outDir || manifest.productId;
|
||||||
|
const outPath = path.resolve(outDir);
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`\n🚀 Scaffolding ${manifest.displayName}`);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(` Product ID: ${manifest.productId}`);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(` Output: ${outPath}`);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(` Platforms: ${manifest.platforms.join(', ')}`);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(` Features: ${manifest.features.join(', ')}`);
|
||||||
|
if (cliOpts.dryRun) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(' ⚠️ DRY RUN\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = generateFiles(manifest);
|
||||||
|
|
||||||
|
if (cliOpts.dryRun) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`📄 ${files.length} files would be generated:\n`);
|
||||||
|
for (const file of files) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`── ${file.path} ──────────────────────────────────────`);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(file.content);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`\n✨ Dry run complete. Re-run without --dry-run to write files.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write files
|
||||||
|
for (const file of files) {
|
||||||
|
const fullPath = path.join(outPath, file.path);
|
||||||
|
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
||||||
|
await fs.writeFile(fullPath, file.content, 'utf-8');
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(` ✅ ${file.path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`\n✨ ${manifest.displayName} scaffolded at ${outPath}`);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`\nNext steps:`);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(` cd ${outDir}/backend && npm install && npm run dev`);
|
||||||
|
if (manifest.platforms.includes('web')) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(` cd ${outDir}/web && npm install && npm run dev`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for testing
|
||||||
|
export { generateFiles, renderTemplate, type ProductManifest, type GeneratedFile };
|
||||||
|
|
||||||
|
main().catch(err => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('❌ Error:', err instanceof Error ? err.message : String(err));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
9
vendor/bytelyst/create-app/tsconfig.json
vendored
Normal file
9
vendor/bytelyst/create-app/tsconfig.json
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["dist", "src/**/*.test.ts"]
|
||||||
|
}
|
||||||
8
vendor/bytelyst/create-app/vitest.config.ts
vendored
Normal file
8
vendor/bytelyst/create-app/vitest.config.ts
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
passWithNoTests: true,
|
||||||
|
pool: 'forks',
|
||||||
|
},
|
||||||
|
});
|
||||||
39
vendor/bytelyst/dashboard-components/package.json
vendored
Normal file
39
vendor/bytelyst/dashboard-components/package.json
vendored
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "@bytelyst/dashboard-components",
|
||||||
|
"version": "0.1.5",
|
||||||
|
"description": "Shared React components for ByteLyst dashboards",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"test": "vitest run --pool forks",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18.0.0",
|
||||||
|
"react-dom": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"happy-dom": "^18.0.1",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/"
|
||||||
|
}
|
||||||
|
}
|
||||||
53
vendor/bytelyst/dashboard-components/src/EmptyState.tsx
vendored
Normal file
53
vendor/bytelyst/dashboard-components/src/EmptyState.tsx
vendored
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface EmptyStateProps {
|
||||||
|
icon?: ReactNode;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
action?: {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
};
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action,
|
||||||
|
className = '',
|
||||||
|
}: EmptyStateProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex flex-col items-center justify-center min-h-[300px] p-8 text-center ${className}`}
|
||||||
|
>
|
||||||
|
{icon && (
|
||||||
|
<div
|
||||||
|
className="w-16 h-16 rounded-full flex items-center justify-center mb-4"
|
||||||
|
style={{ backgroundColor: 'var(--color-muted, #f3f4f6)' }}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h3
|
||||||
|
className="text-lg font-medium mb-2"
|
||||||
|
style={{ color: 'var(--color-foreground, #111827)' }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<p className="max-w-sm mb-6" style={{ color: 'var(--color-muted-foreground, #6b7280)' }}>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
{action && (
|
||||||
|
<button
|
||||||
|
onClick={action.onClick}
|
||||||
|
className="px-4 py-2 rounded-lg transition-colors text-white"
|
||||||
|
style={{ backgroundColor: 'var(--color-primary, #2563eb)' }}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
vendor/bytelyst/dashboard-components/src/ErrorPage.tsx
vendored
Normal file
60
vendor/bytelyst/dashboard-components/src/ErrorPage.tsx
vendored
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface ErrorPageProps {
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
onRetry?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorPage({
|
||||||
|
title = 'Something went wrong',
|
||||||
|
message = 'An unexpected error occurred. Please try again.',
|
||||||
|
onRetry,
|
||||||
|
className = '',
|
||||||
|
}: ErrorPageProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col items-center justify-center min-h-[400px] p-8 ${className}`}>
|
||||||
|
<div
|
||||||
|
className="w-16 h-16 rounded-full flex items-center justify-center mb-4"
|
||||||
|
style={{ backgroundColor: 'var(--color-destructive-muted, #fef2f2)' }}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-8 h-8"
|
||||||
|
style={{ color: 'var(--color-destructive, #ef4444)' }}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2
|
||||||
|
className="text-xl font-semibold mb-2"
|
||||||
|
style={{ color: 'var(--color-foreground, #111827)' }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
className="text-center max-w-md mb-6"
|
||||||
|
style={{ color: 'var(--color-muted-foreground, #6b7280)' }}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
{onRetry && (
|
||||||
|
<button
|
||||||
|
onClick={onRetry}
|
||||||
|
className="px-4 py-2 rounded-lg transition-colors text-white"
|
||||||
|
style={{ backgroundColor: 'var(--color-primary, #2563eb)' }}
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
vendor/bytelyst/dashboard-components/src/LoadingSkeleton.tsx
vendored
Normal file
20
vendor/bytelyst/dashboard-components/src/LoadingSkeleton.tsx
vendored
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface LoadingSkeletonProps {
|
||||||
|
rows?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingSkeleton({ rows = 3, className = '' }: LoadingSkeletonProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className={`space-y-3 ${className}`} role="status" aria-label="Loading content">
|
||||||
|
{Array.from({ length: rows }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-12 rounded animate-pulse"
|
||||||
|
style={{ backgroundColor: 'var(--color-muted, #e5e7eb)' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
vendor/bytelyst/dashboard-components/src/LoadingSpinner.tsx
vendored
Normal file
40
vendor/bytelyst/dashboard-components/src/LoadingSpinner.tsx
vendored
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface LoadingSpinnerProps {
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingSpinner({ size = 'md', className = '' }: LoadingSpinnerProps): ReactNode {
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'w-4 h-4',
|
||||||
|
md: 'w-8 h-8',
|
||||||
|
lg: 'w-12 h-12',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${sizeClasses[size]} ${className}`} role="status" aria-label="Loading">
|
||||||
|
<svg
|
||||||
|
className="animate-spin"
|
||||||
|
style={{ color: 'var(--color-primary, currentColor)' }}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
vendor/bytelyst/dashboard-components/src/NotFoundPage.tsx
vendored
Normal file
61
vendor/bytelyst/dashboard-components/src/NotFoundPage.tsx
vendored
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface NotFoundPageProps {
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
statusCode?: string;
|
||||||
|
backLabel?: string;
|
||||||
|
backHref?: string;
|
||||||
|
onBack?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotFoundPage({
|
||||||
|
title = 'Page not found',
|
||||||
|
message = "The page you're looking for doesn't exist or has been moved.",
|
||||||
|
statusCode = '404',
|
||||||
|
backLabel = 'Go Back',
|
||||||
|
backHref,
|
||||||
|
onBack,
|
||||||
|
className = '',
|
||||||
|
}: NotFoundPageProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className={`flex min-h-screen items-center justify-center p-4 ${className}`}>
|
||||||
|
<div className="mx-auto max-w-md text-center">
|
||||||
|
<div
|
||||||
|
className="mb-4 text-6xl font-bold"
|
||||||
|
style={{ color: 'var(--color-muted-foreground, #9ca3af)' }}
|
||||||
|
>
|
||||||
|
{statusCode}
|
||||||
|
</div>
|
||||||
|
<h2
|
||||||
|
className="mb-2 text-xl font-semibold"
|
||||||
|
style={{ color: 'var(--color-foreground, #111827)' }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<p className="mb-6 text-sm" style={{ color: 'var(--color-muted-foreground, #6b7280)' }}>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
{(onBack || backHref) &&
|
||||||
|
(backHref ? (
|
||||||
|
<a
|
||||||
|
href={backHref}
|
||||||
|
className="inline-block rounded-md px-4 py-2 text-sm font-medium text-white transition-colors"
|
||||||
|
style={{ backgroundColor: 'var(--color-primary, #2563eb)' }}
|
||||||
|
>
|
||||||
|
{backLabel}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="rounded-md px-4 py-2 text-sm font-medium text-white transition-colors"
|
||||||
|
style={{ backgroundColor: 'var(--color-primary, #2563eb)' }}
|
||||||
|
>
|
||||||
|
{backLabel}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
vendor/bytelyst/dashboard-components/src/PageHeader.tsx
vendored
Normal file
55
vendor/bytelyst/dashboard-components/src/PageHeader.tsx
vendored
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface Breadcrumb {
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageHeaderProps {
|
||||||
|
title: string;
|
||||||
|
breadcrumbs?: Breadcrumb[];
|
||||||
|
actions?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageHeader({
|
||||||
|
title,
|
||||||
|
breadcrumbs,
|
||||||
|
actions,
|
||||||
|
className = '',
|
||||||
|
}: PageHeaderProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center justify-between mb-6 ${className}`}>
|
||||||
|
<div>
|
||||||
|
{breadcrumbs && breadcrumbs.length > 0 && (
|
||||||
|
<nav
|
||||||
|
className="flex items-center space-x-2 text-sm mb-2"
|
||||||
|
style={{ color: 'var(--color-muted-foreground, #6b7280)' }}
|
||||||
|
aria-label="Breadcrumb"
|
||||||
|
>
|
||||||
|
{breadcrumbs.map((crumb, index) => (
|
||||||
|
<span key={index} className="flex items-center">
|
||||||
|
{index > 0 && <span className="mx-2">/</span>}
|
||||||
|
{crumb.href ? (
|
||||||
|
<a
|
||||||
|
href={crumb.href}
|
||||||
|
className="hover:opacity-80 transition-opacity"
|
||||||
|
style={{ color: 'var(--color-muted-foreground, #6b7280)' }}
|
||||||
|
>
|
||||||
|
{crumb.label}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span>{crumb.label}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
<h1 className="text-2xl font-bold" style={{ color: 'var(--color-foreground, #111827)' }}>
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
{actions && <div className="flex items-center space-x-3">{actions}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
254
vendor/bytelyst/dashboard-components/src/components.test.tsx
vendored
Normal file
254
vendor/bytelyst/dashboard-components/src/components.test.tsx
vendored
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { LoadingSpinner } from './LoadingSpinner.js';
|
||||||
|
import { LoadingSkeleton } from './LoadingSkeleton.js';
|
||||||
|
import { EmptyState } from './EmptyState.js';
|
||||||
|
import { PageHeader } from './PageHeader.js';
|
||||||
|
import { ErrorPage } from './ErrorPage.js';
|
||||||
|
import { NotFoundPage } from './NotFoundPage.js';
|
||||||
|
|
||||||
|
describe('LoadingSpinner', () => {
|
||||||
|
it('renders with default size', () => {
|
||||||
|
render(<LoadingSpinner />);
|
||||||
|
const status = screen.getByRole('status');
|
||||||
|
expect(status).toBeDefined();
|
||||||
|
expect(status.className).toContain('w-8 h-8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with small size', () => {
|
||||||
|
render(<LoadingSpinner size="sm" />);
|
||||||
|
const status = screen.getByRole('status');
|
||||||
|
expect(status.className).toContain('w-4 h-4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with large size', () => {
|
||||||
|
render(<LoadingSpinner size="lg" />);
|
||||||
|
const status = screen.getByRole('status');
|
||||||
|
expect(status.className).toContain('w-12 h-12');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies custom className', () => {
|
||||||
|
render(<LoadingSpinner className="mt-4" />);
|
||||||
|
const status = screen.getByRole('status');
|
||||||
|
expect(status.className).toContain('mt-4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders SVG spinner element', () => {
|
||||||
|
render(<LoadingSpinner />);
|
||||||
|
const svg = screen.getByRole('status').querySelector('svg');
|
||||||
|
expect(svg).toBeDefined();
|
||||||
|
expect(svg!.classList.contains('animate-spin')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('LoadingSkeleton', () => {
|
||||||
|
it('renders default 3 rows', () => {
|
||||||
|
render(<LoadingSkeleton />);
|
||||||
|
const container = screen.getByRole('status');
|
||||||
|
const rows = container.querySelectorAll('.animate-pulse');
|
||||||
|
expect(rows.length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders custom number of rows', () => {
|
||||||
|
render(<LoadingSkeleton rows={5} />);
|
||||||
|
const container = screen.getByRole('status');
|
||||||
|
const rows = container.querySelectorAll('.animate-pulse');
|
||||||
|
expect(rows.length).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies custom className', () => {
|
||||||
|
render(<LoadingSkeleton className="my-8" />);
|
||||||
|
const container = screen.getByRole('status');
|
||||||
|
expect(container.className).toContain('my-8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders pulse-animated skeleton rows', () => {
|
||||||
|
render(<LoadingSkeleton rows={1} />);
|
||||||
|
const row = screen.getByRole('status').querySelector('.animate-pulse');
|
||||||
|
expect(row).toBeDefined();
|
||||||
|
expect(row!.classList.contains('rounded')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('EmptyState', () => {
|
||||||
|
it('renders title and description', () => {
|
||||||
|
render(<EmptyState title="No items" description="Create your first item." />);
|
||||||
|
expect(screen.getByText('No items')).toBeDefined();
|
||||||
|
expect(screen.getByText('Create your first item.')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders icon when provided', () => {
|
||||||
|
render(
|
||||||
|
<EmptyState
|
||||||
|
title="No items"
|
||||||
|
description="Create one."
|
||||||
|
icon={<span data-testid="icon">X</span>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('icon')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render icon container when not provided', () => {
|
||||||
|
const { container } = render(<EmptyState title="No items" description="Create one." />);
|
||||||
|
const iconWrapper = container.querySelector('.w-16.h-16');
|
||||||
|
expect(iconWrapper).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders action button and handles click', () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<EmptyState
|
||||||
|
title="No items"
|
||||||
|
description="Create one."
|
||||||
|
action={{ label: 'Create', onClick }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const button = screen.getByText('Create');
|
||||||
|
expect(button).toBeDefined();
|
||||||
|
fireEvent.click(button);
|
||||||
|
expect(onClick).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render action button when not provided', () => {
|
||||||
|
const { container } = render(<EmptyState title="No items" description="Create one." />);
|
||||||
|
const buttons = container.querySelectorAll('button');
|
||||||
|
expect(buttons.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with theme-aware structure', () => {
|
||||||
|
const { container } = render(<EmptyState title="Test" description="Desc" />);
|
||||||
|
const heading = container.querySelector('h3');
|
||||||
|
expect(heading).toBeDefined();
|
||||||
|
expect(heading!.textContent).toBe('Test');
|
||||||
|
const desc = container.querySelector('p');
|
||||||
|
expect(desc).toBeDefined();
|
||||||
|
expect(desc!.textContent).toBe('Desc');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PageHeader', () => {
|
||||||
|
it('renders title', () => {
|
||||||
|
render(<PageHeader title="Dashboard" />);
|
||||||
|
expect(screen.getByText('Dashboard')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders breadcrumbs', () => {
|
||||||
|
render(
|
||||||
|
<PageHeader title="Users" breadcrumbs={[{ label: 'Home', href: '/' }, { label: 'Users' }]} />
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Home')).toBeDefined();
|
||||||
|
expect(screen.getByLabelText('Breadcrumb')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders breadcrumb links with href', () => {
|
||||||
|
render(
|
||||||
|
<PageHeader
|
||||||
|
title="Detail"
|
||||||
|
breadcrumbs={[{ label: 'Home', href: '/' }, { label: 'Detail' }]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const link = screen.getByText('Home');
|
||||||
|
expect(link.tagName).toBe('A');
|
||||||
|
expect(link.getAttribute('href')).toBe('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders breadcrumb text without href', () => {
|
||||||
|
render(<PageHeader title="Detail" breadcrumbs={[{ label: 'Current' }]} />);
|
||||||
|
const text = screen.getByText('Current');
|
||||||
|
expect(text.tagName).toBe('SPAN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders actions', () => {
|
||||||
|
render(<PageHeader title="Test" actions={<button data-testid="action-btn">Action</button>} />);
|
||||||
|
expect(screen.getByTestId('action-btn')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render breadcrumb nav when empty', () => {
|
||||||
|
const { container } = render(<PageHeader title="Simple" />);
|
||||||
|
const nav = container.querySelector('nav');
|
||||||
|
expect(nav).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ErrorPage', () => {
|
||||||
|
it('renders with default props', () => {
|
||||||
|
render(<ErrorPage />);
|
||||||
|
expect(screen.getByText('Something went wrong')).toBeDefined();
|
||||||
|
expect(screen.getByText('An unexpected error occurred. Please try again.')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders custom title and message', () => {
|
||||||
|
render(<ErrorPage title="Server Error" message="The server is down." />);
|
||||||
|
expect(screen.getByText('Server Error')).toBeDefined();
|
||||||
|
expect(screen.getByText('The server is down.')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders retry button and handles click', () => {
|
||||||
|
const onRetry = vi.fn();
|
||||||
|
render(<ErrorPage onRetry={onRetry} />);
|
||||||
|
const button = screen.getByText('Try Again');
|
||||||
|
expect(button).toBeDefined();
|
||||||
|
fireEvent.click(button);
|
||||||
|
expect(onRetry).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render retry button when not provided', () => {
|
||||||
|
const { container } = render(<ErrorPage />);
|
||||||
|
const buttons = container.querySelectorAll('button');
|
||||||
|
expect(buttons.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders error icon and semantic structure', () => {
|
||||||
|
const { container } = render(<ErrorPage />);
|
||||||
|
const iconContainer = container.querySelector('.w-16');
|
||||||
|
expect(iconContainer).toBeDefined();
|
||||||
|
const svg = iconContainer!.querySelector('svg');
|
||||||
|
expect(svg).toBeDefined();
|
||||||
|
const heading = container.querySelector('h2');
|
||||||
|
expect(heading).toBeDefined();
|
||||||
|
expect(heading!.textContent).toBe('Something went wrong');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('NotFoundPage', () => {
|
||||||
|
it('renders with default props', () => {
|
||||||
|
render(<NotFoundPage />);
|
||||||
|
expect(screen.getByText('404')).toBeDefined();
|
||||||
|
expect(screen.getByText('Page not found')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders custom status code', () => {
|
||||||
|
render(<NotFoundPage statusCode="403" title="Forbidden" />);
|
||||||
|
expect(screen.getByText('403')).toBeDefined();
|
||||||
|
expect(screen.getByText('Forbidden')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders back button with onClick', () => {
|
||||||
|
const onBack = vi.fn();
|
||||||
|
render(<NotFoundPage onBack={onBack} />);
|
||||||
|
const button = screen.getByText('Go Back');
|
||||||
|
fireEvent.click(button);
|
||||||
|
expect(onBack).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders back link with href', () => {
|
||||||
|
render(<NotFoundPage backHref="/" backLabel="Go Home" />);
|
||||||
|
const link = screen.getByText('Go Home');
|
||||||
|
expect(link.tagName).toBe('A');
|
||||||
|
expect(link.getAttribute('href')).toBe('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render button when neither onBack nor backHref provided', () => {
|
||||||
|
const { container } = render(<NotFoundPage />);
|
||||||
|
const buttons = container.querySelectorAll('button');
|
||||||
|
const links = container.querySelectorAll('a');
|
||||||
|
expect(buttons.length).toBe(0);
|
||||||
|
expect(links.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('custom backLabel is used', () => {
|
||||||
|
render(<NotFoundPage onBack={() => {}} backLabel="Return" />);
|
||||||
|
expect(screen.getByText('Return')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
15
vendor/bytelyst/dashboard-components/src/index.ts
vendored
Normal file
15
vendor/bytelyst/dashboard-components/src/index.ts
vendored
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* @bytelyst/dashboard-components
|
||||||
|
*
|
||||||
|
* Shared React components for ByteLyst dashboards.
|
||||||
|
* All components are theme-aware — they read CSS custom properties
|
||||||
|
* (--color-primary, --color-foreground, --color-muted, etc.)
|
||||||
|
* with sensible fallback defaults.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { ErrorPage, type ErrorPageProps } from './ErrorPage.js';
|
||||||
|
export { NotFoundPage, type NotFoundPageProps } from './NotFoundPage.js';
|
||||||
|
export { LoadingSpinner, type LoadingSpinnerProps } from './LoadingSpinner.js';
|
||||||
|
export { LoadingSkeleton, type LoadingSkeletonProps } from './LoadingSkeleton.js';
|
||||||
|
export { EmptyState, type EmptyStateProps } from './EmptyState.js';
|
||||||
|
export { PageHeader, type PageHeaderProps, type Breadcrumb } from './PageHeader.js';
|
||||||
13
vendor/bytelyst/dashboard-components/tsconfig.json
vendored
Normal file
13
vendor/bytelyst/dashboard-components/tsconfig.json
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
|
||||||
|
}
|
||||||
10
vendor/bytelyst/dashboard-components/vitest.config.ts
vendored
Normal file
10
vendor/bytelyst/dashboard-components/vitest.config.ts
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'happy-dom',
|
||||||
|
passWithNoTests: true,
|
||||||
|
pool: 'forks',
|
||||||
|
},
|
||||||
|
});
|
||||||
39
vendor/bytelyst/dashboard-shell/package.json
vendored
Normal file
39
vendor/bytelyst/dashboard-shell/package.json
vendored
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "@bytelyst/dashboard-shell",
|
||||||
|
"version": "0.1.5",
|
||||||
|
"description": "Configurable Next.js dashboard layout with sidebar, profile, billing, and settings pages",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"test": "vitest run --pool forks",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18.0.0",
|
||||||
|
"react-dom": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"happy-dom": "^18.0.1",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/"
|
||||||
|
}
|
||||||
|
}
|
||||||
189
vendor/bytelyst/dashboard-shell/src/BillingPage.tsx
vendored
Normal file
189
vendor/bytelyst/dashboard-shell/src/BillingPage.tsx
vendored
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { BillingPageProps } from './types.js';
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
active: 'var(--color-success, #16a34a)',
|
||||||
|
trialing: 'var(--color-warning, #d97706)',
|
||||||
|
past_due: 'var(--color-destructive, #dc2626)',
|
||||||
|
canceled: 'var(--color-muted-foreground, #6b7280)',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BillingPage({
|
||||||
|
currentPlan = 'Free',
|
||||||
|
status = 'active',
|
||||||
|
trialEndsAt,
|
||||||
|
onManageBilling,
|
||||||
|
plans = [],
|
||||||
|
}: BillingPageProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<div data-testid="bl-shell-billing-page" style={{ maxWidth: 800 }}>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 700,
|
||||||
|
marginBottom: 24,
|
||||||
|
color: 'var(--color-foreground, #111827)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Billing
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Current plan card */}
|
||||||
|
<div
|
||||||
|
data-testid="bl-billing-current"
|
||||||
|
style={{
|
||||||
|
padding: 24,
|
||||||
|
borderRadius: 12,
|
||||||
|
border: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
|
||||||
|
marginBottom: 32,
|
||||||
|
background: 'var(--color-surface, #fff)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: 'var(--color-muted-foreground, #6b7280)',
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Current Plan
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{ fontSize: 24, fontWeight: 700, color: 'var(--color-foreground, #111827)' }}
|
||||||
|
>
|
||||||
|
{currentPlan}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
data-testid="bl-billing-status"
|
||||||
|
style={{
|
||||||
|
padding: '4px 12px',
|
||||||
|
borderRadius: 20,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: statusColors[status] || statusColors.active,
|
||||||
|
background: `color-mix(in srgb, ${statusColors[status] || statusColors.active} 10%, transparent)`,
|
||||||
|
border: `1px solid color-mix(in srgb, ${statusColors[status] || statusColors.active} 30%, transparent)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{trialEndsAt && (
|
||||||
|
<div
|
||||||
|
data-testid="bl-billing-trial"
|
||||||
|
style={{ fontSize: 14, color: 'var(--color-warning, #d97706)', marginBottom: 12 }}
|
||||||
|
>
|
||||||
|
Trial ends: {trialEndsAt}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onManageBilling && (
|
||||||
|
<button
|
||||||
|
data-testid="bl-billing-manage"
|
||||||
|
onClick={onManageBilling}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: 'var(--color-surface, #fff)',
|
||||||
|
color: 'var(--color-foreground, #111827)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Manage Billing
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Plan comparison */}
|
||||||
|
{plans.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 600,
|
||||||
|
marginBottom: 16,
|
||||||
|
color: 'var(--color-foreground, #111827)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Available Plans
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
data-testid="bl-billing-plans"
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: `repeat(${Math.min(plans.length, 3)}, 1fr)`,
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{plans.map(plan => (
|
||||||
|
<div
|
||||||
|
key={plan.name}
|
||||||
|
data-testid={`bl-billing-plan-${plan.name.toLowerCase()}`}
|
||||||
|
style={{
|
||||||
|
padding: 24,
|
||||||
|
borderRadius: 12,
|
||||||
|
border: plan.current
|
||||||
|
? '2px solid var(--bl-shell-accent, var(--color-primary, #2563eb))'
|
||||||
|
: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
|
||||||
|
background: 'var(--color-surface, #fff)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 600, marginBottom: 4 }}>{plan.name}</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 700,
|
||||||
|
marginBottom: 16,
|
||||||
|
color: 'var(--bl-shell-accent, var(--color-primary, #2563eb))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{plan.price}
|
||||||
|
</div>
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
||||||
|
{plan.features.map(f => (
|
||||||
|
<li
|
||||||
|
key={f}
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
padding: '4px 0',
|
||||||
|
color: 'var(--color-muted-foreground, #6b7280)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✓ {f}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{plan.current && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 16,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--bl-shell-accent, var(--color-primary, #2563eb))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Current Plan
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
vendor/bytelyst/dashboard-shell/src/DashboardShell.tsx
vendored
Normal file
73
vendor/bytelyst/dashboard-shell/src/DashboardShell.tsx
vendored
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { useState, type ReactNode } from 'react';
|
||||||
|
import type { DashboardShellProps } from './types.js';
|
||||||
|
import { Sidebar } from './Sidebar.js';
|
||||||
|
import { TopBar } from './TopBar.js';
|
||||||
|
|
||||||
|
export function DashboardShell({
|
||||||
|
productName,
|
||||||
|
logo,
|
||||||
|
version,
|
||||||
|
nav,
|
||||||
|
pathname: externalPathname,
|
||||||
|
user,
|
||||||
|
features = {},
|
||||||
|
onSignOut,
|
||||||
|
onNavigate,
|
||||||
|
sidebarFooter,
|
||||||
|
topBarActions,
|
||||||
|
children,
|
||||||
|
}: DashboardShellProps): ReactNode {
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
|
|
||||||
|
// Use external pathname or default to '/'
|
||||||
|
const pathname = externalPathname ?? '/';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="bl-dashboard-shell"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
minHeight: '100vh',
|
||||||
|
background: 'var(--bl-shell-bg, var(--color-background, #f9fafb))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Sidebar */}
|
||||||
|
<Sidebar
|
||||||
|
productName={productName}
|
||||||
|
logo={logo}
|
||||||
|
version={version}
|
||||||
|
nav={nav}
|
||||||
|
pathname={pathname}
|
||||||
|
features={features}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
footer={sidebarFooter}
|
||||||
|
collapsed={sidebarCollapsed}
|
||||||
|
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main content area */}
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
|
{/* Top bar */}
|
||||||
|
<TopBar
|
||||||
|
user={user}
|
||||||
|
features={features}
|
||||||
|
onSignOut={onSignOut}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
actions={topBarActions}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Page content */}
|
||||||
|
<main
|
||||||
|
data-testid="bl-shell-main"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '24px 32px',
|
||||||
|
overflow: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
180
vendor/bytelyst/dashboard-shell/src/ProfilePage.tsx
vendored
Normal file
180
vendor/bytelyst/dashboard-shell/src/ProfilePage.tsx
vendored
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
import { useState, type ReactNode } from 'react';
|
||||||
|
import type { ProfilePageProps } from './types.js';
|
||||||
|
|
||||||
|
export function ProfilePage({
|
||||||
|
user,
|
||||||
|
onUpdateProfile,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
success,
|
||||||
|
}: ProfilePageProps): ReactNode {
|
||||||
|
const [name, setName] = useState(user.name);
|
||||||
|
const [email, setEmail] = useState(user.email);
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (onUpdateProfile) onUpdateProfile({ name, email });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="bl-shell-profile-page" style={{ maxWidth: 600 }}>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 700,
|
||||||
|
marginBottom: 24,
|
||||||
|
color: 'var(--color-foreground, #111827)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Profile
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div data-testid="bl-profile-error" style={alertStyle('var(--color-destructive, #dc2626)')}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<div data-testid="bl-profile-success" style={alertStyle('var(--color-success, #16a34a)')}>
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Avatar */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 32 }}>
|
||||||
|
<div
|
||||||
|
data-testid="bl-profile-avatar"
|
||||||
|
style={{
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'var(--bl-shell-accent, var(--color-primary, #2563eb))',
|
||||||
|
color: '#fff',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={user.avatarUrl}
|
||||||
|
alt={user.name}
|
||||||
|
style={{ width: 64, height: 64, borderRadius: '50%' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
user.name
|
||||||
|
.split(' ')
|
||||||
|
.map(w => w[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 600 }}>{user.name}</div>
|
||||||
|
<div style={{ fontSize: 14, color: 'var(--color-muted-foreground, #6b7280)' }}>
|
||||||
|
{user.email}
|
||||||
|
</div>
|
||||||
|
{user.role && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--color-muted-foreground, #6b7280)',
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Role: {user.role}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle} htmlFor="bl-profile-name">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="bl-profile-name"
|
||||||
|
data-testid="bl-profile-name"
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
required
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle} htmlFor="bl-profile-email">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="bl-profile-email"
|
||||||
|
data-testid="bl-profile-email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{onUpdateProfile && (
|
||||||
|
<button
|
||||||
|
data-testid="bl-profile-submit"
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: 'none',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||||
|
background: 'var(--bl-shell-accent, var(--color-primary, #2563eb))',
|
||||||
|
color: '#fff',
|
||||||
|
opacity: isLoading ? 0.6 : 1,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
display: 'block',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: 6,
|
||||||
|
color: 'var(--color-foreground, #111827)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 12px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
|
||||||
|
fontSize: 14,
|
||||||
|
background: 'var(--color-surface, #fff)',
|
||||||
|
color: 'var(--color-foreground, #111827)',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
};
|
||||||
|
|
||||||
|
function alertStyle(color: string): React.CSSProperties {
|
||||||
|
return {
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 16,
|
||||||
|
fontSize: 14,
|
||||||
|
color,
|
||||||
|
background: `color-mix(in srgb, ${color} 10%, transparent)`,
|
||||||
|
border: `1px solid color-mix(in srgb, ${color} 30%, transparent)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
71
vendor/bytelyst/dashboard-shell/src/SettingsPage.tsx
vendored
Normal file
71
vendor/bytelyst/dashboard-shell/src/SettingsPage.tsx
vendored
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { SettingsPageProps } from './types.js';
|
||||||
|
|
||||||
|
export function SettingsPage({ productName, sections = [] }: SettingsPageProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<div data-testid="bl-shell-settings-page" style={{ maxWidth: 700 }}>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 700,
|
||||||
|
marginBottom: 24,
|
||||||
|
color: 'var(--color-foreground, #111827)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{sections.length === 0 && (
|
||||||
|
<div
|
||||||
|
data-testid="bl-settings-empty"
|
||||||
|
style={{
|
||||||
|
padding: 32,
|
||||||
|
textAlign: 'center',
|
||||||
|
color: 'var(--color-muted-foreground, #6b7280)',
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No settings configured for {productName}.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sections.map((section, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
data-testid={`bl-settings-section-${i}`}
|
||||||
|
style={{
|
||||||
|
padding: 24,
|
||||||
|
borderRadius: 12,
|
||||||
|
border: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
|
||||||
|
marginBottom: 16,
|
||||||
|
background: 'var(--color-surface, #fff)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 600,
|
||||||
|
marginBottom: 4,
|
||||||
|
color: 'var(--color-foreground, #111827)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{section.title}
|
||||||
|
</h2>
|
||||||
|
{section.description && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: 'var(--color-muted-foreground, #6b7280)',
|
||||||
|
marginBottom: 16,
|
||||||
|
marginTop: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{section.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div>{section.content}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
236
vendor/bytelyst/dashboard-shell/src/Sidebar.tsx
vendored
Normal file
236
vendor/bytelyst/dashboard-shell/src/Sidebar.tsx
vendored
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { SidebarProps, NavItem, NavSection } from './types.js';
|
||||||
|
|
||||||
|
function isNavSections(nav: NavItem[] | NavSection[]): nav is NavSection[] {
|
||||||
|
return nav.length > 0 && 'items' in nav[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavLink({
|
||||||
|
item,
|
||||||
|
active,
|
||||||
|
collapsed,
|
||||||
|
onNavigate,
|
||||||
|
}: {
|
||||||
|
item: NavItem;
|
||||||
|
active: boolean;
|
||||||
|
collapsed: boolean;
|
||||||
|
onNavigate?: (href: string) => void;
|
||||||
|
}): ReactNode {
|
||||||
|
if (item.hidden) return null;
|
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
|
if (onNavigate) {
|
||||||
|
e.preventDefault();
|
||||||
|
onNavigate(item.href);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
onClick={handleClick}
|
||||||
|
data-testid={`bl-nav-${item.href.replace(/\//g, '-').replace(/^-/, '')}`}
|
||||||
|
title={collapsed ? item.label : undefined}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
padding: collapsed ? '10px 0' : '10px 12px',
|
||||||
|
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: active ? 600 : 400,
|
||||||
|
color: active
|
||||||
|
? 'var(--bl-shell-nav-active-text, var(--color-foreground, #111827))'
|
||||||
|
: 'var(--bl-shell-nav-text, var(--color-muted-foreground, #6b7280))',
|
||||||
|
background: active
|
||||||
|
? 'var(--bl-shell-nav-active-bg, var(--color-muted, #f3f4f6))'
|
||||||
|
: 'transparent',
|
||||||
|
textDecoration: 'none',
|
||||||
|
transition: 'background 0.15s, color 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.icon && (
|
||||||
|
<span style={{ fontSize: 16, width: 20, textAlign: 'center', flexShrink: 0 }}>
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!collapsed && <span style={{ flex: 1 }}>{item.label}</span>}
|
||||||
|
{!collapsed && item.badge !== undefined && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: 10,
|
||||||
|
background: 'var(--bl-shell-badge-bg, var(--color-primary, #2563eb))',
|
||||||
|
color: 'var(--bl-shell-badge-text, #fff)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar({
|
||||||
|
productName,
|
||||||
|
logo,
|
||||||
|
version,
|
||||||
|
nav,
|
||||||
|
pathname,
|
||||||
|
features = {},
|
||||||
|
onNavigate,
|
||||||
|
footer,
|
||||||
|
collapsed = false,
|
||||||
|
onToggleCollapse,
|
||||||
|
}: SidebarProps): ReactNode {
|
||||||
|
const sections: NavSection[] = isNavSections(nav) ? nav : [{ items: nav }];
|
||||||
|
|
||||||
|
const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/');
|
||||||
|
|
||||||
|
// Add built-in settings nav if enabled and not already present
|
||||||
|
const hasSettings = features.settings !== false;
|
||||||
|
const allItems = sections.flatMap(s => s.items);
|
||||||
|
const settingsExists = allItems.some(i => i.href === '/settings');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
data-testid="bl-shell-sidebar"
|
||||||
|
style={{
|
||||||
|
width: collapsed ? 64 : 220,
|
||||||
|
minHeight: '100vh',
|
||||||
|
background: 'var(--bl-shell-sidebar-bg, var(--color-surface, #fff))',
|
||||||
|
borderRight: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
|
||||||
|
padding: '16px 0',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flexShrink: 0,
|
||||||
|
transition: 'width 0.2s ease',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: collapsed ? '0 8px 16px' : '0 16px 16px',
|
||||||
|
borderBottom: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
|
||||||
|
marginBottom: 8,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ overflow: 'hidden', flex: 1 }}>
|
||||||
|
{logo ? (
|
||||||
|
<div data-testid="bl-shell-logo">{logo}</div>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
data-testid="bl-shell-product-name"
|
||||||
|
style={{
|
||||||
|
fontSize: collapsed ? 14 : 18,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--bl-shell-accent, var(--color-primary, #2563eb))',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{collapsed ? productName.charAt(0) : productName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{onToggleCollapse && (
|
||||||
|
<button
|
||||||
|
data-testid="bl-shell-collapse-toggle"
|
||||||
|
onClick={onToggleCollapse}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 4,
|
||||||
|
fontSize: 16,
|
||||||
|
color: 'var(--bl-shell-nav-text, var(--color-muted-foreground, #6b7280))',
|
||||||
|
}}
|
||||||
|
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||||
|
>
|
||||||
|
{collapsed ? '▸' : '◂'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav
|
||||||
|
data-testid="bl-shell-nav"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 2,
|
||||||
|
padding: collapsed ? '0 8px' : '0 8px',
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sections.map((section, si) => (
|
||||||
|
<div key={si}>
|
||||||
|
{section.title && !collapsed && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '12px 12px 4px',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
color: 'var(--bl-shell-nav-text, var(--color-muted-foreground, #6b7280))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{section.title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{section.items.map(item => (
|
||||||
|
<NavLink
|
||||||
|
key={item.href}
|
||||||
|
item={item}
|
||||||
|
active={isActive(item.href)}
|
||||||
|
collapsed={collapsed}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Built-in settings link */}
|
||||||
|
{hasSettings && !settingsExists && (
|
||||||
|
<div style={{ marginTop: 'auto' }}>
|
||||||
|
<NavLink
|
||||||
|
item={{ href: '/settings', label: 'Settings', icon: '⚙' }}
|
||||||
|
active={isActive('/settings')}
|
||||||
|
collapsed={collapsed}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div
|
||||||
|
data-testid="bl-shell-sidebar-footer"
|
||||||
|
style={{
|
||||||
|
marginTop: 'auto',
|
||||||
|
padding: collapsed ? '12px 8px' : '12px 16px',
|
||||||
|
borderTop: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--bl-shell-nav-text, var(--color-muted-foreground, #6b7280))',
|
||||||
|
textAlign: collapsed ? 'center' : 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{footer
|
||||||
|
? footer
|
||||||
|
: version
|
||||||
|
? collapsed
|
||||||
|
? `v${version}`
|
||||||
|
: `${productName} v${version}`
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
244
vendor/bytelyst/dashboard-shell/src/TopBar.tsx
vendored
Normal file
244
vendor/bytelyst/dashboard-shell/src/TopBar.tsx
vendored
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
import { useState, type ReactNode } from 'react';
|
||||||
|
import type { TopBarProps } from './types.js';
|
||||||
|
|
||||||
|
export function TopBar({
|
||||||
|
user,
|
||||||
|
features = {},
|
||||||
|
onSignOut,
|
||||||
|
onNavigate,
|
||||||
|
actions,
|
||||||
|
onToggleSidebar,
|
||||||
|
}: TopBarProps): ReactNode {
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleNav = (href: string) => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
if (onNavigate) onNavigate(href);
|
||||||
|
};
|
||||||
|
|
||||||
|
const initials = user
|
||||||
|
? user.name
|
||||||
|
.split(' ')
|
||||||
|
.map(w => w[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2)
|
||||||
|
: '?';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
data-testid="bl-shell-topbar"
|
||||||
|
style={{
|
||||||
|
height: 56,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '0 24px',
|
||||||
|
borderBottom: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
|
||||||
|
background: 'var(--bl-shell-topbar-bg, var(--color-surface, #fff))',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Left: mobile hamburger */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
{onToggleSidebar && (
|
||||||
|
<button
|
||||||
|
data-testid="bl-shell-hamburger"
|
||||||
|
onClick={onToggleSidebar}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 20,
|
||||||
|
padding: 4,
|
||||||
|
color: 'var(--bl-shell-nav-text, var(--color-muted-foreground, #6b7280))',
|
||||||
|
}}
|
||||||
|
aria-label="Toggle sidebar"
|
||||||
|
>
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: actions + user menu */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||||
|
{actions}
|
||||||
|
|
||||||
|
{features.notifications && (
|
||||||
|
<button
|
||||||
|
data-testid="bl-shell-notifications"
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 18,
|
||||||
|
padding: 4,
|
||||||
|
color: 'var(--bl-shell-nav-text, var(--color-muted-foreground, #6b7280))',
|
||||||
|
}}
|
||||||
|
aria-label="Notifications"
|
||||||
|
>
|
||||||
|
🔔
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<button
|
||||||
|
data-testid="bl-shell-user-menu-trigger"
|
||||||
|
onClick={() => setMenuOpen(!menuOpen)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={user.avatarUrl}
|
||||||
|
alt={user.name}
|
||||||
|
style={{ width: 32, height: 32, borderRadius: '50%' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
data-testid="bl-shell-user-avatar"
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'var(--bl-shell-accent, var(--color-primary, #2563eb))',
|
||||||
|
color: '#fff',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: 'var(--color-foreground, #111827)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.name}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 10 }}>▾</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{menuOpen && (
|
||||||
|
<div
|
||||||
|
data-testid="bl-shell-user-menu"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
top: '100%',
|
||||||
|
marginTop: 4,
|
||||||
|
minWidth: 180,
|
||||||
|
background: 'var(--bl-shell-topbar-bg, var(--color-surface, #fff))',
|
||||||
|
border: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
|
||||||
|
borderRadius: 8,
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
||||||
|
zIndex: 50,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderBottom: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600 }}>{user.name}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-muted-foreground, #6b7280)' }}>
|
||||||
|
{user.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{features.profile !== false && (
|
||||||
|
<a
|
||||||
|
data-testid="bl-shell-menu-profile"
|
||||||
|
href="/profile"
|
||||||
|
onClick={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleNav('/profile');
|
||||||
|
}}
|
||||||
|
style={menuItemStyle}
|
||||||
|
>
|
||||||
|
Profile
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{features.billing && (
|
||||||
|
<a
|
||||||
|
data-testid="bl-shell-menu-billing"
|
||||||
|
href="/billing"
|
||||||
|
onClick={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleNav('/billing');
|
||||||
|
}}
|
||||||
|
style={menuItemStyle}
|
||||||
|
>
|
||||||
|
Billing
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{features.settings !== false && (
|
||||||
|
<a
|
||||||
|
data-testid="bl-shell-menu-settings"
|
||||||
|
href="/settings"
|
||||||
|
onClick={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleNav('/settings');
|
||||||
|
}}
|
||||||
|
style={menuItemStyle}
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onSignOut && (
|
||||||
|
<button
|
||||||
|
data-testid="bl-shell-menu-signout"
|
||||||
|
onClick={() => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
onSignOut();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
...menuItemStyle,
|
||||||
|
width: '100%',
|
||||||
|
textAlign: 'left',
|
||||||
|
borderTop: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
|
||||||
|
color: 'var(--color-destructive, #dc2626)',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
borderTopStyle: 'solid',
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: 'var(--bl-shell-border, var(--color-border, #e5e7eb))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuItemStyle: React.CSSProperties = {
|
||||||
|
display: 'block',
|
||||||
|
padding: '10px 16px',
|
||||||
|
fontSize: 14,
|
||||||
|
color: 'var(--color-foreground, #111827)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user