Compare commits

...

4 Commits

Author SHA1 Message Date
5c7255b759 feat(frontend): align with API schema updates and improve subscription UX 2026-09-08 20:40:04 +07:00
6749b89bbd feat: linking btn 2026-08-18 21:31:17 +07:00
8a9248d337 feat: password strength bar 2026-08-18 12:45:00 +07:00
91ffbadcae feat: browser-side security check 2026-08-18 12:41:37 +07:00
18 changed files with 270 additions and 43 deletions

File diff suppressed because one or more lines are too long

16
package-lock.json generated
View File

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

View File

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

View File

@@ -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,
@@ -154,11 +155,11 @@ 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>('/api/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>('/api/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>('/api/plans/', { auth: false }); return request<PricingPlans>('/api/plans/', { auth: false });
@@ -181,4 +182,7 @@ export const api = {
{ method: 'DELETE' }, { method: 'DELETE' },
); );
}, },
createTelegramLinkCode(): Promise<LinkCodeResponse> {
return request<LinkCodeResponse>('/api/link-codes', { method: 'POST' });
},
}; };

View File

@@ -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> &nbsp;мест <span className="axis-now">{value}</span> &nbsp;устройств
</span> </span>
</div> </div>
<div className="slider-track-frame" ref={frameRef} onClick={onFrameClick}> <div className="slider-track-frame" ref={frameRef} onClick={onFrameClick}>

View File

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

View File

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

20
src/lib/password.test.ts Normal file
View 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
View 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 символов и не состоять из очевидных сочетаний.';

View File

@@ -5,6 +5,7 @@ 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 {
@@ -65,11 +71,11 @@ function PlanLocation() {
return <div>{`plan page${location.search}`}</div>; return <div>{`plan page${location.search}`}</div>;
} }
const ME = { username: 'alice', telegram_id: 'alice_id', referal_code: 'c', bonus_balance: 150 }; const ME = { username: 'alice', telegram_id: 123456789, referal_code: 'c', bonus_balance: 150 };
const SUB = { const SUB = {
devices: 6, devices: 6,
duration_days: 90, duration_days: 90,
expires_at: '2026-03-14T00:00:00Z', expires_at: '2099-03-14T00:00:00Z',
addon_ids: [], addon_ids: [],
subscription_link: 'link', subscription_link: 'link',
has_subscription: true, has_subscription: true,
@@ -85,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());
@@ -100,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);
@@ -113,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 () => {
@@ -148,6 +165,24 @@ describe('AccountPage', () => {
); );
}); });
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 () => { it('opens the plan page with current subscription settings when renewal is adjusted', 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);

View File

@@ -31,6 +31,7 @@ export function AccountPage() {
const [isClientLinksOpen, setIsClientLinksOpen] = useState(false); const [isClientLinksOpen, setIsClientLinksOpen] = useState(false);
const [isRenewalOpen, setIsRenewalOpen] = useState(false); const [isRenewalOpen, setIsRenewalOpen] = useState(false);
const [isRenewing, setIsRenewing] = useState(false); const [isRenewing, setIsRenewing] = useState(false);
const [isLinkingTelegram, setIsLinkingTelegram] = useState(false);
useEffect(() => { useEffect(() => {
if (!isAuthenticated) { if (!isAuthenticated) {
@@ -137,6 +138,19 @@ export function AccountPage() {
} }
}; };
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">
@@ -147,14 +161,26 @@ export function AccountPage() {
<> <>
<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 ? ( {subscription?.has_subscription ? (
<button type="button" className="btn btn-primary account-renew" onClick={() => setIsRenewalOpen(true)}> <button type="button" className="btn btn-primary" onClick={() => setIsRenewalOpen(true)}>
Продлить Продлить
</button> </button>
) : null} ) : null}
<button
type="button"
className="btn btn-secondary"
disabled={isLinkingTelegram}
onClick={() => void linkTelegram()}
>
{isLinkingTelegram ? 'Подготовка…' : 'Привязать Telegram'}
</button>
</div>
</> </>
) : null} ) : null}
</Reveal> </Reveal>

View File

@@ -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));

View File

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

View File

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

View File

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

View File

@@ -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,8 +522,12 @@
.account-hero .hero-sub { .account-hero .hero-sub {
max-width: none; max-width: none;
} }
.account-renew { .account-actions {
margin-top: 24px; margin-top: 24px;
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 12px;
} }
.checkout-confirmation { .checkout-confirmation {
text-align: center; text-align: center;

View File

@@ -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;
@@ -67,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;

View File

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