Compare commits
7 Commits
76f26430ba
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c7255b759 | |||
| 6749b89bbd | |||
| 8a9248d337 | |||
| 91ffbadcae | |||
| 33d35d91e6 | |||
| f2940e55bd | |||
| 4e788312ab |
File diff suppressed because one or more lines are too long
16
package-lock.json
generated
16
package-lock.json
generated
@@ -9,11 +9,13 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"@types/zxcvbn": "^4.4.5",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^6.28.0",
|
"react-router-dom": "^6.28.0",
|
||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2",
|
||||||
|
"zxcvbn": "^4.4.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
@@ -1886,6 +1888,12 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/zxcvbn": {
|
||||||
|
"version": "4.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/zxcvbn/-/zxcvbn-4.4.5.tgz",
|
||||||
|
"integrity": "sha512-FZJgC5Bxuqg7Rhsm/bx6gAruHHhDQ55r+s0JhDh8CQ16fD7NsJJ+p8YMMQDhSQoIrSmjpqqYWA96oQVMNkjRyA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.66.0",
|
"version": "8.66.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz",
|
||||||
@@ -8032,6 +8040,12 @@
|
|||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zxcvbn": {
|
||||||
|
"version": "4.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/zxcvbn/-/zxcvbn-4.4.2.tgz",
|
||||||
|
"integrity": "sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ==",
|
||||||
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"@types/zxcvbn": "^4.4.5",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^6.28.0",
|
"react-router-dom": "^6.28.0",
|
||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2",
|
||||||
|
"zxcvbn": "^4.4.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ describe('api client', () => {
|
|||||||
mockFetch(async (input) => {
|
mockFetch(async (input) => {
|
||||||
calls++;
|
calls++;
|
||||||
const url = urlOf(input);
|
const url = urlOf(input);
|
||||||
if (url.startsWith('/auth/refresh')) {
|
if (url.startsWith('/api/auth/refresh')) {
|
||||||
return jsonResponse({ access_token: 'new', refresh_token: 'new-r', expires_at: 9999999999 });
|
return jsonResponse({ access_token: 'new', refresh_token: 'new-r', expires_at: 9999999999 });
|
||||||
}
|
}
|
||||||
if (calls === 1) return jsonResponse({ detail: 'expired' }, 401);
|
if (calls === 1) return jsonResponse({ detail: 'expired' }, 401);
|
||||||
@@ -113,7 +113,7 @@ describe('api client', () => {
|
|||||||
setStoredTokens({ access_token: ACCESS, refresh_token: REFRESH, expires_at: 9999999999 });
|
setStoredTokens({ access_token: ACCESS, refresh_token: REFRESH, expires_at: 9999999999 });
|
||||||
mockFetch(async (input) => {
|
mockFetch(async (input) => {
|
||||||
const url = urlOf(input);
|
const url = urlOf(input);
|
||||||
if (url.startsWith('/auth/refresh')) return jsonResponse({ detail: 'invalid' }, 401);
|
if (url.startsWith('/api/auth/refresh')) return jsonResponse({ detail: 'invalid' }, 401);
|
||||||
return jsonResponse({ detail: 'expired' }, 401);
|
return jsonResponse({ detail: 'expired' }, 401);
|
||||||
});
|
});
|
||||||
await expect(api.getMe()).rejects.toMatchObject({ status: 401 });
|
await expect(api.getMe()).rejects.toMatchObject({ status: 401 });
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useAuthStore } from '../store/useStore';
|
import { useAuthStore } from '../store/useStore';
|
||||||
import type {
|
import type {
|
||||||
|
AuthenticatedUser,
|
||||||
CheckoutResponse,
|
CheckoutResponse,
|
||||||
Device,
|
Device,
|
||||||
|
LinkCodeResponse,
|
||||||
LoginPayload,
|
LoginPayload,
|
||||||
LoginResponse,
|
|
||||||
OperationResult,
|
OperationResult,
|
||||||
OrderDetails,
|
OrderDetails,
|
||||||
PricingPlans,
|
PricingPlans,
|
||||||
@@ -87,7 +88,7 @@ async function refreshTokens(): Promise<boolean> {
|
|||||||
refreshing = (async () => {
|
refreshing = (async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/auth/refresh?refresh_token=${encodeURIComponent(refresh)}&iss=credentials`,
|
`/api/auth/refresh?refresh_token=${encodeURIComponent(refresh)}&iss=credentials`,
|
||||||
{ method: 'POST' },
|
{ method: 'POST' },
|
||||||
);
|
);
|
||||||
if (!res.ok) return false;
|
if (!res.ok) return false;
|
||||||
@@ -154,31 +155,34 @@ async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
|||||||
|
|
||||||
/* ── Public API ───────────────────────────────────────────── */
|
/* ── Public API ───────────────────────────────────────────── */
|
||||||
export const api = {
|
export const api = {
|
||||||
signup(payload: SignupPayload): Promise<LoginResponse> {
|
signup(payload: SignupPayload): Promise<AuthenticatedUser> {
|
||||||
return request<LoginResponse>('/auth/signup', { method: 'POST', body: payload, auth: false });
|
return request<AuthenticatedUser>('/api/auth/signup', { method: 'POST', body: payload, auth: false });
|
||||||
},
|
},
|
||||||
login(payload: LoginPayload): Promise<LoginResponse> {
|
login(payload: LoginPayload): Promise<AuthenticatedUser> {
|
||||||
return request<LoginResponse>('/auth/login', { method: 'POST', body: payload, auth: false });
|
return request<AuthenticatedUser>('/api/auth/login', { method: 'POST', body: payload, auth: false });
|
||||||
},
|
},
|
||||||
getPlans(): Promise<PricingPlans> {
|
getPlans(): Promise<PricingPlans> {
|
||||||
return request<PricingPlans>('/plans/', { auth: false });
|
return request<PricingPlans>('/api/plans/', { auth: false });
|
||||||
},
|
},
|
||||||
checkout(order: OrderDetails): Promise<CheckoutResponse> {
|
checkout(order: OrderDetails): Promise<CheckoutResponse> {
|
||||||
return request<CheckoutResponse>('/orders/checkout', { method: 'POST', body: order });
|
return request<CheckoutResponse>('/api/orders/checkout', { method: 'POST', body: order });
|
||||||
},
|
},
|
||||||
getMe(): Promise<UserInfo> {
|
getMe(): Promise<UserInfo> {
|
||||||
return request<UserInfo>('/users/me');
|
return request<UserInfo>('/api/users/me');
|
||||||
},
|
},
|
||||||
getSubscription(): Promise<SubscriptionData> {
|
getSubscription(): Promise<SubscriptionData> {
|
||||||
return request<SubscriptionData>('/users/subscription');
|
return request<SubscriptionData>('/api/users/subscription');
|
||||||
},
|
},
|
||||||
getDevices(): Promise<Device[]> {
|
getDevices(): Promise<Device[]> {
|
||||||
return request<Device[]>('/users/subscription/hwid');
|
return request<Device[]>('/api/users/subscription/hwid');
|
||||||
},
|
},
|
||||||
deleteDevice(hwid: string): Promise<OperationResult> {
|
deleteDevice(hwid: string): Promise<OperationResult> {
|
||||||
return request<OperationResult>(
|
return request<OperationResult>(
|
||||||
`/users/subscription/hwid?hwid=${encodeURIComponent(hwid)}`,
|
`/api/users/subscription/hwid?hwid=${encodeURIComponent(hwid)}`,
|
||||||
{ method: 'DELETE' },
|
{ method: 'DELETE' },
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
createTelegramLinkCode(): Promise<LinkCodeResponse> {
|
||||||
|
return request<LinkCodeResponse>('/api/link-codes', { method: 'POST' });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,18 +19,21 @@ export function AddonList({ addons, devices, selected, onToggle }: AddonListProp
|
|||||||
<div className="addon-list">
|
<div className="addon-list">
|
||||||
{addons.map((a) => {
|
{addons.map((a) => {
|
||||||
const on = selected.includes(a.id);
|
const on = selected.includes(a.id);
|
||||||
|
const enabled = a.is_enabled;
|
||||||
const price = addonPriceForDevices(a, devices);
|
const price = addonPriceForDevices(a, devices);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={a.id}
|
key={a.id}
|
||||||
type="button"
|
type="button"
|
||||||
className={`addon-row ${on ? 'is-selected' : ''}`.trim()}
|
className={`addon-row ${on ? 'is-selected' : ''} ${enabled ? '' : 'is-disabled'}`.trim()}
|
||||||
aria-pressed={on}
|
aria-pressed={on}
|
||||||
|
disabled={!enabled}
|
||||||
onClick={() => onToggle(a.id)}
|
onClick={() => onToggle(a.id)}
|
||||||
>
|
>
|
||||||
<span className="addon-toggle" aria-hidden="true" />
|
<span className="addon-toggle" aria-hidden="true" />
|
||||||
<span className="addon-info">
|
<span className="addon-info">
|
||||||
<span className="addon-name">{a.name}</span>
|
<span className="addon-name">{a.name}</span>
|
||||||
|
{!enabled && <span className="addon-status">Недоступно</span>}
|
||||||
</span>
|
</span>
|
||||||
<span className="addon-price">
|
<span className="addon-price">
|
||||||
{price === 0 ? 'Бесплатно' : <><span className="plus">+</span>{formatMoney(price)}</>}
|
{price === 0 ? 'Бесплатно' : <><span className="plus">+</span>{formatMoney(price)}</>}
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export function DeviceSlider({ min, max, value, onChange }: DeviceSliderProps) {
|
|||||||
<div className="slider-axis">
|
<div className="slider-axis">
|
||||||
<span>Устройства</span>
|
<span>Устройства</span>
|
||||||
<span>
|
<span>
|
||||||
<span className="axis-now">{value}</span> мест
|
<span className="axis-now">{value}</span> устройств
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="slider-track-frame" ref={frameRef} onClick={onFrameClick}>
|
<div className="slider-track-frame" ref={frameRef} onClick={onFrameClick}>
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ interface ModalProps {
|
|||||||
body: React.ReactNode;
|
body: React.ReactNode;
|
||||||
confirmLabel: string;
|
confirmLabel: string;
|
||||||
cancelLabel?: string;
|
cancelLabel?: string;
|
||||||
|
confirmClassName?: string;
|
||||||
|
pending?: boolean;
|
||||||
|
onCancel?: () => void;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,6 +23,9 @@ export function Modal({
|
|||||||
body,
|
body,
|
||||||
confirmLabel,
|
confirmLabel,
|
||||||
cancelLabel = 'Оставить',
|
cancelLabel = 'Оставить',
|
||||||
|
confirmClassName = 'btn-danger',
|
||||||
|
pending = false,
|
||||||
|
onCancel,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}: ModalProps) {
|
}: ModalProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -59,13 +65,14 @@ export function Modal({
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="modal-body">{body}</div>
|
<div className="modal-body">{body}</div>
|
||||||
<div className="modal-actions">
|
<div className="modal-actions">
|
||||||
<button type="button" className="btn" onClick={onClose} autoFocus>
|
<button type="button" className="btn" onClick={onCancel ?? onClose} disabled={pending} autoFocus>
|
||||||
{cancelLabel}
|
{cancelLabel}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-danger"
|
className={`btn ${confirmClassName}`}
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
|
disabled={pending}
|
||||||
>
|
>
|
||||||
{confirmLabel}
|
{confirmLabel}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export function StatStrip({ user, subscription, deviceCount }: StatStripProps) {
|
|||||||
const telegramId = user?.telegram_id ?? null;
|
const telegramId = user?.telegram_id ?? null;
|
||||||
const devicesLimit = subscription?.devices ?? null;
|
const devicesLimit = subscription?.devices ?? null;
|
||||||
const expiresAt = subscription?.expires_at ?? null;
|
const expiresAt = subscription?.expires_at ?? null;
|
||||||
|
const isExpired = expiresAt !== null && new Date(expiresAt).getTime() < Date.now();
|
||||||
const bonusBalance = user?.bonus_balance ?? 0;
|
const bonusBalance = user?.bonus_balance ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -57,7 +58,7 @@ export function StatStrip({ user, subscription, deviceCount }: StatStripProps) {
|
|||||||
}
|
}
|
||||||
label="Telegram ID"
|
label="Telegram ID"
|
||||||
>
|
>
|
||||||
{telegramId ? <span className="is-mono">@{telegramId}</span> : <span className="is-empty">не привязан</span>}
|
{telegramId ? <span className="is-mono">{telegramId}</span> : <span className="is-empty">не привязан</span>}
|
||||||
</Stat>
|
</Stat>
|
||||||
|
|
||||||
<Stat
|
<Stat
|
||||||
@@ -86,9 +87,13 @@ export function StatStrip({ user, subscription, deviceCount }: StatStripProps) {
|
|||||||
<path d="M3 9h18M8 3v4M16 3v4" />
|
<path d="M3 9h18M8 3v4M16 3v4" />
|
||||||
</svg>
|
</svg>
|
||||||
}
|
}
|
||||||
label="Продление"
|
label="Подписка до"
|
||||||
>
|
>
|
||||||
{expiresAt ? <span>{formatDate(expiresAt)}</span> : <span className="is-empty">—</span>}
|
{expiresAt ? (
|
||||||
|
<span>{isExpired ? 'Истекла' : formatDate(expiresAt)}</span>
|
||||||
|
) : (
|
||||||
|
<span className="is-empty">—</span>
|
||||||
|
)}
|
||||||
</Stat>
|
</Stat>
|
||||||
|
|
||||||
<Stat
|
<Stat
|
||||||
|
|||||||
@@ -53,17 +53,16 @@ export function SubscriptionQrModal({
|
|||||||
className="modal-veil is-open"
|
className="modal-veil is-open"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="subscription-qr-title"
|
aria-label="Откройте на устройстве"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
if (event.target === event.currentTarget) onClose();
|
if (event.target === event.currentTarget) onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="modal subscription-qr-modal" role="document">
|
<div className="modal subscription-qr-modal" role="document">
|
||||||
<div className="modal-kicker">{label}</div>
|
<div className="modal-kicker">Сканировать</div>
|
||||||
<h3 className="modal-title" id="subscription-qr-title">
|
<h3 className="modal-title" id="subscription-qr-title">
|
||||||
Откройте на устройстве
|
{label}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="modal-body">Отсканируйте код камерой или приложением Malenia.</p>
|
|
||||||
<div className="subscription-qr-image">
|
<div className="subscription-qr-image">
|
||||||
{image ? <img src={image} alt={imageAlt} /> : <span>Генерируем QR-код</span>}
|
{image ? <img src={image} alt={imageAlt} /> : <span>Генерируем QR-код</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import type { AddonData, PricingPlans } from '../types';
|
|||||||
const pricing: PricingPlans = {
|
const pricing: PricingPlans = {
|
||||||
device_price: 100,
|
device_price: 100,
|
||||||
addons: [
|
addons: [
|
||||||
{ id: 'a', name: 'A', price: 50, free_threshold: -1 },
|
{ id: 'a', name: 'A', price: 50, free_threshold: -1, is_enabled: true },
|
||||||
{ id: 'b', name: 'B', price: 30, free_threshold: -1 },
|
{ id: 'b', name: 'B', price: 30, free_threshold: -1, is_enabled: true },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,8 +38,8 @@ describe('sumAddonPrices', () => {
|
|||||||
|
|
||||||
it('makes addons free at their device threshold but keeps -1 paid', () => {
|
it('makes addons free at their device threshold but keeps -1 paid', () => {
|
||||||
const thresholdAddons: AddonData[] = [
|
const thresholdAddons: AddonData[] = [
|
||||||
{ id: 'threshold', name: 'Threshold', price: 50, free_threshold: 6 },
|
{ id: 'threshold', name: 'Threshold', price: 50, free_threshold: 6, is_enabled: true },
|
||||||
{ id: 'paid', name: 'Paid', price: 30, free_threshold: -1 },
|
{ id: 'paid', name: 'Paid', price: 30, free_threshold: -1, is_enabled: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
expect(sumAddonPrices(thresholdAddons, ['threshold', 'paid'], 5)).toBe(80);
|
expect(sumAddonPrices(thresholdAddons, ['threshold', 'paid'], 5)).toBe(80);
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export function sumAddonPrices(addons: AddonData[], selected: string[], devices:
|
|||||||
const addonById = new Map(addons.map((addon) => [addon.id, addon] as const));
|
const addonById = new Map(addons.map((addon) => [addon.id, addon] as const));
|
||||||
return selected.reduce<number>((sum, id) => {
|
return selected.reduce<number>((sum, id) => {
|
||||||
const addon = addonById.get(id);
|
const addon = addonById.get(id);
|
||||||
return sum + (addon ? addonPriceForDevices(addon, devices) : 0);
|
return sum + (addon?.is_enabled ? addonPriceForDevices(addon, devices) : 0);
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
20
src/lib/password.test.ts
Normal file
20
src/lib/password.test.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { getPasswordStrength, isPasswordStrong } from './password';
|
||||||
|
|
||||||
|
describe('isPasswordStrong', () => {
|
||||||
|
it('rejects passwords shorter than eight characters', () => {
|
||||||
|
expect(isPasswordStrong('A1!abcd')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects obvious passwords with a zxcvbn score of two or below', () => {
|
||||||
|
expect(isPasswordStrong('password1')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts passwords with a zxcvbn score above two', () => {
|
||||||
|
expect(isPasswordStrong('correct horse battery staple')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a display label and the zxcvbn score', () => {
|
||||||
|
expect(getPasswordStrength('password1')).toMatchObject({ score: 0, isStrong: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
31
src/lib/password.ts
Normal file
31
src/lib/password.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import zxcvbn from 'zxcvbn';
|
||||||
|
|
||||||
|
const MIN_PASSWORD_LENGTH = 8;
|
||||||
|
const MIN_PASSWORD_SCORE = 3;
|
||||||
|
|
||||||
|
export interface PasswordStrength {
|
||||||
|
score: number;
|
||||||
|
isStrong: boolean;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const strengthLabels = ['Слишком слабый', 'Слабый', 'Ненадёжный', 'Надёжный', 'Очень надёжный'];
|
||||||
|
|
||||||
|
export function getPasswordStrength(password: string): PasswordStrength | null {
|
||||||
|
if (!password) return null;
|
||||||
|
|
||||||
|
const score = zxcvbn(password).score;
|
||||||
|
return {
|
||||||
|
score,
|
||||||
|
isStrong: password.length >= MIN_PASSWORD_LENGTH && score >= MIN_PASSWORD_SCORE,
|
||||||
|
label:
|
||||||
|
password.length < MIN_PASSWORD_LENGTH ? 'Минимум 8 символов' : (strengthLabels[score] ?? 'Слишком слабый'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPasswordStrong(password: string): boolean {
|
||||||
|
return getPasswordStrength(password)?.isStrong ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const passwordStrengthMessage =
|
||||||
|
'Пароль должен быть не короче 8 символов и не состоять из очевидных сочетаний.';
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||||
import { render, screen, waitFor } from '@testing-library/react';
|
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { MemoryRouter, Routes, Route } from 'react-router-dom';
|
import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom';
|
||||||
import { AccountPage } from './AccountPage';
|
import { AccountPage } from './AccountPage';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import { useAuthStore } from '../store/useStore';
|
import { useAuthStore } from '../store/useStore';
|
||||||
|
import { redirectTo } from '../lib/redirect';
|
||||||
|
|
||||||
vi.mock('qrcode', () => ({
|
vi.mock('qrcode', () => ({
|
||||||
default: { toDataURL: () => Promise.resolve('data:image/png;base64,qr') },
|
default: { toDataURL: () => Promise.resolve('data:image/png;base64,qr') },
|
||||||
@@ -18,6 +19,7 @@ vi.mock('../api/client', () => ({
|
|||||||
getSubscription: vi.fn(),
|
getSubscription: vi.fn(),
|
||||||
getDevices: vi.fn(),
|
getDevices: vi.fn(),
|
||||||
deleteDevice: vi.fn(),
|
deleteDevice: vi.fn(),
|
||||||
|
createTelegramLinkCode: vi.fn(),
|
||||||
login: vi.fn(),
|
login: vi.fn(),
|
||||||
signup: vi.fn(),
|
signup: vi.fn(),
|
||||||
},
|
},
|
||||||
@@ -30,13 +32,17 @@ vi.mock('../api/client', () => ({
|
|||||||
clearStoredTokens: vi.fn(),
|
clearStoredTokens: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../lib/redirect', () => ({
|
||||||
|
redirectTo: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
function renderAccount(authed = true) {
|
function renderAccount(authed = true) {
|
||||||
if (authed) {
|
if (authed) {
|
||||||
useAuthStore.setState({
|
useAuthStore.setState({
|
||||||
access: 'a',
|
access: 'a',
|
||||||
refresh: 'r',
|
refresh: 'r',
|
||||||
expiresAt: 9999999999,
|
expiresAt: 9999999999,
|
||||||
user: { username: 'alice', telegram_id: 'alice_id', referal_code: 'c', bonus_balance: 150 },
|
user: { username: 'alice', telegram_id: 123456789, referal_code: 'c', bonus_balance: 150 },
|
||||||
remember: true,
|
remember: true,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -54,15 +60,22 @@ function renderAccount(authed = true) {
|
|||||||
<Route path="/account" element={<AccountPage />} />
|
<Route path="/account" element={<AccountPage />} />
|
||||||
<Route path="/login" element={<div>login page</div>} />
|
<Route path="/login" element={<div>login page</div>} />
|
||||||
<Route path="/" element={<div>landing</div>} />
|
<Route path="/" element={<div>landing</div>} />
|
||||||
|
<Route path="/plan" element={<PlanLocation />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ME = { username: 'alice', telegram_id: 'alice_id', referal_code: 'c', bonus_balance: 150 };
|
function PlanLocation() {
|
||||||
|
const location = useLocation();
|
||||||
|
return <div>{`plan page${location.search}`}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ME = { username: 'alice', telegram_id: 123456789, referal_code: 'c', bonus_balance: 150 };
|
||||||
const SUB = {
|
const SUB = {
|
||||||
devices: 6,
|
devices: 6,
|
||||||
expires_at: '2026-03-14T00:00:00Z',
|
duration_days: 90,
|
||||||
|
expires_at: '2099-03-14T00:00:00Z',
|
||||||
addon_ids: [],
|
addon_ids: [],
|
||||||
subscription_link: 'link',
|
subscription_link: 'link',
|
||||||
has_subscription: true,
|
has_subscription: true,
|
||||||
@@ -78,6 +91,7 @@ describe('AccountPage', () => {
|
|||||||
vi.mocked(api.getSubscription).mockReset();
|
vi.mocked(api.getSubscription).mockReset();
|
||||||
vi.mocked(api.getDevices).mockReset();
|
vi.mocked(api.getDevices).mockReset();
|
||||||
vi.mocked(api.deleteDevice).mockReset();
|
vi.mocked(api.deleteDevice).mockReset();
|
||||||
|
vi.mocked(api.createTelegramLinkCode).mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => vi.restoreAllMocks());
|
afterEach(() => vi.restoreAllMocks());
|
||||||
@@ -93,7 +107,7 @@ describe('AccountPage', () => {
|
|||||||
vi.mocked(api.getDevices).mockResolvedValue(DEVICES);
|
vi.mocked(api.getDevices).mockResolvedValue(DEVICES);
|
||||||
renderAccount();
|
renderAccount();
|
||||||
expect(await screen.findByText('alice')).toBeInTheDocument();
|
expect(await screen.findByText('alice')).toBeInTheDocument();
|
||||||
expect(screen.getByText('@alice_id')).toBeInTheDocument();
|
expect(screen.getByText('123456789')).toBeInTheDocument();
|
||||||
expect(screen.getByText(/150\s*₽/)).toBeInTheDocument();
|
expect(screen.getByText(/150\s*₽/)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/только на подписку/i)).toBeInTheDocument();
|
expect(screen.getByText(/только на подписку/i)).toBeInTheDocument();
|
||||||
expect(screen.getAllByText(/iPhone 15/)).toHaveLength(1);
|
expect(screen.getAllByText(/iPhone 15/)).toHaveLength(1);
|
||||||
@@ -106,7 +120,17 @@ describe('AccountPage', () => {
|
|||||||
renderAccount();
|
renderAccount();
|
||||||
|
|
||||||
expect(screen.queryByText(/активная подписка отсутствует/i)).not.toBeInTheDocument();
|
expect(screen.queryByText(/активная подписка отсутствует/i)).not.toBeInTheDocument();
|
||||||
expect(await screen.findByText(/подписка до/i)).toBeInTheDocument();
|
expect(await screen.findAllByText(/подписка до/i)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders expired subscription status', async () => {
|
||||||
|
vi.mocked(api.getMe).mockResolvedValue(ME);
|
||||||
|
vi.mocked(api.getSubscription).mockResolvedValue({ ...SUB, expires_at: '2020-01-01T00:00:00Z' });
|
||||||
|
vi.mocked(api.getDevices).mockResolvedValue(DEVICES);
|
||||||
|
renderAccount();
|
||||||
|
|
||||||
|
expect(await screen.findAllByText('Истекла')).toHaveLength(2);
|
||||||
|
expect(screen.queryByText(/^Подписка до \d/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the subscription link and copy action', async () => {
|
it('renders the subscription link and copy action', async () => {
|
||||||
@@ -118,6 +142,62 @@ describe('AccountPage', () => {
|
|||||||
expect(screen.getByRole('button', { name: /копировать ссылку подписки/i })).toBeEnabled();
|
expect(screen.getByRole('button', { name: /копировать ссылку подписки/i })).toBeEnabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('creates checkout with current subscription settings when renewal is confirmed', async () => {
|
||||||
|
vi.mocked(api.getMe).mockResolvedValue(ME);
|
||||||
|
vi.mocked(api.getSubscription).mockResolvedValue(SUB);
|
||||||
|
vi.mocked(api.getDevices).mockResolvedValue(DEVICES);
|
||||||
|
vi.mocked(api.checkout).mockResolvedValue({
|
||||||
|
order_id: 'order-1',
|
||||||
|
total_amount: 600,
|
||||||
|
bonus_paid: 0,
|
||||||
|
amount_to_pay: 600,
|
||||||
|
payment_link: null,
|
||||||
|
});
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderAccount();
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: /^продлить$/i }));
|
||||||
|
expect(await screen.findByText(/продлить с текущими параметрами/i)).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: /да, продлить/i }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.checkout).toHaveBeenCalledWith({ devices: 6, addons: [], duration_days: 90 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requests a Telegram link code and follows its deeplink', async () => {
|
||||||
|
vi.mocked(api.getMe).mockResolvedValue(ME);
|
||||||
|
vi.mocked(api.getSubscription).mockResolvedValue(SUB);
|
||||||
|
vi.mocked(api.getDevices).mockResolvedValue(DEVICES);
|
||||||
|
vi.mocked(api.createTelegramLinkCode).mockResolvedValue({
|
||||||
|
code: 'code-1',
|
||||||
|
expires_at: '2026-03-14T00:00:00Z',
|
||||||
|
deep_link: 'https://t.me/malenia_bot?start=code-1',
|
||||||
|
});
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderAccount();
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: /привязать telegram/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(api.createTelegramLinkCode).toHaveBeenCalledOnce());
|
||||||
|
expect(redirectTo).toHaveBeenCalledWith('https://t.me/malenia_bot?start=code-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the plan page with current subscription settings when renewal is adjusted', async () => {
|
||||||
|
vi.mocked(api.getMe).mockResolvedValue(ME);
|
||||||
|
vi.mocked(api.getSubscription).mockResolvedValue(SUB);
|
||||||
|
vi.mocked(api.getDevices).mockResolvedValue(DEVICES);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderAccount();
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: /^продлить$/i }));
|
||||||
|
await user.click(screen.getByRole('button', { name: /нет, настроить/i }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText('plan page?devices=6&duration=90')).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('renders a referral link with copy and QR actions', async () => {
|
it('renders a referral link with copy and QR actions', async () => {
|
||||||
vi.mocked(api.getMe).mockResolvedValue(ME);
|
vi.mocked(api.getMe).mockResolvedValue(ME);
|
||||||
vi.mocked(api.getSubscription).mockResolvedValue(SUB);
|
vi.mocked(api.getSubscription).mockResolvedValue(SUB);
|
||||||
@@ -163,9 +243,9 @@ describe('AccountPage', () => {
|
|||||||
|
|
||||||
await user.click(await screen.findByRole('button', { name: /показать клиенты для подключения/i }));
|
await user.click(await screen.findByRole('button', { name: /показать клиенты для подключения/i }));
|
||||||
expect(await screen.findByRole('dialog', { name: /выберите клиент/i })).toBeInTheDocument();
|
expect(await screen.findByRole('dialog', { name: /выберите клиент/i })).toBeInTheDocument();
|
||||||
expect(screen.getAllByRole('link', { name: 'v2raytun' })[0]).toHaveAttribute(
|
expect(within(screen.getByRole('region', { name: 'Android' })).getByRole('link', { name: 'v2raytun' })).toHaveAttribute(
|
||||||
'href',
|
'href',
|
||||||
'https://apps.apple.com/en/app/v2raytun/id6476628951',
|
'https://play.google.com/store/apps/details?id=com.v2raytun.android&hl=ru',
|
||||||
);
|
);
|
||||||
expect(screen.getAllByText(/разрешить отправку hwid/i)).toHaveLength(2);
|
expect(screen.getAllByText(/разрешить отправку hwid/i)).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { DeviceCard } from '../components/DeviceCard';
|
|||||||
import { Modal } from '../components/Modal';
|
import { Modal } from '../components/Modal';
|
||||||
import { SubscriptionQrModal } from '../components/SubscriptionQrModal';
|
import { SubscriptionQrModal } from '../components/SubscriptionQrModal';
|
||||||
import { ClientLinksModal } from '../components/ClientLinksModal';
|
import { ClientLinksModal } from '../components/ClientLinksModal';
|
||||||
|
import { formatMoney } from '../lib/format';
|
||||||
|
import { redirectTo } from '../lib/redirect';
|
||||||
import type { Device, SubscriptionData, UserInfo } from '../types';
|
import type { Device, SubscriptionData, UserInfo } from '../types';
|
||||||
|
|
||||||
export function AccountPage() {
|
export function AccountPage() {
|
||||||
@@ -27,6 +29,9 @@ export function AccountPage() {
|
|||||||
const [isQrOpen, setIsQrOpen] = useState(false);
|
const [isQrOpen, setIsQrOpen] = useState(false);
|
||||||
const [isReferralQrOpen, setIsReferralQrOpen] = useState(false);
|
const [isReferralQrOpen, setIsReferralQrOpen] = useState(false);
|
||||||
const [isClientLinksOpen, setIsClientLinksOpen] = useState(false);
|
const [isClientLinksOpen, setIsClientLinksOpen] = useState(false);
|
||||||
|
const [isRenewalOpen, setIsRenewalOpen] = useState(false);
|
||||||
|
const [isRenewing, setIsRenewing] = useState(false);
|
||||||
|
const [isLinkingTelegram, setIsLinkingTelegram] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
@@ -98,6 +103,54 @@ export function AccountPage() {
|
|||||||
navigate('/', { replace: true });
|
navigate('/', { replace: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openPlanWithCurrentConfig = () => {
|
||||||
|
if (!subscription) return;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (subscription.devices) params.set('devices', String(subscription.devices));
|
||||||
|
if (subscription.duration_days) params.set('duration', String(subscription.duration_days));
|
||||||
|
if (subscription.addon_ids.length > 0) params.set('addons', subscription.addon_ids.join(','));
|
||||||
|
navigate(`/plan?${params.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renewCurrentPlan = async () => {
|
||||||
|
if (!subscription?.devices || !subscription.duration_days) {
|
||||||
|
openPlanWithCurrentConfig();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsRenewing(true);
|
||||||
|
try {
|
||||||
|
const result = await api.checkout({
|
||||||
|
devices: subscription.devices,
|
||||||
|
addons: subscription.addon_ids,
|
||||||
|
duration_days: subscription.duration_days,
|
||||||
|
});
|
||||||
|
if (result.payment_link) {
|
||||||
|
redirectTo(result.payment_link);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsRenewalOpen(false);
|
||||||
|
showToast(`Заказ ${result.order_id} оформлен. К оплате: ${formatMoney(result.amount_to_pay)}`);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof ApiError ? err.message : 'Не удалось оформить продление';
|
||||||
|
showToast(message);
|
||||||
|
} finally {
|
||||||
|
setIsRenewing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const linkTelegram = async () => {
|
||||||
|
setIsLinkingTelegram(true);
|
||||||
|
try {
|
||||||
|
const { deep_link } = await api.createTelegramLinkCode();
|
||||||
|
redirectTo(deep_link);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof ApiError ? err.message : 'Не удалось начать привязку Telegram';
|
||||||
|
showToast(message);
|
||||||
|
} finally {
|
||||||
|
setIsLinkingTelegram(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Shell active="account">
|
<Shell active="account">
|
||||||
<section className="account-hero">
|
<section className="account-hero">
|
||||||
@@ -105,11 +158,30 @@ export function AccountPage() {
|
|||||||
<div className="kicker">Ваш аккаунт</div>
|
<div className="kicker">Ваш аккаунт</div>
|
||||||
<h1 className="hero-title">{userInfo?.username ?? 'Аккаунт'}</h1>
|
<h1 className="hero-title">{userInfo?.username ?? 'Аккаунт'}</h1>
|
||||||
{!loadingMeta ? (
|
{!loadingMeta ? (
|
||||||
|
<>
|
||||||
<p className="hero-sub">
|
<p className="hero-sub">
|
||||||
{subscription?.has_subscription && subscription.expires_at
|
{subscription?.has_subscription && subscription.expires_at
|
||||||
? `Подписка до ${new Date(subscription.expires_at).toLocaleDateString('ru-RU')}`
|
? new Date(subscription.expires_at).getTime() < Date.now()
|
||||||
|
? 'Истекла'
|
||||||
|
: `Подписка до ${new Date(subscription.expires_at).toLocaleDateString('ru-RU')}`
|
||||||
: 'Активная подписка отсутствует'}
|
: 'Активная подписка отсутствует'}
|
||||||
</p>
|
</p>
|
||||||
|
<div className="account-actions">
|
||||||
|
{subscription?.has_subscription ? (
|
||||||
|
<button type="button" className="btn btn-primary" onClick={() => setIsRenewalOpen(true)}>
|
||||||
|
Продлить
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
disabled={isLinkingTelegram}
|
||||||
|
onClick={() => void linkTelegram()}
|
||||||
|
>
|
||||||
|
{isLinkingTelegram ? 'Подготовка…' : 'Привязать Telegram'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</section>
|
</section>
|
||||||
@@ -275,6 +347,19 @@ export function AccountPage() {
|
|||||||
confirmLabel="Удалить"
|
confirmLabel="Удалить"
|
||||||
onConfirm={confirmDelete}
|
onConfirm={confirmDelete}
|
||||||
/>
|
/>
|
||||||
|
<Modal
|
||||||
|
open={isRenewalOpen}
|
||||||
|
onClose={() => setIsRenewalOpen(false)}
|
||||||
|
kicker="Продление"
|
||||||
|
title="Продлить с текущими параметрами?"
|
||||||
|
body="Мы повторим число устройств, дополнения и срок вашей текущей подписки."
|
||||||
|
confirmLabel={isRenewing ? 'Оформление…' : 'Да, продлить'}
|
||||||
|
cancelLabel="Нет, настроить"
|
||||||
|
confirmClassName="btn-primary"
|
||||||
|
pending={isRenewing}
|
||||||
|
onCancel={openPlanWithCurrentConfig}
|
||||||
|
onConfirm={() => void renewCurrentPlan()}
|
||||||
|
/>
|
||||||
<SubscriptionQrModal
|
<SubscriptionQrModal
|
||||||
link={isQrOpen ? subscription?.subscription_link ?? null : null}
|
link={isQrOpen ? subscription?.subscription_link ?? null : null}
|
||||||
onClose={() => setIsQrOpen(false)}
|
onClose={() => setIsQrOpen(false)}
|
||||||
|
|||||||
@@ -99,19 +99,43 @@ describe('LoginPage', () => {
|
|||||||
renderLogin();
|
renderLogin();
|
||||||
await user.click(screen.getByRole('tab', { name: /Регистрация/i }));
|
await user.click(screen.getByRole('tab', { name: /Регистрация/i }));
|
||||||
await user.type(screen.getByLabelText('Имя пользователя'), 'alice');
|
await user.type(screen.getByLabelText('Имя пользователя'), 'alice');
|
||||||
await user.type(screen.getByLabelText('Пароль'), 'secret');
|
await user.type(screen.getByLabelText('Пароль'), 'correct horse battery staple');
|
||||||
await user.click(screen.getByRole('button', { name: /создать аккаунт/i }));
|
await user.click(screen.getByRole('button', { name: /создать аккаунт/i }));
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(api.signup).toHaveBeenCalledWith({
|
expect(api.signup).toHaveBeenCalledWith({
|
||||||
provider: 'credentials',
|
provider: 'credentials',
|
||||||
username: 'alice',
|
username: 'alice',
|
||||||
password: 'secret',
|
password: 'correct horse battery staple',
|
||||||
referal_code: 'friend-code',
|
referal_code: 'friend-code',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not submit a weak password during signup', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderLogin();
|
||||||
|
await user.click(screen.getByRole('tab', { name: /Регистрация/i }));
|
||||||
|
await user.type(screen.getByLabelText('Имя пользователя'), 'alice');
|
||||||
|
await user.type(screen.getByLabelText('Пароль'), 'password1');
|
||||||
|
await user.click(screen.getByRole('button', { name: /создать аккаунт/i }));
|
||||||
|
|
||||||
|
expect(await screen.findByText(/не короче 8 символов/i)).toBeInTheDocument();
|
||||||
|
expect(api.signup).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a dynamic password strength meter during signup', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderLogin();
|
||||||
|
await user.click(screen.getByRole('tab', { name: /Регистрация/i }));
|
||||||
|
await user.type(screen.getByLabelText('Пароль'), 'password1');
|
||||||
|
|
||||||
|
expect(screen.getByRole('progressbar', { name: /надёжность пароля/i })).toHaveAttribute(
|
||||||
|
'aria-valuetext',
|
||||||
|
'Слишком слабый',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('shows backend error on login failure', async () => {
|
it('shows backend error on login failure', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
vi.mocked(api.login).mockRejectedValue(new ApiError('неверные данные', 401));
|
vi.mocked(api.login).mockRejectedValue(new ApiError('неверные данные', 401));
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Emblem } from '../components/Emblem';
|
|||||||
import { Logo } from '../components/Logo';
|
import { Logo } from '../components/Logo';
|
||||||
import { Reveal } from '../components/Reveal';
|
import { Reveal } from '../components/Reveal';
|
||||||
import { getReferralCode } from '../lib/referral';
|
import { getReferralCode } from '../lib/referral';
|
||||||
|
import { getPasswordStrength, isPasswordStrong, passwordStrengthMessage } from '../lib/password';
|
||||||
|
|
||||||
type Mode = 'signin' | 'signup';
|
type Mode = 'signin' | 'signup';
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ export function LoginPage() {
|
|||||||
const queryReturnTo = new URLSearchParams(location.search).get('returnTo');
|
const queryReturnTo = new URLSearchParams(location.search).get('returnTo');
|
||||||
const returnTo =
|
const returnTo =
|
||||||
returnPath(queryReturnTo) ?? returnPath((location.state as LocationState | null)?.returnTo) ?? '/account';
|
returnPath(queryReturnTo) ?? returnPath((location.state as LocationState | null)?.returnTo) ?? '/account';
|
||||||
|
const passwordStrength = mode === 'signup' ? getPasswordStrength(password) : null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) navigate(returnTo, { replace: true });
|
if (isAuthenticated) navigate(returnTo, { replace: true });
|
||||||
@@ -75,8 +77,8 @@ export function LoginPage() {
|
|||||||
setStatusMsg('Имя пользователя и пароль обязательны.', 'error');
|
setStatusMsg('Имя пользователя и пароль обязательны.', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (mode === 'signup' && password.length < 4) {
|
if (mode === 'signup' && !isPasswordStrong(password)) {
|
||||||
setStatusMsg('Пароль слишком короткий (минимум 4 символа).', 'error');
|
setStatusMsg(passwordStrengthMessage, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@@ -204,6 +206,28 @@ export function LoginPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{mode === 'signup' && passwordStrength && (
|
||||||
|
<div className="password-strength" aria-live="polite">
|
||||||
|
<div
|
||||||
|
className={`password-strength-meter ${passwordStrength.isStrong ? 'is-strong' : ''}`.trim()}
|
||||||
|
role="progressbar"
|
||||||
|
aria-label="Надёжность пароля"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={4}
|
||||||
|
aria-valuenow={passwordStrength.score}
|
||||||
|
aria-valuetext={passwordStrength.label}
|
||||||
|
>
|
||||||
|
{[0, 1, 2, 3].map((segment) => (
|
||||||
|
<span
|
||||||
|
key={segment}
|
||||||
|
className={segment < passwordStrength.score ? 'is-filled' : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="password-strength-label">{passwordStrength.label}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{mode === 'signin' && (
|
{mode === 'signin' && (
|
||||||
<div className="auth-row">
|
<div className="auth-row">
|
||||||
<label className="check">
|
<label className="check">
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ function renderPlan(initialEntries: string[] = ['/plan']) {
|
|||||||
const PLANS = {
|
const PLANS = {
|
||||||
device_price: 100,
|
device_price: 100,
|
||||||
addons: [
|
addons: [
|
||||||
{ id: 'dedicated-ip', name: 'Dedicated IP', price: 50, free_threshold: -1 },
|
{ id: 'dedicated-ip', name: 'Dedicated IP', price: 50, free_threshold: -1, is_enabled: true },
|
||||||
{ id: 'multi-hop', name: 'Multi-Hop', price: 30, free_threshold: -1 },
|
{ id: 'multi-hop', name: 'Multi-Hop', price: 30, free_threshold: -1, is_enabled: true },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -130,4 +130,21 @@ describe('PlanPage', () => {
|
|||||||
await waitFor(() => expect(api.checkout).toHaveBeenCalledOnce());
|
await waitFor(() => expect(api.checkout).toHaveBeenCalledOnce());
|
||||||
expect(redirectTo).toHaveBeenCalledWith('https://pay.example/abc');
|
expect(redirectTo).toHaveBeenCalledWith('https://pay.example/abc');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not allow selecting disabled addons', async () => {
|
||||||
|
vi.mocked(api.getPlans).mockResolvedValue({
|
||||||
|
...PLANS,
|
||||||
|
addons: [
|
||||||
|
...PLANS.addons,
|
||||||
|
{ id: 'unavailable', name: 'Unavailable', price: 30, free_threshold: -1, is_enabled: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPlan();
|
||||||
|
|
||||||
|
const addon = await screen.findByRole('button', { name: /Unavailable/i });
|
||||||
|
expect(addon).toBeDisabled();
|
||||||
|
await user.click(addon);
|
||||||
|
expect(addon).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function PlanPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!plans) return;
|
if (!plans) return;
|
||||||
const available = new Set(plans.addons.map((addon) => addon.id));
|
const available = new Set(plans.addons.filter((addon) => addon.is_enabled).map((addon) => addon.id));
|
||||||
setSelectedAddons((current) => current.filter((addon) => available.has(addon)));
|
setSelectedAddons((current) => current.filter((addon) => available.has(addon)));
|
||||||
}, [plans]);
|
}, [plans]);
|
||||||
|
|
||||||
@@ -63,6 +63,7 @@ export function PlanPage() {
|
|||||||
}, [plans, devices, selectedAddons, duration]);
|
}, [plans, devices, selectedAddons, duration]);
|
||||||
|
|
||||||
const toggleAddon = (id: string) => {
|
const toggleAddon = (id: string) => {
|
||||||
|
if (!plans?.addons.some((addon) => addon.id === id && addon.is_enabled)) return;
|
||||||
setSelectedAddons((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
setSelectedAddons((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -99,9 +100,9 @@ export function PlanPage() {
|
|||||||
<section className="plan-hero">
|
<section className="plan-hero">
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<div className="kicker">Соберите свой план</div>
|
<div className="kicker">Соберите свой план</div>
|
||||||
<h1 className="hero-title">Круг, подогнанный под вас.</h1>
|
<h1 className="hero-title">Платите только за то, что используете.</h1>
|
||||||
<p className="hero-sub">
|
<p className="hero-sub">
|
||||||
Выберите, сколько устройств разделят вашу свободу. Цена меняется тихо — фиксированная
|
Выберите, сколько устройств разделят вашу свободу. Цена меняется — фиксированная
|
||||||
ставка за каждое, без уровней и наценок.
|
ставка за каждое, без уровней и наценок.
|
||||||
</p>
|
</p>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { LoginResponse, Tokens, UserInfo } from '../types';
|
import type { AuthenticatedUser, Tokens, UserInfo } from '../types';
|
||||||
import { clearStoredTokens, setStoredTokens } from '../api/client';
|
import { clearStoredTokens, setStoredTokens } from '../api/client';
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
@@ -8,7 +8,7 @@ interface AuthState {
|
|||||||
expiresAt: number | null;
|
expiresAt: number | null;
|
||||||
user: UserInfo | null;
|
user: UserInfo | null;
|
||||||
remember: boolean;
|
remember: boolean;
|
||||||
setSession: (session: LoginResponse, remember: boolean) => void;
|
setSession: (session: AuthenticatedUser, remember: boolean) => void;
|
||||||
setTokens: (tokens: Tokens) => void;
|
setTokens: (tokens: Tokens) => void;
|
||||||
updateUser: (user: UserInfo) => void;
|
updateUser: (user: UserInfo) => void;
|
||||||
setRemember: (remember: boolean) => void;
|
setRemember: (remember: boolean) => void;
|
||||||
|
|||||||
@@ -1209,6 +1209,14 @@
|
|||||||
.addon-row:hover {
|
.addon-row:hover {
|
||||||
background: color-mix(in srgb, var(--surface) 82%, var(--fg) 4%);
|
background: color-mix(in srgb, var(--surface) 82%, var(--fg) 4%);
|
||||||
}
|
}
|
||||||
|
.addon-row.is-disabled {
|
||||||
|
color: var(--fg-muted);
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.58;
|
||||||
|
}
|
||||||
|
.addon-row.is-disabled:hover {
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
.addon-row.is-selected {
|
.addon-row.is-selected {
|
||||||
background: color-mix(in srgb, var(--surface) 78%, var(--accent) 10%);
|
background: color-mix(in srgb, var(--surface) 78%, var(--accent) 10%);
|
||||||
}
|
}
|
||||||
@@ -1250,6 +1258,12 @@
|
|||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
}
|
}
|
||||||
|
.addon-status {
|
||||||
|
color: var(--fg-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
.addon-price {
|
.addon-price {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|||||||
@@ -227,6 +227,37 @@
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
.password-strength {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: -10px;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.password-strength-meter {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
flex: 1;
|
||||||
|
gap: 3px;
|
||||||
|
height: 3px;
|
||||||
|
}
|
||||||
|
.password-strength-meter span {
|
||||||
|
background: var(--hairline-strong);
|
||||||
|
transition: background 0.25s;
|
||||||
|
}
|
||||||
|
.password-strength-meter.is-strong span.is-filled {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
.password-strength-meter:not(.is-strong) span.is-filled {
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
.password-strength-label {
|
||||||
|
min-width: 138px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
.auth-divider {
|
.auth-divider {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -491,6 +522,13 @@
|
|||||||
.account-hero .hero-sub {
|
.account-hero .hero-sub {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
.account-actions {
|
||||||
|
margin-top: 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
.checkout-confirmation {
|
.checkout-confirmation {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 96px 0 64px;
|
padding: 96px 0 64px;
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
import '@testing-library/jest-dom/vitest';
|
import '@testing-library/jest-dom/vitest';
|
||||||
import { afterEach } from 'vitest';
|
import { afterEach, vi } from 'vitest';
|
||||||
import { cleanup } from '@testing-library/react';
|
import { cleanup } from '@testing-library/react';
|
||||||
|
|
||||||
|
const storage = new Map<string, string>();
|
||||||
|
|
||||||
|
vi.stubGlobal('localStorage', {
|
||||||
|
get length() {
|
||||||
|
return storage.size;
|
||||||
|
},
|
||||||
|
clear: () => storage.clear(),
|
||||||
|
getItem: (key: string) => storage.get(key) ?? null,
|
||||||
|
key: (index: number) => [...storage.keys()][index] ?? null,
|
||||||
|
removeItem: (key: string) => storage.delete(key),
|
||||||
|
setItem: (key: string, value: string) => storage.set(key, String(value)),
|
||||||
|
} satisfies Storage);
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
|
|||||||
15
src/types.ts
15
src/types.ts
@@ -2,18 +2,21 @@ export type Provider = 'credentials' | 'telegram' | 'api';
|
|||||||
|
|
||||||
export interface UserInfo {
|
export interface UserInfo {
|
||||||
username: string | null;
|
username: string | null;
|
||||||
telegram_id: string | null;
|
telegram_id: number | null;
|
||||||
referal_code: string;
|
referal_code: string;
|
||||||
bonus_balance: number;
|
bonus_balance: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginResponse {
|
export interface AuthenticatedUser {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
refresh_token: string;
|
refresh_token: string;
|
||||||
expires_at: number;
|
expires_at: number;
|
||||||
user: UserInfo;
|
user: UserInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated Переименовано в AuthenticatedUser вслед за бэкендом. */
|
||||||
|
export type LoginResponse = AuthenticatedUser;
|
||||||
|
|
||||||
export interface Tokens {
|
export interface Tokens {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
refresh_token: string;
|
refresh_token: string;
|
||||||
@@ -25,6 +28,7 @@ export interface AddonData {
|
|||||||
name: string;
|
name: string;
|
||||||
price: number;
|
price: number;
|
||||||
free_threshold: number;
|
free_threshold: number;
|
||||||
|
is_enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PricingPlans {
|
export interface PricingPlans {
|
||||||
@@ -55,6 +59,7 @@ export interface Device {
|
|||||||
|
|
||||||
export interface SubscriptionData {
|
export interface SubscriptionData {
|
||||||
devices: number | null;
|
devices: number | null;
|
||||||
|
duration_days: number | null;
|
||||||
expires_at: string | null;
|
expires_at: string | null;
|
||||||
addon_ids: string[];
|
addon_ids: string[];
|
||||||
subscription_link: string | null;
|
subscription_link: string | null;
|
||||||
@@ -65,6 +70,12 @@ export interface OperationResult {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LinkCodeResponse {
|
||||||
|
code: string;
|
||||||
|
expires_at: string;
|
||||||
|
deep_link: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SignupPayload {
|
export interface SignupPayload {
|
||||||
provider: 'credentials';
|
provider: 'credentials';
|
||||||
username: string;
|
username: string;
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/App.tsx","./src/main.tsx","./src/types.ts","./src/api/client.test.ts","./src/api/client.ts","./src/components/AddonList.tsx","./src/components/Breakdown.tsx","./src/components/ClientLinksModal.tsx","./src/components/Cursor.tsx","./src/components/DeviceCard.tsx","./src/components/DeviceSlider.tsx","./src/components/DurationSelect.tsx","./src/components/Emblem.tsx","./src/components/Logo.tsx","./src/components/Modal.tsx","./src/components/Reveal.tsx","./src/components/Shell.tsx","./src/components/Skeleton.tsx","./src/components/StatStrip.tsx","./src/components/SubscriptionQrModal.tsx","./src/components/Toast.tsx","./src/components/TopBar.tsx","./src/hooks/useAuth.ts","./src/hooks/useDevices.ts","./src/hooks/useLocalStorage.ts","./src/hooks/usePlans.ts","./src/lib/format.test.ts","./src/lib/format.ts","./src/lib/planConfig.test.ts","./src/lib/planConfig.ts","./src/lib/redirect.ts","./src/lib/referral.ts","./src/pages/AccountPage.test.tsx","./src/pages/AccountPage.tsx","./src/pages/Landing.test.tsx","./src/pages/Landing.tsx","./src/pages/LoginPage.test.tsx","./src/pages/LoginPage.tsx","./src/pages/PlanPage.test.tsx","./src/pages/PlanPage.tsx","./src/store/useStore.ts","./src/store/useToast.ts","./src/test/setup.ts"],"version":"5.9.3"}
|
{"root":["./src/App.tsx","./src/main.tsx","./src/types.ts","./src/api/client.test.ts","./src/api/client.ts","./src/components/AddonList.tsx","./src/components/Breakdown.tsx","./src/components/ClientLinksModal.tsx","./src/components/Cursor.tsx","./src/components/DeviceCard.tsx","./src/components/DeviceSlider.tsx","./src/components/DurationSelect.tsx","./src/components/Emblem.tsx","./src/components/Logo.tsx","./src/components/Modal.tsx","./src/components/Reveal.tsx","./src/components/Shell.tsx","./src/components/Skeleton.tsx","./src/components/StatStrip.tsx","./src/components/SubscriptionQrModal.tsx","./src/components/Toast.tsx","./src/components/TopBar.tsx","./src/hooks/useAuth.ts","./src/hooks/useDevices.ts","./src/hooks/useLocalStorage.ts","./src/hooks/usePlans.ts","./src/lib/format.test.ts","./src/lib/format.ts","./src/lib/password.test.ts","./src/lib/password.ts","./src/lib/planConfig.test.ts","./src/lib/planConfig.ts","./src/lib/redirect.ts","./src/lib/referral.ts","./src/pages/AccountPage.test.tsx","./src/pages/AccountPage.tsx","./src/pages/Landing.test.tsx","./src/pages/Landing.tsx","./src/pages/LoginPage.test.tsx","./src/pages/LoginPage.tsx","./src/pages/PlanPage.test.tsx","./src/pages/PlanPage.tsx","./src/store/useStore.ts","./src/store/useToast.ts","./src/test/setup.ts"],"version":"5.9.3"}
|
||||||
@@ -7,10 +7,11 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/auth': { target: 'http://127.0.0.1:8000', changeOrigin: true },
|
'/api': {
|
||||||
'/plans': { target: 'http://127.0.0.1:8000', changeOrigin: true },
|
target: 'http://127.0.0.1:8000',
|
||||||
'/orders': { target: 'http://127.0.0.1:8000', changeOrigin: true },
|
changeOrigin: true,
|
||||||
'/users': { target: 'http://127.0.0.1:8000', changeOrigin: true },
|
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
|
|||||||
Reference in New Issue
Block a user