Add SVG assets for login devices and passkey icons
- Created a new SVG file for login devices with detailed paths and styles. - Added a new SVG file for the passkey icon with multiple vector paths and fill attributes.
This commit is contained in:
@@ -11,6 +11,11 @@ DATABASE_URL=postgresql://transit_admin:your_secure_password_here@localhost:5432
|
||||
REDIS_URL=redis://localhost:6379
|
||||
JWT_SECRET=replace_with_a_long_random_string
|
||||
|
||||
# Passkeys (WebAuthn). HTTP works only on localhost; production requires HTTPS.
|
||||
WEBAUTHN_RP_NAME=RideBus DE
|
||||
WEBAUTHN_RP_ID=localhost
|
||||
WEBAUTHN_ORIGIN=http://localhost,http://localhost:3001
|
||||
|
||||
# Firebase Cloud Messaging (server-side alert notifications)
|
||||
# Keep disabled to run without Firebase credentials.
|
||||
FCM_ENABLED=false
|
||||
@@ -38,3 +43,5 @@ API_URL=/api/v1
|
||||
# NODE_ENV=production
|
||||
# CORS_ORIGIN=https://admin.yourdomain.com
|
||||
# API_URL=/api/v1
|
||||
# WEBAUTHN_RP_ID=admin.yourdomain.com
|
||||
# WEBAUTHN_ORIGIN=https://admin.yourdomain.com
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,5 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# Environment variables
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Структура проекта Polotsk Transit API
|
||||
# Структура проекта RideBus DE API
|
||||
|
||||
## 📁 Обзор директорий
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Polotsk Transit API
|
||||
# RideBus DE API
|
||||
|
||||
Система управления информацией об общественном транспорте города Полоцка с REST API, веб-панелью администратора и поддержкой offline-синхронизации.
|
||||
|
||||
@@ -63,6 +63,11 @@ API_URL=/api/v1
|
||||
FCM_ENABLED=false
|
||||
```
|
||||
|
||||
Для входа по passkey на публичном адресе укажите в `.env` точный домен панели
|
||||
и используйте HTTPS: `WEBAUTHN_RP_ID=admin.example.by` и
|
||||
`WEBAUTHN_ORIGIN=https://admin.example.by`. В `WEBAUTHN_ORIGIN` не применяются
|
||||
wildcard-маски; при нескольких разрешённых адресах перечислите их через запятую.
|
||||
|
||||
Генерация безопасного salt:
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
|
||||
31
backend/migrations/012_passkeys.sql
Normal file
31
backend/migrations/012_passkeys.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- Discoverable WebAuthn credentials used for passwordless sign-in.
|
||||
CREATE TABLE passkey_credentials (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
credential_id VARCHAR(512) UNIQUE NOT NULL,
|
||||
public_key BYTEA NOT NULL,
|
||||
counter BIGINT NOT NULL DEFAULT 0,
|
||||
transports TEXT[] NOT NULL DEFAULT '{}',
|
||||
device_type VARCHAR(32) NOT NULL,
|
||||
backed_up BOOLEAN NOT NULL DEFAULT false,
|
||||
label VARCHAR(100),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_passkey_credentials_user_id ON passkey_credentials(user_id);
|
||||
|
||||
-- Challenges are short-lived and single-use. Keeping them in PostgreSQL lets
|
||||
-- verification work across backend restarts and multiple application instances.
|
||||
CREATE TABLE passkey_challenges (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
purpose VARCHAR(32) NOT NULL CHECK (purpose IN ('registration', 'authentication')),
|
||||
challenge VARCHAR(512) NOT NULL,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(purpose, challenge)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_passkey_challenges_lookup
|
||||
ON passkey_challenges(purpose, challenge, expires_at);
|
||||
5383
backend/package-lock.json
generated
Normal file
5383
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@
|
||||
"author": "Zakhar",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@simplewebauthn/server": "^14.0.2",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cheerio": "^1.2.0",
|
||||
|
||||
@@ -2,12 +2,71 @@ const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const pool = require('../config/database');
|
||||
const logger = require('../config/logger');
|
||||
const passkeyService = require('../services/passkeyService');
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||
const JWT_EXPIRES_IN = '24h';
|
||||
const REFRESH_TOKEN_EXPIRES_DAYS = 7;
|
||||
|
||||
class AuthController {
|
||||
async createSessionResponse(user, req) {
|
||||
const accessToken = jwt.sign(
|
||||
{ id: user.id, username: user.username, role: user.role },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: JWT_EXPIRES_IN }
|
||||
);
|
||||
|
||||
const refreshToken = jwt.sign(
|
||||
{ id: user.id, type: 'refresh' },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: `${REFRESH_TOKEN_EXPIRES_DAYS}d` }
|
||||
);
|
||||
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + REFRESH_TOKEN_EXPIRES_DAYS);
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO user_sessions (user_id, refresh_token, expires_at, ip_address, user_agent)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[user.id, refreshToken, expiresAt, req.ip, req.get('user-agent')]
|
||||
);
|
||||
|
||||
await pool.query('UPDATE users SET last_login = NOW() WHERE id = $1', [user.id]);
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
full_name: user.full_name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
avatar_url: user.avatar_url,
|
||||
is_active: user.is_active,
|
||||
last_login: user.last_login,
|
||||
created_at: user.created_at,
|
||||
},
|
||||
accessToken,
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
sendPasskeyError(res, error, fallbackMessage) {
|
||||
if (error.code === 'PASSKEY_CONFIGURATION_REQUIRED') {
|
||||
return res.status(503).json({ error: 'Вход по passkey ещё не настроен для этого адреса сайта.' });
|
||||
}
|
||||
|
||||
if (error.code === 'PASSKEY_CHALLENGE_EXPIRED') {
|
||||
return res.status(400).json({ error: 'Время ожидания passkey истекло. Повторите попытку.' });
|
||||
}
|
||||
|
||||
if (error.code === 'PASSKEY_NOT_FOUND' || error.code === 'PASSKEY_VERIFICATION_FAILED' || error.code === 'PASSKEY_RESPONSE_INVALID') {
|
||||
return res.status(401).json({ error: 'Не удалось подтвердить passkey.' });
|
||||
}
|
||||
|
||||
logger.error('Passkey error:', error);
|
||||
return res.status(500).json({ error: fallbackMessage });
|
||||
}
|
||||
|
||||
// Login
|
||||
async login(req, res) {
|
||||
try {
|
||||
@@ -35,51 +94,105 @@ class AuthController {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
const accessToken = jwt.sign(
|
||||
{ id: user.id, username: user.username, role: user.role },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: JWT_EXPIRES_IN }
|
||||
);
|
||||
|
||||
const refreshToken = jwt.sign(
|
||||
{ id: user.id, type: 'refresh' },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: `${REFRESH_TOKEN_EXPIRES_DAYS}d` }
|
||||
);
|
||||
|
||||
// Save refresh token
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + REFRESH_TOKEN_EXPIRES_DAYS);
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO user_sessions (user_id, refresh_token, expires_at, ip_address, user_agent)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[user.id, refreshToken, expiresAt, req.ip, req.get('user-agent')]
|
||||
);
|
||||
|
||||
// Update last login
|
||||
await pool.query(
|
||||
'UPDATE users SET last_login = NOW() WHERE id = $1',
|
||||
[user.id]
|
||||
);
|
||||
|
||||
// Remove password hash from response
|
||||
delete user.password_hash;
|
||||
|
||||
logger.info(`User logged in: ${username}`);
|
||||
|
||||
res.json({
|
||||
user,
|
||||
accessToken,
|
||||
refreshToken
|
||||
});
|
||||
res.json(await this.createSessionResponse(user, req));
|
||||
} catch (error) {
|
||||
logger.error('Login error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
||||
async generatePasskeyRegistrationOptions(req, res) {
|
||||
try {
|
||||
const userResult = await pool.query(
|
||||
`SELECT id, username, full_name
|
||||
FROM users
|
||||
WHERE id = $1 AND is_active = true`,
|
||||
[req.user.id]
|
||||
);
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Пользователь не найден.' });
|
||||
}
|
||||
|
||||
res.json(await passkeyService.generateRegistrationOptionsForUser(req, userResult.rows[0]));
|
||||
} catch (error) {
|
||||
this.sendPasskeyError(res, error, 'Не удалось начать регистрацию passkey.');
|
||||
}
|
||||
}
|
||||
|
||||
async verifyPasskeyRegistration(req, res) {
|
||||
try {
|
||||
const userResult = await pool.query(
|
||||
`SELECT id, username, full_name
|
||||
FROM users
|
||||
WHERE id = $1 AND is_active = true`,
|
||||
[req.user.id]
|
||||
);
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Пользователь не найден.' });
|
||||
}
|
||||
|
||||
const passkey = await passkeyService.verifyRegistrationForUser(
|
||||
req,
|
||||
userResult.rows[0],
|
||||
req.body.response,
|
||||
req.body.label,
|
||||
);
|
||||
logger.info(`Passkey registered for user: ${userResult.rows[0].username}`);
|
||||
res.status(201).json({ passkey });
|
||||
} catch (error) {
|
||||
this.sendPasskeyError(res, error, 'Не удалось сохранить passkey.');
|
||||
}
|
||||
}
|
||||
|
||||
async generatePasskeyAuthenticationOptions(req, res) {
|
||||
try {
|
||||
res.json(await passkeyService.generateAuthenticationOptionsForRequest(req));
|
||||
} catch (error) {
|
||||
this.sendPasskeyError(res, error, 'Не удалось начать вход по passkey.');
|
||||
}
|
||||
}
|
||||
|
||||
async verifyPasskeyAuthentication(req, res) {
|
||||
try {
|
||||
const user = await passkeyService.verifyAuthenticationForRequest(req, req.body.response);
|
||||
logger.info(`User logged in with passkey: ${user.username}`);
|
||||
res.json(await this.createSessionResponse(user, req));
|
||||
} catch (error) {
|
||||
this.sendPasskeyError(res, error, 'Не удалось выполнить вход по passkey.');
|
||||
}
|
||||
}
|
||||
|
||||
async listPasskeys(req, res) {
|
||||
try {
|
||||
res.json({ passkeys: await passkeyService.listUserPasskeys(req.user.id) });
|
||||
} catch (error) {
|
||||
logger.error('List passkeys error:', error);
|
||||
res.status(500).json({ error: 'Не удалось получить список passkey.' });
|
||||
}
|
||||
}
|
||||
|
||||
async removePasskey(req, res) {
|
||||
try {
|
||||
const passkeyId = Number(req.params.passkeyId);
|
||||
if (!Number.isSafeInteger(passkeyId) || passkeyId < 1) {
|
||||
return res.status(400).json({ error: 'Некорректный идентификатор passkey.' });
|
||||
}
|
||||
|
||||
const removed = await passkeyService.removeUserPasskey(req.user.id, passkeyId);
|
||||
if (!removed) {
|
||||
return res.status(404).json({ error: 'Passkey не найден.' });
|
||||
}
|
||||
|
||||
res.json({ message: 'Passkey удалён.' });
|
||||
} catch (error) {
|
||||
logger.error('Remove passkey error:', error);
|
||||
res.status(500).json({ error: 'Не удалось удалить passkey.' });
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh token
|
||||
async refresh(req, res) {
|
||||
try {
|
||||
|
||||
@@ -7,9 +7,15 @@ const { authenticateToken } = require('../middleware/auth');
|
||||
router.post('/login', authController.login.bind(authController));
|
||||
router.post('/refresh', authController.refresh.bind(authController));
|
||||
router.post('/logout', authController.logout.bind(authController));
|
||||
router.post('/passkeys/authentication/options', authController.generatePasskeyAuthenticationOptions.bind(authController));
|
||||
router.post('/passkeys/authentication/verify', authController.verifyPasskeyAuthentication.bind(authController));
|
||||
|
||||
// Protected routes
|
||||
router.get('/me', authenticateToken, authController.me.bind(authController));
|
||||
router.post('/change-password', authenticateToken, authController.changePassword.bind(authController));
|
||||
router.get('/passkeys', authenticateToken, authController.listPasskeys.bind(authController));
|
||||
router.post('/passkeys/registration/options', authenticateToken, authController.generatePasskeyRegistrationOptions.bind(authController));
|
||||
router.post('/passkeys/registration/verify', authenticateToken, authController.verifyPasskeyRegistration.bind(authController));
|
||||
router.delete('/passkeys/:passkeyId', authenticateToken, authController.removePasskey.bind(authController));
|
||||
|
||||
module.exports = router;
|
||||
325
backend/src/services/passkeyService.js
Normal file
325
backend/src/services/passkeyService.js
Normal file
@@ -0,0 +1,325 @@
|
||||
const {
|
||||
generateRegistrationOptions,
|
||||
generateAuthenticationOptions,
|
||||
verifyRegistrationResponse,
|
||||
verifyAuthenticationResponse,
|
||||
} = require('@simplewebauthn/server');
|
||||
|
||||
const pool = require('../config/database');
|
||||
|
||||
const CHALLENGE_TTL_MINUTES = 5;
|
||||
const PASSKEY_RP_NAME = process.env.WEBAUTHN_RP_NAME || 'RideBus DE';
|
||||
|
||||
function decodeClientDataChallenge(response) {
|
||||
const encodedClientData = response?.response?.clientDataJSON;
|
||||
|
||||
if (!encodedClientData || typeof encodedClientData !== 'string') {
|
||||
throw new Error('Passkey response does not contain client data');
|
||||
}
|
||||
|
||||
let clientData;
|
||||
try {
|
||||
clientData = JSON.parse(Buffer.from(encodedClientData, 'base64url').toString('utf8'));
|
||||
} catch {
|
||||
throw new Error('Passkey response contains invalid client data');
|
||||
}
|
||||
|
||||
if (!clientData.challenge || typeof clientData.challenge !== 'string') {
|
||||
throw new Error('Passkey response does not contain a challenge');
|
||||
}
|
||||
|
||||
return clientData.challenge;
|
||||
}
|
||||
|
||||
function parseConfiguredOrigins() {
|
||||
return (process.env.WEBAUTHN_ORIGIN || process.env.WEBAUTHN_ORIGINS || '')
|
||||
.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function getPasskeyConfig(req) {
|
||||
const configuredOrigins = parseConfiguredOrigins();
|
||||
const configuredRpId = process.env.WEBAUTHN_RP_ID?.trim();
|
||||
|
||||
if (configuredRpId && configuredOrigins.length > 0) {
|
||||
return {
|
||||
rpID: configuredRpId,
|
||||
origins: configuredOrigins,
|
||||
};
|
||||
}
|
||||
|
||||
// A safe local-development fallback. Production must name the public origin
|
||||
// explicitly: the browser will otherwise reject passkeys on a real domain.
|
||||
const requestOrigin = req.get('origin');
|
||||
const localOrigin = requestOrigin && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(requestOrigin);
|
||||
|
||||
if (process.env.NODE_ENV !== 'production' && localOrigin) {
|
||||
return {
|
||||
rpID: new URL(requestOrigin).hostname,
|
||||
origins: [requestOrigin],
|
||||
};
|
||||
}
|
||||
|
||||
const error = new Error('Passkeys require WEBAUTHN_RP_ID and WEBAUTHN_ORIGIN to be configured');
|
||||
error.code = 'PASSKEY_CONFIGURATION_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
async function storeChallenge({ userId = null, purpose, challenge }) {
|
||||
const expiresAt = new Date(Date.now() + CHALLENGE_TTL_MINUTES * 60 * 1000);
|
||||
await pool.query(
|
||||
`INSERT INTO passkey_challenges (user_id, purpose, challenge, expires_at)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[userId, purpose, challenge, expiresAt],
|
||||
);
|
||||
|
||||
// Keep this table bounded without making authentication depend on a scheduled job.
|
||||
await pool.query('DELETE FROM passkey_challenges WHERE expires_at <= NOW()');
|
||||
}
|
||||
|
||||
async function claimChallenge({ purpose, challenge, userId = null }) {
|
||||
const params = [purpose, challenge];
|
||||
let userFilter = '';
|
||||
|
||||
if (userId !== null) {
|
||||
params.push(userId);
|
||||
userFilter = ` AND user_id = $${params.length}`;
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`DELETE FROM passkey_challenges
|
||||
WHERE id = (
|
||||
SELECT id
|
||||
FROM passkey_challenges
|
||||
WHERE purpose = $1 AND challenge = $2 AND expires_at > NOW()${userFilter}
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
RETURNING id, user_id, challenge`,
|
||||
params,
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
async function getUserCredentials(userId) {
|
||||
const result = await pool.query(
|
||||
`SELECT credential_id, transports
|
||||
FROM passkey_credentials
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at ASC`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async function generateRegistrationOptionsForUser(req, user) {
|
||||
const config = getPasskeyConfig(req);
|
||||
const credentials = await getUserCredentials(user.id);
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: PASSKEY_RP_NAME,
|
||||
rpID: config.rpID,
|
||||
userID: new TextEncoder().encode(String(user.id)),
|
||||
userName: user.username,
|
||||
userDisplayName: user.full_name || user.username,
|
||||
attestationType: 'none',
|
||||
excludeCredentials: credentials.map((credential) => ({
|
||||
id: credential.credential_id,
|
||||
transports: credential.transports || undefined,
|
||||
})),
|
||||
authenticatorSelection: {
|
||||
residentKey: 'required',
|
||||
userVerification: 'required',
|
||||
},
|
||||
});
|
||||
|
||||
await storeChallenge({
|
||||
userId: user.id,
|
||||
purpose: 'registration',
|
||||
challenge: options.challenge,
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
async function verifyRegistrationForUser(req, user, response, label) {
|
||||
const config = getPasskeyConfig(req);
|
||||
const challenge = decodeClientDataChallenge(response);
|
||||
const pendingChallenge = await claimChallenge({
|
||||
purpose: 'registration',
|
||||
challenge,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (!pendingChallenge) {
|
||||
const error = new Error('Passkey registration has expired. Please try again.');
|
||||
error.code = 'PASSKEY_CHALLENGE_EXPIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response,
|
||||
expectedChallenge: pendingChallenge.challenge,
|
||||
expectedOrigin: config.origins,
|
||||
expectedRPID: config.rpID,
|
||||
requireUserVerification: true,
|
||||
});
|
||||
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
const error = new Error('Passkey could not be verified');
|
||||
error.code = 'PASSKEY_VERIFICATION_FAILED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
|
||||
const normalizedLabel = typeof label === 'string'
|
||||
? label.trim().slice(0, 100) || null
|
||||
: null;
|
||||
await pool.query(
|
||||
`INSERT INTO passkey_credentials
|
||||
(user_id, credential_id, public_key, counter, transports, device_type, backed_up, label)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[
|
||||
user.id,
|
||||
credential.id,
|
||||
Buffer.from(credential.publicKey),
|
||||
credential.counter,
|
||||
response.response.transports || [],
|
||||
credentialDeviceType,
|
||||
credentialBackedUp,
|
||||
normalizedLabel,
|
||||
],
|
||||
);
|
||||
return {
|
||||
id: credential.id,
|
||||
deviceType: credentialDeviceType,
|
||||
backedUp: credentialBackedUp,
|
||||
};
|
||||
}
|
||||
|
||||
async function generateAuthenticationOptionsForRequest(req) {
|
||||
const config = getPasskeyConfig(req);
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: config.rpID,
|
||||
userVerification: 'required',
|
||||
});
|
||||
|
||||
await storeChallenge({
|
||||
purpose: 'authentication',
|
||||
challenge: options.challenge,
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
async function verifyAuthenticationForRequest(req, response) {
|
||||
const config = getPasskeyConfig(req);
|
||||
const challenge = decodeClientDataChallenge(response);
|
||||
const credentialId = response?.id;
|
||||
|
||||
if (!credentialId || typeof credentialId !== 'string') {
|
||||
const error = new Error('Passkey response does not contain a credential');
|
||||
error.code = 'PASSKEY_RESPONSE_INVALID';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const pendingChallenge = await claimChallenge({
|
||||
purpose: 'authentication',
|
||||
challenge,
|
||||
});
|
||||
|
||||
if (!pendingChallenge) {
|
||||
const error = new Error('Passkey authentication has expired. Please try again.');
|
||||
error.code = 'PASSKEY_CHALLENGE_EXPIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const credentialResult = await pool.query(
|
||||
`SELECT pc.*, u.id AS user_id, u.username, u.full_name, u.email, u.role, u.avatar_url,
|
||||
u.is_active, u.last_login, u.created_at AS user_created_at
|
||||
FROM passkey_credentials pc
|
||||
JOIN users u ON u.id = pc.user_id
|
||||
WHERE pc.credential_id = $1 AND u.is_active = true
|
||||
LIMIT 1`,
|
||||
[credentialId],
|
||||
);
|
||||
const storedCredential = credentialResult.rows[0];
|
||||
|
||||
if (!storedCredential) {
|
||||
const error = new Error('Passkey is not registered for an active account');
|
||||
error.code = 'PASSKEY_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge: pendingChallenge.challenge,
|
||||
expectedOrigin: config.origins,
|
||||
expectedRPID: config.rpID,
|
||||
requireUserVerification: true,
|
||||
credential: {
|
||||
id: storedCredential.credential_id,
|
||||
publicKey: new Uint8Array(storedCredential.public_key),
|
||||
counter: Number(storedCredential.counter),
|
||||
transports: storedCredential.transports || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (!verification.verified) {
|
||||
const error = new Error('Passkey could not be verified');
|
||||
error.code = 'PASSKEY_VERIFICATION_FAILED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`UPDATE passkey_credentials
|
||||
SET counter = $1, last_used_at = NOW(), backed_up = $2
|
||||
WHERE id = $3`,
|
||||
[
|
||||
verification.authenticationInfo.newCounter,
|
||||
verification.authenticationInfo.credentialBackedUp,
|
||||
storedCredential.id,
|
||||
],
|
||||
);
|
||||
return {
|
||||
id: storedCredential.user_id,
|
||||
username: storedCredential.username,
|
||||
full_name: storedCredential.full_name,
|
||||
email: storedCredential.email,
|
||||
role: storedCredential.role,
|
||||
avatar_url: storedCredential.avatar_url,
|
||||
is_active: storedCredential.is_active,
|
||||
last_login: storedCredential.last_login,
|
||||
created_at: storedCredential.user_created_at,
|
||||
};
|
||||
}
|
||||
|
||||
async function listUserPasskeys(userId) {
|
||||
const result = await pool.query(
|
||||
`SELECT id, label, device_type, backed_up, created_at, last_used_at
|
||||
FROM passkey_credentials
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC`,
|
||||
[userId],
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async function removeUserPasskey(userId, passkeyId) {
|
||||
const result = await pool.query(
|
||||
'DELETE FROM passkey_credentials WHERE id = $1 AND user_id = $2 RETURNING id',
|
||||
[passkeyId, userId],
|
||||
);
|
||||
return result.rows.length > 0;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateRegistrationOptionsForUser,
|
||||
verifyRegistrationForUser,
|
||||
generateAuthenticationOptionsForRequest,
|
||||
verifyAuthenticationForRequest,
|
||||
listUserPasskeys,
|
||||
removeUserPasskey,
|
||||
};
|
||||
@@ -55,6 +55,9 @@ services:
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin123}
|
||||
ADMIN_FULL_NAME: ${ADMIN_FULL_NAME:-Main administrator}
|
||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@polotsk-transit.local}
|
||||
WEBAUTHN_RP_NAME: ${WEBAUTHN_RP_NAME:-RideBus DE}
|
||||
WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID:-localhost}
|
||||
WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN:-http://localhost,http://localhost:3001}
|
||||
FCM_ENABLED: "${FCM_ENABLED:-false}"
|
||||
FCM_TOPIC: "${FCM_TOPIC:-route_alerts}"
|
||||
FCM_ANDROID_CHANNEL_ID: "${FCM_ANDROID_CHANNEL_ID:-route_alerts}"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Polotsk Transit API
|
||||
title: RideBus DE API
|
||||
version: 1.0.0-draft
|
||||
description: |
|
||||
Черновик OpenAPI 3.0, восстановленный по реальному коду проекта.
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/bus-icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Polotsk Transit Admin Panel" />
|
||||
<title>Polotsk Transit Admin</title>
|
||||
<meta name="description" content="RideBus DE Admin Panel" />
|
||||
<title>RideBus DE Admin</title>
|
||||
|
||||
<!-- Leaflet CSS -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
|
||||
3383
frontend/package-lock.json
generated
Normal file
3383
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"name": "polotsk-transit-admin",
|
||||
"version": "1.0.0",
|
||||
"description": "Admin panel for Polotsk Transit API",
|
||||
"description": "Admin panel for RideBus DE API",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@simplewebauthn/browser": "^14.0.0",
|
||||
"@tanstack/react-table": "^8.10.7",
|
||||
"axios": "^1.6.2",
|
||||
"date-fns": "^2.30.0",
|
||||
|
||||
13
frontend/src/assets/login-devices.svg
Normal file
13
frontend/src/assets/login-devices.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="71.9698" height="44.59" viewBox="0 0 71.9698 44.59" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Component 1" clip-path="url(#clip0_0_38)">
|
||||
<path id="Vector" d="M13.1939 37.3605C12.2313 37.3605 11.4509 36.5784 11.4509 35.6138V32.1203H13.1939V34.789C13.1939 35.2177 13.5407 35.5652 13.9686 35.5652H20.6499V37.3605H13.1939Z" fill="black"/>
|
||||
<path id="Vector_2" d="M13.1939 14.9927H11.4509V1.74672C11.4509 0.782035 12.2313 0 13.1939 0H70.2269C71.1895 0 71.9698 0.782034 71.9698 1.74672V35.6138C71.9698 36.5784 71.1895 37.3605 70.2269 37.3605H43.8407V35.5652H69.4522C69.88 35.5652 70.2269 35.2176 70.2269 34.789V2.52304C70.2269 2.0943 69.88 1.74672 69.4522 1.74672H13.9686C13.5407 1.74672 13.1939 2.0943 13.1939 2.52305V14.9927Z" fill="black"/>
|
||||
<path id="Vector_3" d="M3.9632 13.6099C2.93218 13.6099 2.06433 14.3832 1.94373 15.4094L0.0121967 31.8432C-0.109861 32.8817 0.699761 33.7943 1.74317 33.7943H11.4146C11.4146 33.7943 11.1878 36.8948 8.76968 39.2469C8.42297 39.584 8.64475 40.2717 9.12781 40.2717H20.6499V35.4686C20.3395 34.4883 20.2867 33.7943 20.2867 33.7943H20.6499V32.0718H2.68799C2.10817 32.0718 1.65833 31.5646 1.72639 30.9875L3.50481 15.9091C3.55665 15.4695 3.92852 15.1383 4.37025 15.1383H27.5694C28.0509 15.1383 28.4592 15.4928 28.5278 15.9704L29.9001 25.5216H31.6475L30.0464 15.3307C29.8907 14.3399 29.0387 13.6099 28.0377 13.6099H3.9632Z" fill="black"/>
|
||||
<path id="Vector_4" fill-rule="evenodd" clip-rule="evenodd" d="M20.7951 44.59C19.8325 44.59 19.0521 43.808 19.0521 42.8432V25.6672C19.0521 24.7025 19.8325 23.9204 20.7951 23.9204H43.6954C44.658 23.9204 45.4384 24.7025 45.4384 25.6672V42.8432C45.4384 43.808 44.658 44.59 43.6954 44.59H20.7951ZM21.6182 25.5216C21.0833 25.5216 20.6499 25.9561 20.6499 26.492V42.0184C20.6499 42.5543 21.0833 42.9888 21.6182 42.9888H42.8723C43.4071 42.9888 43.8407 42.5543 43.8407 42.0184V26.492C43.8407 25.9561 43.4071 25.5216 42.8723 25.5216H21.6182Z" fill="black"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_0_38">
|
||||
<rect width="71.9698" height="44.59" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -1,26 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generated by Pixelmator Pro 4.3 -->
|
||||
<svg viewBox="0 0 168 34" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg viewBox="0 0 120 29" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="g1">
|
||||
<g id="g2">
|
||||
<path id="path1" fill="#262626" stroke="none" d="M 37.050182 25.721401 L 37.050182 13.416975 L 39.763466 13.416975 L 39.763466 16.445757 L 39.447968 16.06716 C 39.637268 15.625462 39.826565 15.246862 40.078964 14.931366 C 40.331364 14.552767 40.646858 14.300369 41.025459 14.04797 C 41.340958 13.858671 41.656456 13.732471 41.971954 13.606272 C 42.350552 13.480074 42.66605 13.416975 43.044647 13.416975 C 43.423244 13.416975 43.801842 13.416975 44.180439 13.416975 L 44.180439 16.319557 C 43.864944 16.193357 43.423244 16.193357 42.981548 16.193357 C 42.539848 16.256458 42.098152 16.319557 41.782654 16.508856 C 41.404057 16.698156 41.088558 16.887453 40.836159 17.202951 C 40.583763 17.455351 40.394463 17.833948 40.268265 18.149446 C 40.142063 18.528046 40.078964 18.969742 40.078964 19.411438 L 40.078964 25.658302 L 37.050182 25.658302 Z"/>
|
||||
<path id="path2" fill="#262626" stroke="none" d="M 46.641327 11.713284 L 46.641327 9 L 49.733208 9 L 49.733208 11.713284 L 46.641327 11.713284 Z M 46.641327 25.721401 L 46.641327 13.416975 L 49.733208 13.416975 L 49.733208 25.658302 L 46.641327 25.658302 Z"/>
|
||||
<path id="path3" fill="#262626" stroke="none" d="M 58.062359 26.0369 C 56.926563 26.0369 55.916973 25.721401 55.096676 25.216606 C 54.276382 24.648708 53.582283 23.891514 53.140587 22.88192 C 52.635792 21.935425 52.446491 20.799631 52.446491 19.600739 C 52.446491 18.338745 52.698891 17.266052 53.140587 16.256458 C 53.582283 15.246862 54.276382 14.489668 55.159779 13.984871 C 56.043167 13.480074 57.052765 13.164576 58.251656 13.164576 C 59.387451 13.164576 60.397045 13.480074 61.217342 14.04797 C 62.037632 14.615868 62.60553 15.373062 63.047234 16.382658 C 63.425827 17.329151 63.678226 18.464945 63.678226 19.663837 C 63.678226 20.86273 63.488926 21.998524 63.047234 22.945019 C 62.60553 23.891514 62.037632 24.711807 61.217342 25.279705 C 60.333946 25.784502 59.324352 26.0369 58.062359 26.0369 Z M 58.567154 23.323616 C 59.261253 23.323616 59.829147 23.197418 60.207748 22.88192 C 60.649445 22.566422 60.901844 22.124722 61.091141 21.556828 C 61.280441 20.98893 61.34354 20.357933 61.34354 19.600739 C 61.34354 18.843544 61.280441 18.212547 61.091141 17.64465 C 60.901844 17.076752 60.649445 16.635056 60.207748 16.319557 C 59.829147 16.004059 59.261253 15.877859 58.630257 15.877859 C 57.936157 15.877859 57.368263 16.06716 56.926563 16.382658 C 56.484871 16.698156 56.169373 17.139853 55.916973 17.707748 C 55.727669 18.275646 55.601475 18.906643 55.601475 19.600739 C 55.601475 20.294834 55.727669 20.925831 55.916973 21.493727 C 56.106274 22.061625 56.421772 22.503321 56.863464 22.818819 C 57.368263 23.134317 57.936157 23.323616 58.567154 23.323616 Z M 61.406639 25.721401 L 61.406639 17.329151 L 61.028042 17.329151 L 61.028042 9.378597 L 64.119926 9.378597 L 64.119926 25.721401 L 61.406639 25.721401 Z"/>
|
||||
<path id="path4" fill="#262626" stroke="none" d="M 73.016968 26.0369 C 71.754974 26.0369 70.682289 25.784502 69.672691 25.216606 C 68.726196 24.648708 67.969002 23.954613 67.4011 23.008118 C 66.833206 22.061625 66.580811 20.98893 66.580811 19.726936 C 66.580811 18.401844 66.833206 17.202951 67.4011 16.256458 C 67.905899 15.246862 68.663094 14.489668 69.609589 13.92177 C 70.556084 13.353874 71.628784 13.101477 72.827675 13.101477 C 74.089668 13.101477 75.225456 13.416975 76.108856 13.984871 C 76.992249 14.615868 77.686348 15.436163 78.128036 16.508856 C 78.56974 17.581551 78.695938 18.906643 78.56974 20.357933 L 75.540955 20.357933 L 75.540955 19.222141 C 75.540955 17.960148 75.351662 17.076752 74.973061 16.508856 C 74.59446 15.94096 73.900368 15.68856 73.016968 15.68856 C 71.944283 15.68856 71.18708 16.004059 70.682289 16.635056 C 70.17749 17.266052 69.925087 18.212547 69.925087 19.474539 C 69.925087 20.610332 70.17749 21.493727 70.682289 22.124722 C 71.18708 22.755718 71.88118 23.071217 72.89077 23.071217 C 73.521767 23.071217 74.026566 22.945019 74.468262 22.692619 C 74.909958 22.44022 75.225456 22.061625 75.477859 21.556828 L 78.56974 22.44022 C 78.128036 23.576015 77.37085 24.45941 76.361252 25.027306 C 75.288559 25.721401 74.215866 26.0369 73.016968 26.0369 Z M 68.915497 20.421034 L 68.915497 18.149446 L 77.055351 18.149446 L 77.055351 20.421034 L 68.915497 20.421034 Z"/>
|
||||
<path id="path5" fill="#262626" stroke="none" d="M 81.093727 25.721401 L 81.093727 9.378597 L 84.185608 9.378597 L 84.185608 17.329151 L 83.807007 17.329151 L 83.807007 25.721401 L 81.093727 25.721401 Z M 87.151291 26.0369 C 85.952393 26.0369 84.942802 25.721401 84.122505 25.216606 C 83.302216 24.648708 82.671219 23.891514 82.292618 22.88192 C 81.914017 21.872326 81.661621 20.799631 81.661621 19.600739 C 81.661621 18.401844 81.850922 17.266052 82.292618 16.319557 C 82.671219 15.373062 83.302216 14.552767 84.122505 13.984871 C 84.942802 13.416975 85.889297 13.101477 87.088188 13.101477 C 88.287086 13.101477 89.296677 13.353874 90.180077 13.92177 C 91.063461 14.489668 91.757561 15.246862 92.199257 16.193357 C 92.640953 17.139853 92.893356 18.275646 92.893356 19.53764 C 92.893356 20.73653 92.640953 21.872326 92.199257 22.818819 C 91.694458 23.765312 91.063461 24.585609 90.243172 25.153507 C 89.233582 25.784502 88.287086 26.0369 87.151291 26.0369 Z M 86.646492 23.323616 C 87.340591 23.323616 87.845383 23.134317 88.287086 22.818819 C 88.728783 22.503321 89.044281 22.061625 89.233582 21.493727 C 89.422874 20.925831 89.54908 20.294834 89.54908 19.600739 C 89.54908 18.906643 89.422874 18.275646 89.233582 17.707748 C 89.044281 17.139853 88.66568 16.698156 88.223984 16.382658 C 87.782288 16.06716 87.214386 15.877859 86.520294 15.877859 C 85.889297 15.877859 85.384499 16.004059 84.942802 16.319557 C 84.564201 16.635056 84.248711 17.076752 84.059402 17.64465 C 83.87011 18.212547 83.807007 18.843544 83.807007 19.600739 C 83.807007 20.357933 83.87011 20.98893 84.059402 21.556828 C 84.248711 22.124722 84.564201 22.566422 84.942802 22.88192 C 85.384499 23.134317 85.952393 23.323616 86.646492 23.323616 Z"/>
|
||||
<path id="path6" fill="#262626" stroke="none" d="M 99.83432 26.1 C 98.887825 26.1 98.067528 25.910702 97.499626 25.595203 C 96.868629 25.279705 96.426933 24.838009 96.111435 24.33321 C 95.795937 23.828413 95.543541 23.323616 95.417343 22.755718 C 95.291138 22.187822 95.16494 21.746126 95.16494 21.304428 C 95.16494 20.86273 95.101845 20.547232 95.101845 20.357933 L 95.101845 13.416975 L 98.256828 13.416975 L 98.256828 19.222141 C 98.256828 19.474539 98.256828 19.853138 98.319923 20.294834 C 98.383026 20.73653 98.446121 21.178228 98.635422 21.619926 C 98.824722 22.061625 99.077118 22.44022 99.392616 22.692619 C 99.771217 23.008118 100.276016 23.134317 100.907013 23.134317 C 101.159409 23.134317 101.474907 23.071217 101.790405 23.008118 C 102.105896 22.945019 102.358307 22.755718 102.610703 22.503321 C 102.863091 22.250923 103.115501 21.935425 103.241692 21.430628 C 103.430992 20.98893 103.494087 20.357933 103.494087 19.600739 L 105.26088 20.421034 C 105.26088 21.367527 105.071579 22.314022 104.692986 23.134317 C 104.314384 23.954613 103.683388 24.648708 102.926193 25.216606 C 102.169006 25.784502 101.033211 26.1 99.83432 26.1 Z M 103.809586 25.721401 L 103.809586 21.619926 L 103.430992 21.619926 L 103.430992 13.416975 L 106.522873 13.416975 L 106.522873 25.658302 L 103.809586 25.658302 Z"/>
|
||||
<path id="path7" fill="#262626" stroke="none" d="M 114.536522 26.0369 C 113.022141 26.0369 111.760147 25.658302 110.813652 24.964207 C 109.867157 24.270111 109.299263 23.260517 109.109962 21.998524 L 112.264946 21.493727 C 112.391144 22.061625 112.706642 22.503321 113.148338 22.818819 C 113.590034 23.134317 114.157936 23.323616 114.85202 23.323616 C 115.41993 23.323616 115.861618 23.197418 116.177116 23.008118 C 116.492615 22.818819 116.618813 22.503321 116.618813 22.124722 C 116.618813 21.872326 116.555717 21.683025 116.429512 21.556828 C 116.303314 21.430628 116.050919 21.241329 115.60923 21.115129 C 115.167519 20.98893 114.536522 20.799631 113.653137 20.547232 C 112.643539 20.294834 111.82325 19.979336 111.255348 19.663837 C 110.687454 19.348339 110.245758 18.969742 109.93026 18.528046 C 109.614761 18.086346 109.551659 17.51845 109.551659 16.950554 C 109.551659 16.193357 109.740959 15.499262 110.182655 14.931366 C 110.624352 14.363468 111.12915 13.92177 111.886345 13.606272 C 112.643539 13.290775 113.463837 13.164576 114.473434 13.164576 C 115.41993 13.164576 116.303314 13.290775 116.997414 13.606272 C 117.754608 13.92177 118.322502 14.300369 118.827301 14.868265 C 119.268997 15.436163 119.584496 16.06716 119.647598 16.824354 L 116.492615 17.39225 C 116.429512 16.950554 116.240227 16.571957 115.924728 16.319557 C 115.60923 16.06716 115.104431 15.877859 114.536522 15.877859 C 113.968636 15.81476 113.526939 15.94096 113.148338 16.130259 C 112.83284 16.319557 112.643539 16.571957 112.643539 16.950554 C 112.643539 17.139853 112.706642 17.329151 112.895943 17.455351 C 113.022141 17.581551 113.337639 17.770849 113.842438 17.897049 C 114.284134 18.023247 115.041321 18.275646 115.987816 18.464945 C 116.934311 18.717342 117.691505 18.969742 118.259407 19.28524 C 118.827301 19.600739 119.205902 19.979336 119.5214 20.421034 C 119.773796 20.86273 119.899994 21.430628 119.899994 22.061625 C 119.899994 23.323616 119.458298 24.33321 118.511803 25.027306 C 117.376007 25.658302 116.114014 26.0369 114.536522 26.0369 Z"/>
|
||||
<path id="path1" fill="#262626" stroke="none" d="M 37.050182 20.721401 L 37.050182 8.416975 L 39.763466 8.416975 L 39.763466 11.445757 L 39.447968 11.06716 C 39.637268 10.625462 39.826565 10.246862 40.078964 9.931366 C 40.331364 9.552767 40.646858 9.300369 41.025459 9.04797 C 41.340958 8.858671 41.656456 8.732471 41.971954 8.606272 C 42.350552 8.480074 42.66605 8.416975 43.044647 8.416975 C 43.423244 8.416975 43.801842 8.416975 44.180439 8.416975 L 44.180439 11.319557 C 43.864944 11.193357 43.423244 11.193357 42.981548 11.193357 C 42.539848 11.256458 42.098152 11.319557 41.782654 11.508856 C 41.404057 11.698156 41.088558 11.887453 40.836159 12.202951 C 40.583763 12.455351 40.394463 12.833948 40.268265 13.149446 C 40.142063 13.528045 40.078964 13.969741 40.078964 14.411439 L 40.078964 20.658302 L 37.050182 20.658302 Z"/>
|
||||
<path id="path2" fill="#262626" stroke="none" d="M 46.641327 6.713284 L 46.641327 4 L 49.733208 4 L 49.733208 6.713284 L 46.641327 6.713284 Z M 46.641327 20.721401 L 46.641327 8.416975 L 49.733208 8.416975 L 49.733208 20.658302 L 46.641327 20.658302 Z"/>
|
||||
<path id="path3" fill="#262626" stroke="none" d="M 58.062359 21.0369 C 56.926563 21.0369 55.916973 20.721401 55.096676 20.216606 C 54.276382 19.648708 53.582283 18.891514 53.140587 17.88192 C 52.635792 16.935425 52.446491 15.799631 52.446491 14.600738 C 52.446491 13.338745 52.698891 12.266052 53.140587 11.256458 C 53.582283 10.246862 54.276382 9.489668 55.159779 8.984871 C 56.043167 8.480074 57.052765 8.164576 58.251656 8.164576 C 59.387451 8.164576 60.397045 8.480074 61.217342 9.04797 C 62.037632 9.615868 62.60553 10.373062 63.047234 11.382658 C 63.425827 12.329151 63.678226 13.464945 63.678226 14.663837 C 63.678226 15.86273 63.488926 16.998524 63.047234 17.945019 C 62.60553 18.891514 62.037632 19.711807 61.217342 20.279705 C 60.333946 20.784502 59.324352 21.0369 58.062359 21.0369 Z M 58.567154 18.323616 C 59.261253 18.323616 59.829147 18.197418 60.207748 17.88192 C 60.649445 17.566422 60.901844 17.124722 61.091141 16.556828 C 61.280441 15.98893 61.34354 15.357934 61.34354 14.600738 C 61.34354 13.843543 61.280441 13.212546 61.091141 12.64465 C 60.901844 12.076752 60.649445 11.635056 60.207748 11.319557 C 59.829147 11.004059 59.261253 10.877859 58.630257 10.877859 C 57.936157 10.877859 57.368263 11.06716 56.926563 11.382658 C 56.484871 11.698156 56.169373 12.139853 55.916973 12.707748 C 55.727669 13.275646 55.601475 13.906643 55.601475 14.600738 C 55.601475 15.294834 55.727669 15.92583 55.916973 16.493727 C 56.106274 17.061625 56.421772 17.503321 56.863464 17.818819 C 57.368263 18.134317 57.936157 18.323616 58.567154 18.323616 Z M 61.406639 20.721401 L 61.406639 12.329151 L 61.028042 12.329151 L 61.028042 4.378597 L 64.119926 4.378597 L 64.119926 20.721401 L 61.406639 20.721401 Z"/>
|
||||
<path id="path4" fill="#262626" stroke="none" d="M 73.016968 21.0369 C 71.754974 21.0369 70.682289 20.784502 69.672691 20.216606 C 68.726196 19.648708 67.969002 18.954613 67.4011 18.008118 C 66.833206 17.061625 66.580811 15.98893 66.580811 14.726937 C 66.580811 13.401845 66.833206 12.202951 67.4011 11.256458 C 67.905899 10.246862 68.663094 9.489668 69.609589 8.92177 C 70.556084 8.353874 71.628784 8.101477 72.827675 8.101477 C 74.089668 8.101477 75.225456 8.416975 76.108856 8.984871 C 76.992249 9.615868 77.686348 10.436163 78.128036 11.508856 C 78.56974 12.581551 78.695938 13.906643 78.56974 15.357934 L 75.540955 15.357934 L 75.540955 14.22214 C 75.540955 12.960148 75.351662 12.076752 74.973061 11.508856 C 74.59446 10.94096 73.900368 10.68856 73.016968 10.68856 C 71.944283 10.68856 71.18708 11.004059 70.682289 11.635056 C 70.17749 12.266052 69.925087 13.212546 69.925087 14.474539 C 69.925087 15.610332 70.17749 16.493727 70.682289 17.124722 C 71.18708 17.755718 71.88118 18.071217 72.89077 18.071217 C 73.521767 18.071217 74.026566 17.945019 74.468262 17.692619 C 74.909958 17.44022 75.225456 17.061625 75.477859 16.556828 L 78.56974 17.44022 C 78.128036 18.576015 77.37085 19.45941 76.361252 20.027306 C 75.288559 20.721401 74.215866 21.0369 73.016968 21.0369 Z M 68.915497 15.421034 L 68.915497 13.149446 L 77.055351 13.149446 L 77.055351 15.421034 L 68.915497 15.421034 Z"/>
|
||||
<path id="path5" fill="#262626" stroke="none" d="M 81.093727 20.721401 L 81.093727 4.378597 L 84.185608 4.378597 L 84.185608 12.329151 L 83.807007 12.329151 L 83.807007 20.721401 L 81.093727 20.721401 Z M 87.151291 21.0369 C 85.952393 21.0369 84.942802 20.721401 84.122505 20.216606 C 83.302216 19.648708 82.671219 18.891514 82.292618 17.88192 C 81.914017 16.872326 81.661621 15.799631 81.661621 14.600738 C 81.661621 13.401845 81.850922 12.266052 82.292618 11.319557 C 82.671219 10.373062 83.302216 9.552767 84.122505 8.984871 C 84.942802 8.416975 85.889297 8.101477 87.088188 8.101477 C 88.287086 8.101477 89.296677 8.353874 90.180077 8.92177 C 91.063461 9.489668 91.757561 10.246862 92.199257 11.193357 C 92.640953 12.139853 92.893356 13.275646 92.893356 14.537639 C 92.893356 15.736531 92.640953 16.872326 92.199257 17.818819 C 91.694458 18.765312 91.063461 19.585609 90.243172 20.153507 C 89.233582 20.784502 88.287086 21.0369 87.151291 21.0369 Z M 86.646492 18.323616 C 87.340591 18.323616 87.845383 18.134317 88.287086 17.818819 C 88.728783 17.503321 89.044281 17.061625 89.233582 16.493727 C 89.422874 15.92583 89.54908 15.294834 89.54908 14.600738 C 89.54908 13.906643 89.422874 13.275646 89.233582 12.707748 C 89.044281 12.139853 88.66568 11.698156 88.223984 11.382658 C 87.782288 11.06716 87.214386 10.877859 86.520294 10.877859 C 85.889297 10.877859 85.384499 11.004059 84.942802 11.319557 C 84.564201 11.635056 84.248711 12.076752 84.059402 12.64465 C 83.87011 13.212546 83.807007 13.843543 83.807007 14.600738 C 83.807007 15.357934 83.87011 15.98893 84.059402 16.556828 C 84.248711 17.124722 84.564201 17.566422 84.942802 17.88192 C 85.384499 18.134317 85.952393 18.323616 86.646492 18.323616 Z"/>
|
||||
<path id="path6" fill="#262626" stroke="none" d="M 99.83432 21.1 C 98.887825 21.1 98.067528 20.910702 97.499626 20.595203 C 96.868629 20.279705 96.426933 19.838009 96.111435 19.33321 C 95.795937 18.828413 95.543541 18.323616 95.417343 17.755718 C 95.291138 17.187822 95.16494 16.746126 95.16494 16.304428 C 95.16494 15.86273 95.101845 15.547232 95.101845 15.357934 L 95.101845 8.416975 L 98.256828 8.416975 L 98.256828 14.22214 C 98.256828 14.474539 98.256828 14.853137 98.319923 15.294834 C 98.383026 15.736531 98.446121 16.178228 98.635422 16.619926 C 98.824722 17.061625 99.077118 17.44022 99.392616 17.692619 C 99.771217 18.008118 100.276016 18.134317 100.907013 18.134317 C 101.159409 18.134317 101.474907 18.071217 101.790405 18.008118 C 102.105896 17.945019 102.358307 17.755718 102.610703 17.503321 C 102.863091 17.250923 103.115501 16.935425 103.241692 16.430628 C 103.430992 15.98893 103.494087 15.357934 103.494087 14.600738 L 105.26088 15.421034 C 105.26088 16.367527 105.071579 17.314022 104.692986 18.134317 C 104.314384 18.954613 103.683388 19.648708 102.926193 20.216606 C 102.169006 20.784502 101.033211 21.1 99.83432 21.1 Z M 103.809586 20.721401 L 103.809586 16.619926 L 103.430992 16.619926 L 103.430992 8.416975 L 106.522873 8.416975 L 106.522873 20.658302 L 103.809586 20.658302 Z"/>
|
||||
<path id="path7" fill="#262626" stroke="none" d="M 114.536522 21.0369 C 113.022141 21.0369 111.760147 20.658302 110.813652 19.964207 C 109.867157 19.270111 109.299263 18.260517 109.109962 16.998524 L 112.264946 16.493727 C 112.391144 17.061625 112.706642 17.503321 113.148338 17.818819 C 113.590034 18.134317 114.157936 18.323616 114.85202 18.323616 C 115.41993 18.323616 115.861618 18.197418 116.177116 18.008118 C 116.492615 17.818819 116.618813 17.503321 116.618813 17.124722 C 116.618813 16.872326 116.555717 16.683025 116.429512 16.556828 C 116.303314 16.430628 116.050919 16.241329 115.60923 16.115129 C 115.167519 15.98893 114.536522 15.799631 113.653137 15.547232 C 112.643539 15.294834 111.82325 14.979336 111.255348 14.663837 C 110.687454 14.348339 110.245758 13.969741 109.93026 13.528045 C 109.614761 13.086347 109.551659 12.51845 109.551659 11.950554 C 109.551659 11.193357 109.740959 10.499262 110.182655 9.931366 C 110.624352 9.363468 111.12915 8.92177 111.886345 8.606272 C 112.643539 8.290775 113.463837 8.164576 114.473434 8.164576 C 115.41993 8.164576 116.303314 8.290775 116.997414 8.606272 C 117.754608 8.92177 118.322502 9.300369 118.827301 9.868265 C 119.268997 10.436163 119.584496 11.06716 119.647598 11.824354 L 116.492615 12.39225 C 116.429512 11.950554 116.240227 11.571957 115.924728 11.319557 C 115.60923 11.06716 115.104431 10.877859 114.536522 10.877859 C 113.968636 10.81476 113.526939 10.94096 113.148338 11.130259 C 112.83284 11.319557 112.643539 11.571957 112.643539 11.950554 C 112.643539 12.139853 112.706642 12.329151 112.895943 12.455351 C 113.022141 12.581551 113.337639 12.770849 113.842438 12.897049 C 114.284134 13.023247 115.041321 13.275646 115.987816 13.464945 C 116.934311 13.717343 117.691505 13.969741 118.259407 14.285239 C 118.827301 14.600738 119.205902 14.979336 119.5214 15.421034 C 119.773796 15.86273 119.899994 16.430628 119.899994 17.061625 C 119.899994 18.323616 119.458298 19.33321 118.511803 20.027306 C 117.376007 20.658302 116.114014 21.0369 114.536522 21.0369 Z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="g3">
|
||||
<g id="g4">
|
||||
<clipPath id="clipPath1">
|
||||
<path d="M -0.1 5 L 28.9 5 L 28.9 34 L -0.1 34 Z"/>
|
||||
<path d="M -0.1 0 L 28.9 0 L 28.9 29 L -0.1 29 Z"/>
|
||||
</clipPath>
|
||||
<g id="g5" clip-path="url(#clipPath1)">
|
||||
<path id="path8" fill="#2082c4" stroke="none" d="M 10.6 5.700001 L 20.5 6.5 C 25.200001 6.799999 28.700001 10.9 28.299999 15.6 L 27.5 25.5 C 27.1 30.200001 23.1 33.700001 18.4 33.299999 L 8.5 32.5 C 3.9 32.200001 0.4 28 0.7 23.299999 L 1.5 13.4 C 1.9 8.799999 6 5.299999 10.6 5.700001 Z"/>
|
||||
<path id="path8" fill="#2082c4" stroke="none" d="M 10.6 0.700001 L 20.5 1.5 C 25.200001 1.799999 28.700001 5.9 28.299999 10.6 L 27.5 20.5 C 27.1 25.200001 23.1 28.700001 18.4 28.299999 L 8.5 27.5 C 3.9 27.200001 0.4 23 0.7 18.299999 L 1.5 8.4 C 1.9 3.799999 6 0.299999 10.6 0.700001 Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<path id="path9" fill="#ffffff" stroke="none" d="M 9.8 27.200001 C 9 27.200001 8.7 26.9 8.7 26.299999 C 8.7 25.9 8.7 25.5 8.7 25.1 C 8.7 25 8.6 25 8.6 24.799999 C 8.1 24.200001 7.8 23.4 7.9 22.700001 C 8 20.200001 7.9 17.700001 7.9 15.1 C 7.9 14.700001 8 14.299999 8 14 C 8.3 13.1 9 12.5 9.9 12.200001 C 11 11.9 12 11.700001 13.2 11.799999 C 14.6 11.799999 16 11.799999 17.299999 11.9 C 18.299999 12 19.4 12.200001 20.200001 13 C 20.299999 13.1 20.5 13.299999 20.5 13.4 C 20.9 14 21.1 14.9 21 15.700001 C 21 16.9 21 18 21 19.1 L 21 19.1 C 21 20.4 21 21.6 21 23 C 21 23.6 20.700001 24.4 20.299999 24.9 C 20 25 20 25.200001 20 25.4 C 20 25.799999 20 26.200001 20 26.4 C 20 26.700001 19.9 27 19.6 27.200001 C 19.5 27.299999 19.299999 27.299999 19.200001 27.299999 C 18.9 27.299999 18.6 27.299999 18.200001 27.299999 C 17.700001 27.299999 17.4 26.9 17.299999 26.5 C 17.299999 26.299999 17.299999 26.1 17.299999 25.9 C 17.299999 25.799999 17.200001 25.799999 17.200001 25.700001 L 17.200001 25.700001 L 11.3 25.700001 C 11.2 25.700001 11.1 25.700001 11.2 25.9 C 11.2 26 11.2 26.299999 11.2 26.4 C 11.2 26.9 10.8 27.299999 10.4 27.299999 M 14.4 15.1 L 14.4 15.1 C 12.8 15.1 11.3 15.1 9.8 15.1 C 9.7 15.1 9.5 15.200001 9.5 15.4 C 9.5 16.5 9.5 17.700001 9.5 18.9 C 9.5 19 9.6 19.200001 9.8 19.200001 C 12.9 19.200001 15.9 19.200001 19 19.200001 C 19.200001 19.200001 19.299999 19.1 19.299999 18.9 C 19.299999 17.799999 19.299999 16.6 19.299999 15.5 C 19.299999 15.4 19.200001 15.200001 19 15.200001 L 19 15.200001 C 17.4 15.1 15.9 15.1 14.4 15.1 Z M 18.200001 24 C 18.799999 23.9 19.299999 23.4 19.200001 22.700001 C 19.1 22 18.6 21.6 17.9 21.700001 C 17.799999 21.700001 17.799999 21.700001 17.6 21.700001 C 17 21.9 16.6 22.6 16.799999 23.200001 C 17.1 23.6 17.6 24 18.200001 24 L 18.200001 24 Z M 10.5 24 C 11.1 24.1 11.8 23.700001 11.9 23 C 12 22.4 11.6 21.700001 11 21.6 C 10.4 21.5 9.7 21.9 9.6 22.6 C 9.5 22.700001 9.5 22.700001 9.5 22.9 C 9.6 23.4 10 23.9 10.5 24 L 10.5 24 Z M 13 23.1 C 12.9 23.6 13.3 24.1 13.7 24.299999 C 14.1 24.6 14.8 24.4 15.2 24.200001 C 15.6 23.9 15.8 23.6 15.7 23.1 L 15.1 23.1 C 15 23.6 14.7 23.9 14.3 23.9 C 13.9 23.9 13.7 23.700001 13.5 23.1 C 13.6 23.1 13 23.1 13 23.1 Z"/>
|
||||
<path id="path9" fill="#ffffff" stroke="none" d="M 9.8 22.200001 C 9 22.200001 8.7 21.9 8.7 21.299999 C 8.7 20.9 8.7 20.5 8.7 20.1 C 8.7 20 8.6 20 8.6 19.799999 C 8.1 19.200001 7.8 18.4 7.9 17.700001 C 8 15.200001 7.9 12.700001 7.9 10.1 C 7.9 9.700001 8 9.299999 8 9 C 8.3 8.1 9 7.5 9.9 7.200001 C 11 6.9 12 6.700001 13.2 6.799999 C 14.6 6.799999 16 6.799999 17.299999 6.9 C 18.299999 7 19.4 7.200001 20.200001 8 C 20.299999 8.1 20.5 8.299999 20.5 8.4 C 20.9 9 21.1 9.9 21 10.700001 C 21 11.9 21 13 21 14.1 L 21 14.1 C 21 15.4 21 16.6 21 18 C 21 18.6 20.700001 19.4 20.299999 19.9 C 20 20 20 20.200001 20 20.4 C 20 20.799999 20 21.200001 20 21.4 C 20 21.700001 19.9 22 19.6 22.200001 C 19.5 22.299999 19.299999 22.299999 19.200001 22.299999 C 18.9 22.299999 18.6 22.299999 18.200001 22.299999 C 17.700001 22.299999 17.4 21.9 17.299999 21.5 C 17.299999 21.299999 17.299999 21.1 17.299999 20.9 C 17.299999 20.799999 17.200001 20.799999 17.200001 20.700001 L 17.200001 20.700001 L 11.3 20.700001 C 11.2 20.700001 11.1 20.700001 11.2 20.9 C 11.2 21 11.2 21.299999 11.2 21.4 C 11.2 21.9 10.8 22.299999 10.4 22.299999 M 14.4 10.1 L 14.4 10.1 C 12.8 10.1 11.3 10.1 9.8 10.1 C 9.7 10.1 9.5 10.200001 9.5 10.4 C 9.5 11.5 9.5 12.700001 9.5 13.9 C 9.5 14 9.6 14.200001 9.8 14.200001 C 12.9 14.200001 15.9 14.200001 19 14.200001 C 19.200001 14.200001 19.299999 14.1 19.299999 13.9 C 19.299999 12.799999 19.299999 11.6 19.299999 10.5 C 19.299999 10.4 19.200001 10.200001 19 10.200001 L 19 10.200001 C 17.4 10.1 15.9 10.1 14.4 10.1 Z M 18.200001 19 C 18.799999 18.9 19.299999 18.4 19.200001 17.700001 C 19.1 17 18.6 16.6 17.9 16.700001 C 17.799999 16.700001 17.799999 16.700001 17.6 16.700001 C 17 16.9 16.6 17.6 16.799999 18.200001 C 17.1 18.6 17.6 19 18.200001 19 L 18.200001 19 Z M 10.5 19 C 11.1 19.1 11.8 18.700001 11.9 18 C 12 17.4 11.6 16.700001 11 16.6 C 10.4 16.5 9.7 16.9 9.6 17.6 C 9.5 17.700001 9.5 17.700001 9.5 17.9 C 9.6 18.4 10 18.9 10.5 19 L 10.5 19 Z M 13 18.1 C 12.9 18.6 13.3 19.1 13.7 19.299999 C 14.1 19.6 14.8 19.4 15.2 19.200001 C 15.6 18.9 15.8 18.6 15.7 18.1 L 15.1 18.1 C 15 18.6 14.7 18.9 14.3 18.9 C 13.9 18.9 13.7 18.700001 13.5 18.1 C 13.6 18.1 13 18.1 13 18.1 Z"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
8
frontend/src/assets/passkey.svg
Normal file
8
frontend/src/assets/passkey.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="15.7167" height="16.8839" viewBox="0 0 15.7167 16.8839" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Group">
|
||||
<path id="Vector" fill-rule="evenodd" clip-rule="evenodd" d="M15.7067 6.84388C15.7067 8.37499 14.7644 9.67721 13.4522 10.1528L14.2456 11.4672L13.0711 12.9117L14.2456 14.3239L12.3522 16.865L11.0178 15.4405V12.5628V10.0561C9.83111 9.51721 9.00111 8.28166 9.00111 6.84388C9.00111 4.91055 10.5022 3.34277 12.3544 3.34277C14.2056 3.34277 15.7067 4.91055 15.7067 6.84388ZM12.3533 7.37944C12.8 7.37944 13.1622 7.00166 13.1622 6.53499C13.1622 6.06833 12.8 5.68944 12.3533 5.68944C11.9067 5.68944 11.5444 6.06721 11.5444 6.53499C11.5433 7.00166 11.9067 7.37944 12.3533 7.37944Z" fill="white"/>
|
||||
<path id="Vector_2" fill-rule="evenodd" clip-rule="evenodd" d="M15.7167 6.85388C15.7167 8.36721 14.8 9.65721 13.5133 10.1505L14.2444 11.4661L13.1633 12.9105L14.2444 14.3228L12.3522 16.8839V14.0061V10.365V7.37944C12.7989 7.37944 13.1611 7.00055 13.1611 6.53499C13.1611 6.06833 12.7989 5.68944 12.3522 5.68944V3.34277C14.2111 3.34277 15.7167 4.91499 15.7167 6.85388Z" fill="#DAD9D9"/>
|
||||
<path id="Vector_3" fill-rule="evenodd" clip-rule="evenodd" d="M9.92 10.6928C8.83667 9.80388 8.10889 8.43721 8.00889 6.88499H2.20444C0.986667 6.88499 7.94729e-08 7.8861 7.94729e-08 9.12166V11.9183C7.94729e-08 12.5361 0.493334 13.0372 1.10222 13.0372H8.81778C9.42667 13.0372 9.92 12.5361 9.92 11.9183V10.6928Z" fill="white"/>
|
||||
<path id="Vector_4" d="M4.68889 6.21499C4.42 6.16388 4.15333 6.1161 3.89889 6.00833C2.93778 5.60499 2.37778 4.86166 2.19667 3.81166C2.07222 3.09277 2.13111 2.38166 2.42222 1.70944C2.83556 0.753881 3.57667 0.236104 4.55 0.0605481C5.13222 -0.0438963 5.71222 -0.0205632 6.27222 0.188326C7.11556 0.501659 7.68111 1.1061 7.94222 1.99277C8.20667 2.88721 8.16778 3.78166 7.76889 4.62833C7.35556 5.51277 6.63444 5.98721 5.72222 6.17277C5.64667 6.18833 5.57 6.20277 5.49444 6.21833C5.22667 6.21499 4.95778 6.21499 4.68889 6.21499Z" fill="white"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -59,13 +59,7 @@ export function AuthProvider({ children }) {
|
||||
password
|
||||
});
|
||||
|
||||
const { user, accessToken, refreshToken } = response.data;
|
||||
|
||||
localStorage.setItem('accessToken', accessToken);
|
||||
localStorage.setItem('refreshToken', refreshToken);
|
||||
|
||||
setUser(user);
|
||||
navigate('/');
|
||||
completeLogin(response.data);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
@@ -77,6 +71,32 @@ export function AuthProvider({ children }) {
|
||||
}
|
||||
};
|
||||
|
||||
const loginWithPasskey = async (credential) => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${API_BASE_URL}/auth/passkeys/authentication/verify`,
|
||||
{ response: credential }
|
||||
);
|
||||
|
||||
completeLogin(response.data);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Passkey login error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.error || 'Не удалось выполнить вход по passkey'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const completeLogin = ({ user, accessToken, refreshToken }) => {
|
||||
localStorage.setItem('accessToken', accessToken);
|
||||
localStorage.setItem('refreshToken', refreshToken);
|
||||
|
||||
setUser(user);
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
|
||||
@@ -102,6 +122,7 @@ export function AuthProvider({ children }) {
|
||||
user,
|
||||
loading,
|
||||
login,
|
||||
loginWithPasskey,
|
||||
logout,
|
||||
getAccessToken,
|
||||
isAuthenticated: !!user,
|
||||
|
||||
@@ -89,8 +89,187 @@
|
||||
.city-current { @apply inline-flex items-center gap-1 rounded-full bg-primary-50 px-2 py-1 text-xs font-medium text-primary-700; }
|
||||
.city-isolation-note { @apply flex items-start gap-3 rounded-xl bg-primary-50 p-4 text-sm text-primary-700; }
|
||||
.city-isolation-note svg { @apply mt-0.5 shrink-0; }
|
||||
.login-surface { @apply flex min-h-screen items-center justify-center p-4; background: #f5f5f5; }
|
||||
.login-card { @apply rounded-xl bg-white p-6 sm:p-8; }
|
||||
.login-surface {
|
||||
display: grid;
|
||||
min-height: 100dvh;
|
||||
place-items: start center;
|
||||
padding: max(32px, calc((100dvh - 595px) / 2)) 16px;
|
||||
background: #fff;
|
||||
}
|
||||
.login-layout { width: min(100%, 332px); }
|
||||
.login-card {
|
||||
width: 100%;
|
||||
padding: 40px 40px 32px;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
box-shadow: 0 24px 30px rgb(0 10 28 / 15%);
|
||||
}
|
||||
.login-brand {
|
||||
display: flex;
|
||||
width: 130px;
|
||||
height: 44px;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
margin: 0 auto 28px;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
.brand-logo.login-brand-logo { width: 130px; height: 27px; }
|
||||
.login-methods { width: 100%; }
|
||||
.login-password-choice,
|
||||
.login-passkey-button,
|
||||
.login-password-submit {
|
||||
display: inline-flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
line-height: 16px;
|
||||
transition: background-color 160ms ease, box-shadow 160ms ease, opacity 160ms ease;
|
||||
}
|
||||
.login-password-choice {
|
||||
height: 44px;
|
||||
padding: 0 16px;
|
||||
border-radius: 8px;
|
||||
color: #000;
|
||||
background: #f2f3f7;
|
||||
}
|
||||
.login-password-choice:hover:not(:disabled) { background: #e8ebf0; }
|
||||
.login-divider {
|
||||
position: relative;
|
||||
height: 1px;
|
||||
margin: 28px 0 44px;
|
||||
background: #8c98a9;
|
||||
}
|
||||
.login-divider span {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
padding: 0 16px;
|
||||
color: #8c98a9;
|
||||
background: #fff;
|
||||
font-size: 15px;
|
||||
line-height: 19px;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.login-passkey-button {
|
||||
height: 48px;
|
||||
gap: 4px;
|
||||
padding: 1px 16px;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
background: #61a246;
|
||||
}
|
||||
.login-passkey-button:hover:not(:disabled) { background: #518b3a; }
|
||||
.login-passkey-button img,
|
||||
.login-passkey-button svg { width: 24px; height: 24px; flex: 0 0 24px; }
|
||||
.login-passkey-button:disabled,
|
||||
.login-password-choice:disabled,
|
||||
.login-password-submit:disabled { opacity: .55; }
|
||||
.login-passkey-hint {
|
||||
margin: 28px 0 0;
|
||||
color: #8c98a9;
|
||||
font-size: 15px;
|
||||
line-height: 19.2px;
|
||||
text-align: center;
|
||||
}
|
||||
.login-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 0 0 16px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
color: #b42318;
|
||||
background: #fef2f2;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
}
|
||||
.login-error svg { width: 18px; height: 18px; flex: 0 0 18px; }
|
||||
.login-password-form { display: grid; gap: 16px; }
|
||||
.login-password-heading {
|
||||
display: grid;
|
||||
grid-template-columns: 32px 1fr 32px;
|
||||
align-items: center;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
.login-password-heading h2 {
|
||||
margin: 0;
|
||||
color: #000;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.login-back-button {
|
||||
display: grid;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
color: #536174;
|
||||
background: transparent;
|
||||
}
|
||||
.login-back-button:hover:not(:disabled) { background: #f2f3f7; }
|
||||
.login-back-button svg { width: 20px; height: 20px; }
|
||||
.login-field { display: grid; gap: 6px; color: #536174; font-size: 13px; line-height: 16px; }
|
||||
.login-input-wrap { position: relative; display: block; }
|
||||
.login-input-wrap svg {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 12px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #8c98a9;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.login-input-wrap input {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 0 12px 0 40px;
|
||||
border: 1px solid #c9cfd8;
|
||||
border-radius: 8px;
|
||||
color: #000;
|
||||
background: #fff;
|
||||
font-size: 15px !important;
|
||||
line-height: 20px;
|
||||
}
|
||||
.login-input-wrap input::placeholder { color: #8c98a9; }
|
||||
.login-input-wrap input:focus { border-color: #0958d9; outline: none; box-shadow: 0 0 0 2px rgb(9 88 217 / 15%); }
|
||||
.login-password-submit {
|
||||
height: 44px;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
background: #ff6900;
|
||||
font-weight: 500;
|
||||
}
|
||||
.login-password-submit:hover:not(:disabled) { background: #e95f00; }
|
||||
.login-password-submit svg { width: 18px; height: 18px; }
|
||||
.login-device-card {
|
||||
display: flex;
|
||||
min-height: 104px;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
padding: 28px 40px;
|
||||
border-radius: 12px;
|
||||
color: #000;
|
||||
background: #fff;
|
||||
box-shadow: 0 24px 30px rgb(0 10 28 / 15%);
|
||||
}
|
||||
.login-device-card img { width: 72px; height: 45px; flex: 0 0 72px; }
|
||||
.login-device-card p { margin: -1px 0 0; font-size: 15px; line-height: 20.8px; }
|
||||
.login-footer { min-height: 64px; margin-top: 16px; color: #8c98a9; font-size: 13px; line-height: 20px; text-align: center; }
|
||||
.login-footer p { margin: 0; }
|
||||
@media (max-width: 374px) {
|
||||
.login-card { padding: 28px 24px; }
|
||||
.login-device-card { padding: 24px; }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.app-main.app-main-wide { padding: 24px 16px; }
|
||||
}
|
||||
|
||||
@@ -1,17 +1,38 @@
|
||||
import { useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { startAuthentication, browserSupportsWebAuthn } from '@simplewebauthn/browser';
|
||||
import { AlertCircle, ArrowLeft, Loader2, Lock, User } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { Lock, User, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import BrandLogo from '../components/BrandLogo';
|
||||
import passkeyIcon from '../assets/passkey.svg';
|
||||
import devicesIllustration from '../assets/login-devices.svg';
|
||||
import { getApiBaseUrl } from '../utils/apiBaseUrl';
|
||||
|
||||
const API_BASE_URL = getApiBaseUrl();
|
||||
|
||||
function getPasskeyErrorMessage(error) {
|
||||
if (error?.name === 'NotAllowedError') {
|
||||
return 'Запрос на использование passkey отменён или истёк.';
|
||||
}
|
||||
|
||||
if (error?.name === 'InvalidStateError') {
|
||||
return 'Этот passkey уже используется в другом запросе. Попробуйте ещё раз.';
|
||||
}
|
||||
|
||||
return error?.response?.data?.error || error?.message || 'Не удалось выполнить вход по passkey.';
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPasswordForm, setShowPasswordForm] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
const { login, loginWithPasskey } = useAuth();
|
||||
const passkeysSupported = browserSupportsWebAuthn();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const handlePasswordLogin = async (event) => {
|
||||
event.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
@@ -23,105 +44,147 @@ export default function LoginPage() {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-surface">
|
||||
<div className="w-full max-w-md relative">
|
||||
{/* Logo and Title */}
|
||||
<div className="text-center mb-8 animate-[fadeSlideIn_0.6s_ease-out]">
|
||||
<h1 className="mb-4"><BrandLogo className="mx-auto" /><span className="sr-only">Polotsk Transit</span></h1>
|
||||
<p className="text-gray-600">Административная панель</p>
|
||||
</div>
|
||||
const handlePasskeyLogin = async () => {
|
||||
if (!passkeysSupported) {
|
||||
setError('Этот браузер не поддерживает вход по passkey. Используйте пароль.');
|
||||
return;
|
||||
}
|
||||
|
||||
{/* Login Card */}
|
||||
<div className="login-card">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6 text-center">Вход в систему</h2>
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const optionsResponse = await axios.post(`${API_BASE_URL}/auth/passkeys/authentication/options`);
|
||||
const credential = await startAuthentication({ optionsJSON: optionsResponse.data });
|
||||
const result = await loginWithPasskey(credential);
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (passkeyError) {
|
||||
setError(getPasskeyErrorMessage(passkeyError));
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const returnToLoginMethods = () => {
|
||||
setShowPasswordForm(false);
|
||||
setError('');
|
||||
setPassword('');
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="login-surface">
|
||||
<div className="login-layout">
|
||||
<section className="login-card" aria-labelledby="login-title">
|
||||
<h1 className="login-brand">
|
||||
<BrandLogo className="login-brand-logo" />
|
||||
<span className="sr-only">RideBus DE</span>
|
||||
</h1>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3 animate-[shake_0.4s_ease-out]">
|
||||
<AlertCircle className="h-5 w-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-900">Ошибка входа</p>
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
<div className="login-error" role="alert">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Username */}
|
||||
<div>
|
||||
<label htmlFor="username" className="label">Имя пользователя</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
id="username"
|
||||
autoComplete="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="input pl-10"
|
||||
placeholder="admin"
|
||||
required
|
||||
autoFocus
|
||||
{showPasswordForm ? (
|
||||
<form className="login-password-form" onSubmit={handlePasswordLogin}>
|
||||
<div className="login-password-heading">
|
||||
<button
|
||||
className="login-back-button"
|
||||
type="button"
|
||||
onClick={returnToLoginMethods}
|
||||
disabled={loading}
|
||||
/>
|
||||
aria-label="Вернуться к способам входа"
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" />
|
||||
</button>
|
||||
<h2 id="login-title">Войти с паролем</h2>
|
||||
</div>
|
||||
|
||||
<label className="login-field">
|
||||
<span>Имя пользователя</span>
|
||||
<span className="login-input-wrap">
|
||||
<User aria-hidden="true" />
|
||||
<input
|
||||
autoComplete="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
placeholder="admin"
|
||||
required
|
||||
autoFocus
|
||||
disabled={loading}
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="login-field">
|
||||
<span>Пароль</span>
|
||||
<span className="login-input-wrap">
|
||||
<Lock aria-hidden="true" />
|
||||
<input
|
||||
autoComplete="current-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<button className="login-password-submit" type="submit" disabled={loading}>
|
||||
{loading ? <Loader2 className="animate-spin" aria-hidden="true" /> : null}
|
||||
{loading ? 'Вход…' : 'Войти'}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="login-methods" aria-labelledby="login-title">
|
||||
<h2 id="login-title" className="sr-only">Вход в административную панель</h2>
|
||||
<button
|
||||
className="login-password-choice"
|
||||
type="button"
|
||||
onClick={() => setShowPasswordForm(true)}
|
||||
disabled={loading}
|
||||
>
|
||||
Войти с паролем
|
||||
</button>
|
||||
|
||||
<div className="login-divider" aria-hidden="true"><span>или</span></div>
|
||||
|
||||
<button
|
||||
className="login-passkey-button"
|
||||
type="button"
|
||||
onClick={handlePasskeyLogin}
|
||||
disabled={loading || !passkeysSupported}
|
||||
title={passkeysSupported ? undefined : 'Passkey не поддерживается этим браузером'}
|
||||
>
|
||||
{loading ? <Loader2 className="animate-spin" aria-hidden="true" /> : <img src={passkeyIcon} alt="" />}
|
||||
<span>{loading ? 'Подтверждение…' : 'Войти с Passkey'}</span>
|
||||
</button>
|
||||
|
||||
<p className="login-passkey-hint">
|
||||
Вход без пароля — с отпечатком пальца, Face ID или PIN-кодом устройства.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label htmlFor="password" className="label">Пароль</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
id="password"
|
||||
autoComplete="current-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="input pl-10"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<aside className="login-device-card">
|
||||
<img src={devicesIllustration} alt="" />
|
||||
<p>Вход в служебных приложениях.</p>
|
||||
</aside>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full btn-primary py-3 text-base font-semibold relative overflow-hidden group"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 mr-2 animate-spin inline" />
|
||||
Вход...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="relative z-10">Войти</span>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-primary-700 to-primary-600 transform scale-x-0 group-hover:scale-x-100 transition-transform origin-left"></div>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Help text */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
Мастер-аккаунт по умолчанию:
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
<code className="bg-gray-100 px-2 py-1 rounded">admin / ADMIN_PASSWORD</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-sm text-gray-500 mt-6 animate-[fadeIn_0.6s_ease-out_0.4s_backwards]">
|
||||
© 2024 Polotsk Transit API • v1.0.0
|
||||
</p>
|
||||
<footer className="login-footer">
|
||||
<p>RideBus DE · Административная панель</p>
|
||||
<p>Версия 1.0.0</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { User, Mail, Shield, Calendar, Key, LogOut, Loader2 } from 'lucide-react';
|
||||
import AvatarUpload from '../components/AvatarUpload';
|
||||
import axios from 'axios';
|
||||
import { browserSupportsWebAuthn, startRegistration } from '@simplewebauthn/browser';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { User, Mail, Shield, Calendar, Key, LogOut, Loader2, Trash2 } from 'lucide-react';
|
||||
import AvatarUpload from '../components/AvatarUpload';
|
||||
import { getApiBaseUrl } from '../utils/apiBaseUrl';
|
||||
|
||||
const API_BASE_URL = getApiBaseUrl();
|
||||
@@ -19,12 +20,34 @@ export default function ProfilePage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [passkeys, setPasskeys] = useState([]);
|
||||
const [passkeysLoading, setPasskeysLoading] = useState(false);
|
||||
const [passkeyMessage, setPasskeyMessage] = useState('');
|
||||
const passkeysSupported = browserSupportsWebAuthn();
|
||||
|
||||
// Синхронизация с AuthContext
|
||||
useEffect(() => {
|
||||
setUser(authUser);
|
||||
}, [authUser]);
|
||||
|
||||
const loadPasskeys = async () => {
|
||||
try {
|
||||
const token = getAccessToken();
|
||||
const response = await axios.get(`${API_BASE_URL}/auth/passkeys`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setPasskeys(response.data.passkeys || []);
|
||||
} catch (passkeyError) {
|
||||
console.error('Failed to load passkeys:', passkeyError);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (authUser) {
|
||||
loadPasskeys();
|
||||
}
|
||||
}, [authUser]);
|
||||
|
||||
// Refresh user data when avatar changes
|
||||
const refreshUserData = async () => {
|
||||
try {
|
||||
@@ -97,6 +120,64 @@ export default function ProfilePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegisterPasskey = async () => {
|
||||
if (!passkeysSupported) {
|
||||
setPasskeyMessage('Этот браузер не поддерживает passkey.');
|
||||
return;
|
||||
}
|
||||
|
||||
setPasskeyMessage('');
|
||||
setPasskeysLoading(true);
|
||||
|
||||
try {
|
||||
const token = getAccessToken();
|
||||
const headers = { Authorization: `Bearer ${token}` };
|
||||
const optionsResponse = await axios.post(
|
||||
`${API_BASE_URL}/auth/passkeys/registration/options`,
|
||||
{},
|
||||
{ headers }
|
||||
);
|
||||
const credential = await startRegistration({ optionsJSON: optionsResponse.data });
|
||||
await axios.post(
|
||||
`${API_BASE_URL}/auth/passkeys/registration/verify`,
|
||||
{ response: credential },
|
||||
{ headers }
|
||||
);
|
||||
await loadPasskeys();
|
||||
setPasskeyMessage('Passkey добавлен. Теперь им можно входить без пароля.');
|
||||
} catch (passkeyError) {
|
||||
if (passkeyError?.name === 'NotAllowedError') {
|
||||
setPasskeyMessage('Регистрация passkey отменена или истекло время ожидания.');
|
||||
} else {
|
||||
setPasskeyMessage(passkeyError.response?.data?.error || 'Не удалось добавить passkey.');
|
||||
}
|
||||
} finally {
|
||||
setPasskeysLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemovePasskey = async (passkeyId) => {
|
||||
if (!confirm('Удалить этот passkey? Вход по нему больше не будет доступен.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPasskeyMessage('');
|
||||
setPasskeysLoading(true);
|
||||
|
||||
try {
|
||||
const token = getAccessToken();
|
||||
await axios.delete(`${API_BASE_URL}/auth/passkeys/${passkeyId}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
await loadPasskeys();
|
||||
setPasskeyMessage('Passkey удалён.');
|
||||
} catch (passkeyError) {
|
||||
setPasskeyMessage(passkeyError.response?.data?.error || 'Не удалось удалить passkey.');
|
||||
} finally {
|
||||
setPasskeysLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
@@ -194,13 +275,61 @@ export default function ProfilePage() {
|
||||
{/* Security */}
|
||||
<div className="card">
|
||||
<h3 className="font-semibold mb-4">Безопасность</h3>
|
||||
<button
|
||||
onClick={() => setShowPasswordForm(!showPasswordForm)}
|
||||
className="w-full btn-secondary flex items-center justify-center"
|
||||
>
|
||||
<Key className="h-4 w-4 mr-2" />
|
||||
Изменить пароль
|
||||
</button>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => setShowPasswordForm(!showPasswordForm)}
|
||||
className="w-full btn-secondary flex items-center justify-center"
|
||||
>
|
||||
<Key className="h-4 w-4 mr-2" />
|
||||
Изменить пароль
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleRegisterPasskey}
|
||||
className="w-full btn-secondary flex items-center justify-center"
|
||||
disabled={passkeysLoading || !passkeysSupported}
|
||||
title={passkeysSupported ? undefined : 'Passkey не поддерживается этим браузером'}
|
||||
>
|
||||
{passkeysLoading ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Key className="h-4 w-4 mr-2" />}
|
||||
Добавить passkey
|
||||
</button>
|
||||
|
||||
<p className="text-xs leading-5 text-gray-500">
|
||||
Вход по отпечатку пальца, Face ID, Windows Hello или PIN-коду устройства.
|
||||
</p>
|
||||
|
||||
{passkeyMessage && (
|
||||
<p className="rounded-lg bg-gray-50 px-3 py-2 text-xs leading-5 text-gray-700" role="status">
|
||||
{passkeyMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{passkeys.length > 0 && (
|
||||
<ul className="divide-y divide-gray-100 rounded-lg border border-gray-100">
|
||||
{passkeys.map((passkey, index) => (
|
||||
<li key={passkey.id} className="flex items-center gap-2 px-3 py-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-gray-900">
|
||||
{passkey.label || `Passkey ${passkeys.length - index}`}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Добавлен {new Date(passkey.created_at).toLocaleDateString('ru-RU')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-gray-500 hover:bg-red-50 hover:text-red-600"
|
||||
onClick={() => handleRemovePasskey(passkey.id)}
|
||||
disabled={passkeysLoading}
|
||||
aria-label={`Удалить ${passkey.label || `passkey ${passkeys.length - index}`}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logout */}
|
||||
|
||||
Reference in New Issue
Block a user