feat(auth): добавить вход по passkey
quality / commitlint (push) Skipped
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m11s
quality / api (push) Successful in 47s
CD / quality (push) Successful in 2m10s
CD / publish (push) Successful in 1m50s

Альтернатива паролю на портале; SSO приложений без изменений.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-08-19 10:24:46 +07:00
co-authored by Cursor
parent f76b89c1df
commit 46dc2f714c
26 changed files with 1820 additions and 72 deletions
+1
View File
@@ -20,6 +20,7 @@
"@fastify/sensible": "^6.0.3",
"@fastify/static": "^8.2.0",
"@node-rs/argon2": "^2.0.2",
"@simplewebauthn/server": "^13.3.2",
"fastify": "^5.4.0",
"fastify-plugin": "^5.0.1",
"jose": "^6.2.8",
+2
View File
@@ -31,6 +31,7 @@ import { adminRoutes } from './routes/admin.js'
import { auditAdminRoutes } from './routes/audit.js'
import { auditIngestRoutes } from './routes/ingest-audit.js'
import { oidcRoutes } from './routes/oidc.js'
import { webauthnRoutes } from './routes/webauthn.js'
import { startAuditRetentionJob } from './services/audit-retention.js'
import { ensureOidcSigningKey, resetOidcKeyCache } from './lib/oidc/keys.js'
@@ -129,6 +130,7 @@ export async function buildApp(opts: {
await app.register(auditAdminRoutes)
await app.register(auditIngestRoutes)
await app.register(oidcRoutes)
await app.register(webauthnRoutes)
if (process.env.NODE_ENV !== 'test') {
const stopRetention = startAuditRetentionJob(app)
+38
View File
@@ -24,6 +24,9 @@ export const configSchema = z.object({
logLevel: z.string().default('info'),
isProd: z.boolean(),
auditIngestSecret: z.string().min(8).optional(),
webauthnRpID: z.string().min(1),
webauthnRpName: z.string().min(1).default('Auth Portal'),
webauthnOrigins: z.array(z.string().url()).min(1),
})
export type AppConfig = z.infer<typeof configSchema>
@@ -35,6 +38,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
env.AUDIT_INGEST_SECRET ??
(isProd ? undefined : 'dev-audit-ingest-secret')
const issuer = env.ISSUER ?? 'https://auth.shnt.top'
const { rpID, origins } = webauthnFromIssuer(issuer, env, isProd)
return configSchema.parse({
databaseUrl: env.DATABASE_URL ?? 'sqlite:data/app.db',
@@ -56,9 +60,43 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
boolFromEnv(env.NODE_ENV === 'production' ? 'true' : undefined, false) ||
isProd,
auditIngestSecret,
webauthnRpID: rpID,
webauthnRpName: env.WEBAUTHN_RP_NAME || 'Auth Portal',
webauthnOrigins: origins,
})
}
function webauthnFromIssuer(
issuer: string,
env: NodeJS.ProcessEnv,
isProd: boolean,
): { rpID: string; origins: string[] } {
let issuerUrl: URL
try {
issuerUrl = new URL(issuer)
} catch {
issuerUrl = new URL('https://auth.shnt.top')
}
const rpID = (env.WEBAUTHN_RP_ID || issuerUrl.hostname).trim()
const origins = new Set<string>()
origins.add(issuerUrl.origin)
const extra = env.WEBAUTHN_ORIGINS ?? ''
for (const raw of extra.split(',')) {
const value = raw.trim().replace(/\/$/, '')
if (!value) continue
try {
origins.add(new URL(value).origin)
} catch {
/* skip invalid */
}
}
if (!isProd) {
origins.add('http://localhost:5173')
origins.add('http://localhost:8080')
}
return { rpID, origins: [...origins] }
}
export function oidcIssuerFromConfig(config: AppConfig): string {
return (config.oidcIssuer ?? config.issuer).replace(/\/$/, '')
}
+64
View File
@@ -0,0 +1,64 @@
import type { FastifyInstance, FastifyReply } from 'fastify'
import { randomBytes } from 'node:crypto'
import {
createRefreshSession,
touchLastLogin,
type UserRow,
} from '@authportal/db'
import { issueAccessToken } from './issue-access-token.js'
import { safeAudit } from './audit.js'
import { targetAppFromReturnTo } from './target-app.js'
export const REFRESH_COOKIE = 'refresh_token'
export function completeLogin(
app: FastifyInstance,
reply: FastifyReply,
user: UserRow,
opts: {
method: 'password' | 'passkey'
returnTo?: string
ip: string | null
userAgent: string | null
},
) {
const body = issueAccessToken(app, user)
const refreshRaw = randomBytes(32).toString('hex')
const refreshExpires = new Date(
Date.now() + app.config.refreshTtlDays * 24 * 60 * 60 * 1000,
)
createRefreshSession(app.db, user.id, refreshRaw, refreshExpires, {
ip: opts.ip,
userAgent: opts.userAgent,
})
touchLastLogin(app.db, user.id, opts.ip)
reply.header(
'Set-Cookie',
`${REFRESH_COOKIE}=${refreshRaw}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${app.config.refreshTtlDays * 86400}${app.config.isProd ? '; Secure' : ''}`,
)
const targetApp = targetAppFromReturnTo(opts.returnTo)
const isPasskey = opts.method === 'passkey'
safeAudit(app, {
action: isPasskey ? 'auth.passkey_login' : 'auth.login',
severity: 'info',
actorUserId: user.id,
actorEmail: user.email,
actorName: user.name,
targetType: 'session',
targetId: user.id,
summary: isPasskey
? `Вход (passkey): ${user.email}`
: `Вход: ${user.email}`,
details: {
method: opts.method,
user_agent: opts.userAgent,
return_to: opts.returnTo ?? null,
target_app: targetApp,
},
ip: opts.ip,
})
return body
}
+34
View File
@@ -0,0 +1,34 @@
import type { FastifyInstance } from 'fastify'
import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers'
import type { AuthenticatorTransportFuture } from '@simplewebauthn/server'
export function webauthnRelyingParty(app: FastifyInstance): {
rpID: string
rpName: string
origins: string[]
} {
return {
rpID: app.config.webauthnRpID,
rpName: app.config.webauthnRpName,
origins: app.config.webauthnOrigins,
}
}
export function userIdToBytes(userId: string): Uint8Array {
return isoUint8Array.fromUTF8String(userId)
}
export function encodePublicKey(publicKey: Uint8Array): string {
return isoBase64URL.fromBuffer(publicKey)
}
export function decodePublicKey(stored: string): Uint8Array {
return isoBase64URL.toBuffer(stored)
}
export function asTransports(
values: string[],
): AuthenticatorTransportFuture[] | undefined {
if (values.length === 0) return undefined
return values as AuthenticatorTransportFuture[]
}
+27
View File
@@ -22,6 +22,8 @@ import {
listOidcClients,
parseJsonStringArray,
updateOidcClient,
countWebauthnCredentials,
deleteWebauthnCredentialsForUser,
} from '@authportal/db'
import {
APP_IDS,
@@ -62,6 +64,7 @@ function mapUser(
permissions,
last_login_at: user.lastLoginAt ?? null,
last_login_ip: user.lastLoginIp ?? null,
passkey_count: countWebauthnCredentials(db, user.id),
created_at: user.createdAt,
updated_at: user.updatedAt,
}
@@ -217,6 +220,30 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
},
)
app.delete<{ Params: { id: string } }>(
'/api/v1/admin/users/:id/passkeys',
async (request, reply) => {
const existing = getUserById(app.db, request.params.id)
if (!existing) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
})
}
const removed = deleteWebauthnCredentialsForUser(app.db, existing.id)
safeAudit(app, {
action: 'admin.passkey_reset',
severity: 'warning',
...actorFromRequest(request),
targetType: 'user',
targetId: existing.id,
summary: `Сброшены passkeys: ${existing.email} (${removed})`,
details: { removed },
ip: clientIp(request),
})
return { ok: true, removed, user: mapUser(app.db, existing) }
},
)
app.delete<{ Params: { id: string } }>(
'/api/v1/admin/users/:id',
async (request, reply) => {
+5 -37
View File
@@ -1,6 +1,5 @@
import type { FastifyInstance } from 'fastify'
import { verify } from '@node-rs/argon2'
import { randomBytes } from 'node:crypto'
import {
PERMISSION_CATALOG,
appsMetaFromSwitcher,
@@ -9,26 +8,24 @@ import {
ssoAccessRequestSchema,
} from '@authportal/shared'
import {
createRefreshSession,
getAppSwitcherConfig,
getUserByEmail,
getUserById,
listUsers,
revokeRefreshSession,
touchLastLogin,
} from '@authportal/db'
import { requireAuth } from '../plugins/auth-guards.js'
import { issueAccessToken } from '../lib/issue-access-token.js'
import { completeLogin, REFRESH_COOKIE } from '../lib/complete-login.js'
import { clientIp, safeAudit } from '../lib/audit.js'
import { clientUserAgent, targetAppFromReturnTo } from '../lib/target-app.js'
const REFRESH_COOKIE = 'refresh_token'
export async function authRoutes(app: FastifyInstance): Promise<void> {
/** Public — SPA reads allowlist at runtime (Docker-friendly). */
app.get('/api/v1/auth/config', async () => ({
return_to_allowlist: app.config.returnToAllowlist,
issuer: app.config.issuer,
webauthn: true,
}))
app.post('/api/v1/auth/login', {
@@ -90,41 +87,12 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
})
}
const body = issueAccessToken(app, user)
const refreshRaw = randomBytes(32).toString('hex')
const refreshExpires = new Date(
Date.now() + app.config.refreshTtlDays * 24 * 60 * 60 * 1000,
)
createRefreshSession(app.db, user.id, refreshRaw, refreshExpires, {
return completeLogin(app, reply, user, {
method: 'password',
returnTo,
ip,
userAgent,
})
touchLastLogin(app.db, user.id, ip)
reply.header(
'Set-Cookie',
`${REFRESH_COOKIE}=${refreshRaw}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${app.config.refreshTtlDays * 86400}${app.config.isProd ? '; Secure' : ''}`,
)
safeAudit(app, {
action: 'auth.login',
severity: 'info',
actorUserId: user.id,
actorEmail: user.email,
actorName: user.name,
targetType: 'session',
targetId: user.id,
summary: `Вход: ${user.email}`,
details: {
user_agent: userAgent,
return_to: returnTo ?? null,
target_app: targetApp,
},
ip,
})
return body
},
})
+388
View File
@@ -0,0 +1,388 @@
import type { FastifyInstance } from 'fastify'
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
type AuthenticationResponseJSON,
type RegistrationResponseJSON,
} from '@simplewebauthn/server'
import {
consumeWebauthnChallenge,
createWebauthnChallenge,
createWebauthnCredential,
deleteWebauthnCredential,
getUserById,
getWebauthnCredentialByCredentialId,
getWebauthnCredentialById,
listWebauthnCredentials,
parseTransportsJson,
updateWebauthnCredentialName,
touchWebauthnCredential,
type WebauthnCredentialRow,
} from '@authportal/db'
import {
patchPasskeyRequestSchema,
webauthnVerifyRequestSchema,
type PasskeyCredential,
} from '@authportal/shared'
import { requireAuth } from '../plugins/auth-guards.js'
import { completeLogin } from '../lib/complete-login.js'
import { clientIp, safeAudit } from '../lib/audit.js'
import { clientUserAgent, targetAppFromReturnTo } from '../lib/target-app.js'
import {
asTransports,
decodePublicKey,
encodePublicKey,
userIdToBytes,
webauthnRelyingParty,
} from '../lib/webauthn.js'
const LOGIN_RATE = { max: 20, timeWindow: '1 minute' } as const
function toPasskeyDto(row: WebauthnCredentialRow): PasskeyCredential {
return {
id: row.id,
name: row.name,
created_at: row.createdAt,
last_used_at: row.lastUsedAt ?? null,
device_type: row.deviceType ?? null,
}
}
function defaultPasskeyName(userAgent: string | null): string {
const date = new Date().toLocaleDateString('ru-RU')
if (!userAgent) return `Passkey ${date}`
if (/iPhone|iPad|Macintosh/i.test(userAgent)) return `Apple ${date}`
if (/Windows/i.test(userAgent)) return `Windows Hello ${date}`
if (/Android/i.test(userAgent)) return `Android ${date}`
return `Passkey ${date}`
}
function asRegistrationResponse(
raw: unknown,
): RegistrationResponseJSON | null {
if (!raw || typeof raw !== 'object') return null
if (!('id' in raw) || typeof (raw as { id: unknown }).id !== 'string') {
return null
}
return raw as RegistrationResponseJSON
}
function asAuthenticationResponse(
raw: unknown,
): AuthenticationResponseJSON | null {
if (!raw || typeof raw !== 'object') return null
if (!('id' in raw) || typeof (raw as { id: unknown }).id !== 'string') {
return null
}
return raw as AuthenticationResponseJSON
}
export async function webauthnRoutes(app: FastifyInstance): Promise<void> {
app.post(
'/api/v1/webauthn/register/options',
{
onRequest: requireAuth,
config: { rateLimit: LOGIN_RATE },
},
async (request, reply) => {
const auth = request.authUser!
const user = getUserById(app.db, auth.id)
if (!user || user.disabled) {
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
})
}
const rp = webauthnRelyingParty(app)
const existing = listWebauthnCredentials(app.db, user.id)
const options = await generateRegistrationOptions({
rpName: rp.rpName,
rpID: rp.rpID,
userName: user.email,
userDisplayName: user.name,
userID: userIdToBytes(user.id),
attestationType: 'none',
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
excludeCredentials: existing.map((cred) => ({
id: cred.credentialId,
transports: asTransports(parseTransportsJson(cred.transportsJson)),
})),
})
const row = createWebauthnChallenge(app.db, {
purpose: 'register',
challenge: options.challenge,
userId: user.id,
})
return { challenge_id: row.id, options }
},
)
app.post(
'/api/v1/webauthn/register',
{
onRequest: requireAuth,
config: { rateLimit: LOGIN_RATE },
},
async (request, reply) => {
const parsed = webauthnVerifyRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const auth = request.authUser!
const user = getUserById(app.db, auth.id)
if (!user || user.disabled) {
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
})
}
const response = asRegistrationResponse(parsed.data.response)
if (!response) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректный ответ passkey' },
})
}
const challenge = consumeWebauthnChallenge(
app.db,
parsed.data.challenge_id,
'register',
user.id,
)
if (!challenge) {
return reply.status(400).send({
error: {
code: 'VALIDATION_ERROR',
message: 'Срок действия challenge истёк, повторите регистрацию',
},
})
}
const rp = webauthnRelyingParty(app)
let verification
try {
verification = await verifyRegistrationResponse({
response,
expectedChallenge: challenge.challenge,
expectedOrigin: rp.origins,
expectedRPID: rp.rpID,
requireUserVerification: true,
})
} catch (err) {
app.log.warn({ err }, 'webauthn register verify failed')
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Не удалось проверить passkey' },
})
}
if (!verification.verified || !verification.registrationInfo) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Passkey не подтверждён' },
})
}
const info = verification.registrationInfo
const duplicate = getWebauthnCredentialByCredentialId(
app.db,
info.credential.id,
)
if (duplicate) {
return reply.status(409).send({
error: { code: 'CONFLICT', message: 'Этот passkey уже зарегистрирован' },
})
}
const name =
parsed.data.name?.trim() ||
defaultPasskeyName(clientUserAgent(request.headers))
const row = createWebauthnCredential(app.db, {
userId: user.id,
credentialId: info.credential.id,
publicKey: encodePublicKey(info.credential.publicKey),
counter: info.credential.counter,
deviceType: info.credentialDeviceType,
backedUp: info.credentialBackedUp,
transports: info.credential.transports,
name,
})
safeAudit(app, {
action: 'auth.passkey_register',
severity: 'info',
actorUserId: user.id,
actorEmail: user.email,
actorName: user.name,
targetType: 'credential',
targetId: row.id,
summary: `Passkey добавлен: ${user.email}`,
details: { name: row.name, device_type: row.deviceType },
ip: clientIp(request),
})
return toPasskeyDto(row)
},
)
app.get(
'/api/v1/webauthn/credentials',
{ onRequest: requireAuth },
async (request) => {
const auth = request.authUser!
return listWebauthnCredentials(app.db, auth.id).map(toPasskeyDto)
},
)
app.patch<{ Params: { id: string } }>(
'/api/v1/webauthn/credentials/:id',
{ onRequest: requireAuth },
async (request, reply) => {
const parsed = patchPasskeyRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const auth = request.authUser!
const row = updateWebauthnCredentialName(
app.db,
request.params.id,
auth.id,
parsed.data.name.trim(),
)
if (!row) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Passkey не найден' },
})
}
return toPasskeyDto(row)
},
)
app.delete<{ Params: { id: string } }>(
'/api/v1/webauthn/credentials/:id',
{ onRequest: requireAuth },
async (request, reply) => {
const auth = request.authUser!
const existing = getWebauthnCredentialById(app.db, request.params.id)
if (!existing || existing.userId !== auth.id) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Passkey не найден' },
})
}
deleteWebauthnCredential(app.db, request.params.id, auth.id)
const user = getUserById(app.db, auth.id)
safeAudit(app, {
action: 'auth.passkey_delete',
severity: 'warning',
actorUserId: auth.id,
actorEmail: auth.email,
actorName: auth.name,
targetType: 'credential',
targetId: request.params.id,
summary: `Passkey удалён: ${user?.email ?? auth.email}`,
details: { name: existing.name },
ip: clientIp(request),
})
return { ok: true }
},
)
app.post(
'/api/v1/webauthn/login/options',
{ config: { rateLimit: LOGIN_RATE } },
async () => {
const rp = webauthnRelyingParty(app)
const options = await generateAuthenticationOptions({
rpID: rp.rpID,
userVerification: 'preferred',
})
const row = createWebauthnChallenge(app.db, {
purpose: 'authenticate',
challenge: options.challenge,
})
return { challenge_id: row.id, options }
},
)
app.post(
'/api/v1/webauthn/login',
{ config: { rateLimit: LOGIN_RATE } },
async (request, reply) => {
const parsed = webauthnVerifyRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const ip = clientIp(request)
const userAgent = clientUserAgent(request.headers)
const returnTo = parsed.data.return_to
const fail = (reason: string) => {
safeAudit(app, {
action: 'auth.passkey_login_failed',
severity: 'warning',
targetType: 'session',
summary: 'Неудачный вход по passkey',
details: {
reason,
user_agent: userAgent,
return_to: returnTo ?? null,
target_app: targetAppFromReturnTo(returnTo),
},
ip,
})
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Не удалось войти с passkey' },
})
}
const response = asAuthenticationResponse(parsed.data.response)
if (!response) return fail('bad_response')
const challenge = consumeWebauthnChallenge(
app.db,
parsed.data.challenge_id,
'authenticate',
)
if (!challenge) return fail('expired_challenge')
const cred = getWebauthnCredentialByCredentialId(app.db, response.id)
if (!cred) return fail('unknown_credential')
const user = getUserById(app.db, cred.userId)
if (!user || user.disabled) return fail(user ? 'disabled' : 'unknown_user')
const rp = webauthnRelyingParty(app)
let verification
try {
verification = await verifyAuthenticationResponse({
response,
expectedChallenge: challenge.challenge,
expectedOrigin: rp.origins,
expectedRPID: rp.rpID,
requireUserVerification: true,
credential: {
id: cred.credentialId,
publicKey: decodePublicKey(cred.publicKey),
counter: cred.counter,
transports: asTransports(parseTransportsJson(cred.transportsJson)),
},
})
} catch (err) {
app.log.warn({ err }, 'webauthn login verify failed')
return fail('verify_error')
}
if (!verification.verified) return fail('not_verified')
touchWebauthnCredential(
app.db,
cred.id,
verification.authenticationInfo.newCounter,
)
return completeLogin(app, reply, user, {
method: 'passkey',
returnTo,
ip,
userAgent,
})
},
)
}
+184
View File
@@ -0,0 +1,184 @@
import { describe, expect, it } from 'vitest'
import { buildApp } from '../src/app.js'
import { loadConfig } from '../src/config.js'
import { createWebauthnCredential } from '@authportal/db'
async function buildTestApp() {
const config = loadConfig({
...process.env,
JWT_SECRET: 'test-secret-at-least-8',
ADMIN_EMAIL: '[email protected]',
ADMIN_PASSWORD: 'adminpass',
DATABASE_URL: 'sqlite::memory:',
ISSUER: 'https://auth.test.local',
NODE_ENV: 'test',
})
return buildApp({ config, databaseUrl: 'sqlite::memory:' })
}
async function adminToken(app: Awaited<ReturnType<typeof buildTestApp>>) {
const login = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { email: '[email protected]', password: 'adminpass' },
})
expect(login.statusCode).toBe(200)
return (login.json() as { access_token: string }).access_token
}
describe('webauthn / passkeys', () => {
it('exposes webauthn flag on auth config', async () => {
const app = await buildTestApp()
const res = await app.inject({ method: 'GET', url: '/api/v1/auth/config' })
expect(res.statusCode).toBe(200)
expect(res.json()).toMatchObject({ webauthn: true })
expect(app.config.webauthnRpID).toBe('auth.test.local')
expect(app.config.webauthnOrigins).toContain('https://auth.test.local')
expect(app.config.webauthnOrigins).toContain('http://localhost:5173')
await app.close()
})
it('requires JWT for register options', async () => {
const app = await buildTestApp()
const denied = await app.inject({
method: 'POST',
url: '/api/v1/webauthn/register/options',
})
expect(denied.statusCode).toBe(401)
const token = await adminToken(app)
const ok = await app.inject({
method: 'POST',
url: '/api/v1/webauthn/register/options',
headers: { authorization: `Bearer ${token}` },
})
expect(ok.statusCode).toBe(200)
const body = ok.json() as {
challenge_id: string
options: { challenge: string; rp: { id: string } }
}
expect(body.challenge_id).toBeTruthy()
expect(body.options.challenge).toBeTruthy()
expect(body.options.rp.id).toBe('auth.test.local')
await app.close()
})
it('allows public login options and rejects a bogus assertion', async () => {
const app = await buildTestApp()
const options = await app.inject({
method: 'POST',
url: '/api/v1/webauthn/login/options',
})
expect(options.statusCode).toBe(200)
const body = options.json() as { challenge_id: string; options: unknown }
expect(body.challenge_id).toBeTruthy()
const login = await app.inject({
method: 'POST',
url: '/api/v1/webauthn/login',
payload: {
challenge_id: body.challenge_id,
response: { id: 'not-a-credential', type: 'public-key' },
},
})
expect(login.statusCode).toBe(401)
await app.close()
})
it('lists, deletes own credentials and reports passkey_count', async () => {
const app = await buildTestApp()
const token = await adminToken(app)
const me = await app.inject({
method: 'GET',
url: '/api/v1/auth/me',
headers: { authorization: `Bearer ${token}` },
})
const userId = (me.json() as { id: string }).id
createWebauthnCredential(app.db, {
userId,
credentialId: 'dGVzdC1jcmVkLWlk',
publicKey: 'dGVzdC1wdWJrZXk',
counter: 0,
name: 'Test key',
})
const listed = await app.inject({
method: 'GET',
url: '/api/v1/webauthn/credentials',
headers: { authorization: `Bearer ${token}` },
})
expect(listed.statusCode).toBe(200)
const creds = listed.json() as { id: string; name: string }[]
expect(creds).toHaveLength(1)
expect(creds[0]?.name).toBe('Test key')
const users = await app.inject({
method: 'GET',
url: '/api/v1/admin/users',
headers: { authorization: `Bearer ${token}` },
})
const admin = (
users.json() as { email: string; passkey_count: number }[]
).find((u) => u.email === '[email protected]')
expect(admin?.passkey_count).toBe(1)
const renamed = await app.inject({
method: 'PATCH',
url: `/api/v1/webauthn/credentials/${creds[0]!.id}`,
headers: { authorization: `Bearer ${token}` },
payload: { name: 'Laptop' },
})
expect(renamed.statusCode).toBe(200)
expect((renamed.json() as { name: string }).name).toBe('Laptop')
const deleted = await app.inject({
method: 'DELETE',
url: `/api/v1/webauthn/credentials/${creds[0]!.id}`,
headers: { authorization: `Bearer ${token}` },
})
expect(deleted.statusCode).toBe(200)
const empty = await app.inject({
method: 'GET',
url: '/api/v1/webauthn/credentials',
headers: { authorization: `Bearer ${token}` },
})
expect(empty.json()).toEqual([])
await app.close()
})
it('lets admin reset a user passkeys', async () => {
const app = await buildTestApp()
const token = await adminToken(app)
const me = await app.inject({
method: 'GET',
url: '/api/v1/auth/me',
headers: { authorization: `Bearer ${token}` },
})
const userId = (me.json() as { id: string }).id
createWebauthnCredential(app.db, {
userId,
credentialId: 'cmVzZXQta2V5',
publicKey: 'cHVia2V5',
counter: 1,
name: 'To reset',
})
const reset = await app.inject({
method: 'DELETE',
url: `/api/v1/admin/users/${userId}/passkeys`,
headers: { authorization: `Bearer ${token}` },
})
expect(reset.statusCode).toBe(200)
expect(reset.json()).toMatchObject({ ok: true, removed: 1 })
const listed = await app.inject({
method: 'GET',
url: '/api/v1/webauthn/credentials',
headers: { authorization: `Bearer ${token}` },
})
expect(listed.json()).toEqual([])
await app.close()
})
})
+1
View File
@@ -19,6 +19,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.4.0",
"@simplewebauthn/browser": "^13.3.0",
"@tailwindcss/vite": "^4.3.1",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-router": "^1.170.15",
+11
View File
@@ -2,6 +2,7 @@ import { Link, useRouterState } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import {
AppWindowIcon,
FingerprintIcon,
HistoryIcon,
KeyRoundIcon,
LayoutGridIcon,
@@ -53,6 +54,16 @@ export function AppSidebar() {
<span>Приложения</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
tooltip="Безопасность"
isActive={isActive(pathname, '/account', true)}
render={<Link to="/account" />}
>
<FingerprintIcon className="size-4" />
<span>Безопасность</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
+9
View File
@@ -1,6 +1,7 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
ChevronsUpDownIcon,
FingerprintIcon,
LogOutIcon,
MonitorIcon,
MoonIcon,
@@ -8,6 +9,7 @@ import {
SunIcon,
} from 'lucide-react'
import { useEffect, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useTheme } from 'next-themes'
import { Avatar, AvatarFallback } from '@authportal/ui/components/avatar'
@@ -109,6 +111,7 @@ export function NavUser() {
const { isMobile } = useSidebar()
const { data: me } = useQuery(meQueryOptions)
const queryClient = useQueryClient()
const navigate = useNavigate()
const name = me?.name?.trim() || 'Пользователь'
const email = me?.email?.trim() || ''
@@ -175,6 +178,12 @@ export function NavUser() {
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => void navigate({ to: '/account' })}
>
<FingerprintIcon aria-hidden />
Безопасность
</DropdownMenuItem>
<DropdownMenuItem className="cursor-default focus:bg-transparent">
<PaletteIcon aria-hidden />
Тема
+130 -28
View File
@@ -1,8 +1,20 @@
import { useState, type FormEvent } from 'react'
import { useEffect, useState, type FormEvent } from 'react'
import { useNavigate, useSearch } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
import { EyeIcon, EyeOffIcon } from 'lucide-react'
import { buildSsoRedirectUrl, isPortalOidcAuthorizeUrl, isReturnToAllowed } from '@authportal/shared'
import { EyeIcon, EyeOffIcon, FingerprintIcon } from 'lucide-react'
import {
browserSupportsWebAuthn,
browserSupportsWebAuthnAutofill,
startAuthentication,
WebAuthnAbortService,
} from '@simplewebauthn/browser'
import type { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/browser'
import {
buildSsoRedirectUrl,
isPortalOidcAuthorizeUrl,
isReturnToAllowed,
type LoginResponse,
} from '@authportal/shared'
import { Button } from '@authportal/ui/components/button'
import { Field, FieldGroup, FieldLabel } from '@authportal/ui/components/field'
import { Input } from '@authportal/ui/components/input'
@@ -12,6 +24,7 @@ import {
InputGroupButton,
InputGroupInput,
} from '@authportal/ui/components/input-group'
import { Separator } from '@authportal/ui/components/separator'
import {
Alert,
AlertDescription,
@@ -20,6 +33,7 @@ import {
import { ensureAuthConfig, setToken } from '@/lib/auth'
import { ApiError } from '@/lib/api-client'
import { login, meQueryKey } from '@/queries/auth'
import { webauthnLogin, webauthnLoginOptions } from '@/queries/webauthn'
import { AuthLogo } from '@/components/blocks/auth-18/components/auth-logo'
export function PortalLoginForm() {
@@ -29,42 +43,110 @@ export function PortalLoginForm() {
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState<string | null>(null)
const [pending, setPending] = useState(false)
const [passkeySupported, setPasskeySupported] = useState(false)
async function applySession(res: LoginResponse) {
setToken(res.access_token)
queryClient.setQueryData(meQueryKey, res.user)
const returnTo = search.return_to
const { returnToAllowlist: allowlist, issuer } = await ensureAuthConfig()
if (returnTo && isReturnToAllowed(returnTo, allowlist)) {
if (isPortalOidcAuthorizeUrl(returnTo, issuer)) {
window.location.href = returnTo
return
}
window.location.href = buildSsoRedirectUrl(
returnTo,
res.access_token,
res.expires_at,
)
return
}
if (res.user.is_admin) {
await navigate({ to: '/admin' })
} else {
await navigate({ to: '/apps' })
}
}
async function runPasskeyLogin() {
const { challenge_id, options } = await webauthnLoginOptions()
const assertion = await startAuthentication({
optionsJSON: options as unknown as PublicKeyCredentialRequestOptionsJSON,
})
const res = await webauthnLogin(challenge_id, assertion, search.return_to)
await applySession(res)
}
useEffect(() => {
if (!browserSupportsWebAuthn()) return
setPasskeySupported(true)
let cancelled = false
async function startConditional() {
if (!(await browserSupportsWebAuthnAutofill())) return
try {
const { challenge_id, options } = await webauthnLoginOptions()
if (cancelled) return
const assertion = await startAuthentication({
optionsJSON:
options as unknown as PublicKeyCredentialRequestOptionsJSON,
useBrowserAutofill: true,
})
if (cancelled) return
setPending(true)
setError(null)
const res = await webauthnLogin(
challenge_id,
assertion,
search.return_to,
)
await applySession(res)
} catch {
/* abort / unsupported / user dismissed */
} finally {
if (!cancelled) setPending(false)
}
}
void startConditional()
return () => {
cancelled = true
WebAuthnAbortService.cancelCeremony()
}
// Login page mount only — return_to is stable for the visit.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
WebAuthnAbortService.cancelCeremony()
setError(null)
setPending(true)
const form = new FormData(event.currentTarget)
const email = String(form.get('email') ?? '')
const password = String(form.get('password') ?? '')
const returnTo = search.return_to
try {
const res = await login(email, password, returnTo)
setToken(res.access_token)
queryClient.setQueryData(meQueryKey, res.user)
const res = await login(email, password, search.return_to)
await applySession(res)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Не удалось войти')
} finally {
setPending(false)
}
}
const { returnToAllowlist: allowlist, issuer } = await ensureAuthConfig()
if (returnTo && isReturnToAllowed(returnTo, allowlist)) {
if (isPortalOidcAuthorizeUrl(returnTo, issuer)) {
window.location.href = returnTo
return
}
window.location.href = buildSsoRedirectUrl(
returnTo,
res.access_token,
res.expires_at,
)
return
}
if (res.user.is_admin) {
await navigate({ to: '/admin' })
} else {
await navigate({ to: '/apps' })
}
async function handlePasskeyClick() {
WebAuthnAbortService.cancelCeremony()
setError(null)
setPending(true)
try {
await runPasskeyLogin()
} catch (err) {
setError(
err instanceof ApiError ? err.message : 'Не удалось войти',
err instanceof ApiError ? err.message : 'Не удалось войти с passkey',
)
} finally {
setPending(false)
@@ -101,7 +183,7 @@ export function PortalLoginForm() {
id="email"
name="email"
type="email"
autoComplete="username"
autoComplete="username webauthn"
placeholder="[email protected]"
className="bg-background"
required
@@ -137,6 +219,26 @@ export function PortalLoginForm() {
{pending ? 'Вход…' : 'Войти'}
</Button>
</form>
{passkeySupported ? (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<Separator className="flex-1" />
<span className="text-muted-foreground text-xs">или</span>
<Separator className="flex-1" />
</div>
<Button
type="button"
variant="outline"
className="w-full"
disabled={pending}
onClick={() => void handlePasskeyClick()}
>
<FingerprintIcon aria-hidden="true" />
Войти с passkey
</Button>
</div>
) : null}
</div>
</section>
)
@@ -10,6 +10,7 @@ import {
CircleDotIcon,
FilterIcon,
FunnelXIcon,
FingerprintIcon,
LockIcon,
MailIcon,
MoreHorizontalIcon,
@@ -115,6 +116,7 @@ export interface AdminUsersGridProps {
onOpenAccess: (user: AdminUser) => void
onDeactivate: (user: AdminUser) => void
onDelete: (user: AdminUser) => void
onResetPasskeys: (user: AdminUser) => void
onBulkSetRole: (userIds: string[], isAdmin: boolean) => void
onBulkDeactivate: (userIds: string[]) => void
}
@@ -403,14 +405,17 @@ function ActionsCell({
onOpenAccess,
onDeactivate,
onDelete,
onResetPasskeys,
}: {
row: Row<AdminUser>
onOpenAudit: (user: AdminUser) => void
onOpenAccess: (user: AdminUser) => void
onDeactivate: (user: AdminUser) => void
onDelete: (user: AdminUser) => void
onResetPasskeys: (user: AdminUser) => void
}) {
const [deleteOpen, setDeleteOpen] = useState(false)
const [resetOpen, setResetOpen] = useState(false)
const user = row.original
return (
@@ -444,6 +449,12 @@ function ActionsCell({
Отключить
</DropdownMenuItem>
) : null}
{(user.passkey_count ?? 0) > 0 ? (
<DropdownMenuItem onClick={() => setResetOpen(true)}>
<FingerprintIcon className="size-4" aria-hidden="true" />
Сбросить passkeys
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
@@ -456,6 +467,31 @@ function ActionsCell({
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog open={resetOpen} onOpenChange={setResetOpen}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Сбросить passkeys?</AlertDialogTitle>
<AlertDialogDescription>
Все ключи пользователя{' '}
<span className="text-foreground font-medium">{user.email}</span>{' '}
будут удалены. Вход останется по паролю.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
setResetOpen(false)
onResetPasskeys(user)
}}
>
Сбросить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
@@ -489,6 +525,7 @@ function createAdminUserColumns(handlers: {
onOpenAccess: (user: AdminUser) => void
onDeactivate: (user: AdminUser) => void
onDelete: (user: AdminUser) => void
onResetPasskeys: (user: AdminUser) => void
}): ColumnDef<AdminUser>[] {
return [
{
@@ -614,15 +651,22 @@ function createAdminUserColumns(handlers: {
},
{
id: 'twoFactor',
accessorFn: (row) => (row.passkey_count ?? 0) > 0,
header: ({ column }) => (
<DataGridColumnHeader title="2FA" visibility column={column} />
),
cell: () => (
<Badge variant="destructive-outline">
<TriangleAlertIcon aria-hidden="true" />
Выкл
</Badge>
),
cell: ({ row }) =>
(row.original.passkey_count ?? 0) > 0 ? (
<Badge variant="success-outline">
<FingerprintIcon aria-hidden="true" />
Passkey
</Badge>
) : (
<Badge variant="destructive-outline">
<TriangleAlertIcon aria-hidden="true" />
Выкл
</Badge>
),
size: 100,
enableSorting: false,
enableHiding: true,
@@ -686,6 +730,7 @@ function createAdminUserColumns(handlers: {
onOpenAccess={handlers.onOpenAccess}
onDeactivate={handlers.onDeactivate}
onDelete={handlers.onDelete}
onResetPasskeys={handlers.onResetPasskeys}
/>
),
size: 60,
@@ -710,6 +755,7 @@ export function AdminUsersGrid({
onOpenAccess,
onDeactivate,
onDelete,
onResetPasskeys,
onBulkSetRole,
onBulkDeactivate,
}: AdminUsersGridProps) {
@@ -848,8 +894,9 @@ export function AdminUsersGrid({
onOpenAccess,
onDeactivate,
onDelete,
onResetPasskeys,
}),
[onOpenAudit, onOpenAccess, onDeactivate, onDelete],
[onOpenAudit, onOpenAccess, onDeactivate, onDelete, onResetPasskeys],
)
const [columnOrder, setColumnOrder] = useState<string[]>(
@@ -16,3 +16,4 @@ export { UserAuditSheet } from './user-audit-sheet'
export { CreateUserSheet } from './create-user-sheet'
export { CreateOidcClientSheet } from './create-oidc-client-sheet'
export { AdminUsersGrid, type AdminUsersGridProps } from './admin-users-grid'
export { PasskeySettingsPanel } from './passkey-settings-panel'
@@ -0,0 +1,213 @@
/**
* Passkey management — DNA settings-16 / settings-2 / settings-10.
* Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-2 · https://reui.io/preview/base/settings-10
* Docs: https://reui.io/docs/components/base/frame
*/
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { FingerprintIcon, PlusIcon, Trash2Icon } from 'lucide-react'
import {
startRegistration,
browserSupportsWebAuthn,
} from '@simplewebauthn/browser'
import type { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/browser'
import type { PasskeyCredential } from '@authportal/shared'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { Button } from '@authportal/ui/components/button'
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from '@authportal/ui/components/item'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@authportal/ui/components/alert-dialog'
import { Skeleton } from '@authportal/ui/components/skeleton'
import { toast } from 'sonner'
import { ApiError } from '@/lib/api-client'
import {
deletePasskey,
passkeysQueryKey,
passkeysQueryOptions,
webauthnRegister,
webauthnRegisterOptions,
} from '@/queries/webauthn'
function formatWhen(iso: string | null) {
if (!iso) return 'ещё не использовался'
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('ru-RU', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date)
}
export function PasskeySettingsPanel() {
const queryClient = useQueryClient()
const { data: passkeys = [], isLoading } = useQuery(passkeysQueryOptions)
const [pendingDelete, setPendingDelete] = useState<PasskeyCredential | null>(
null,
)
const supported = browserSupportsWebAuthn()
const registerMutation = useMutation({
mutationFn: async () => {
const { challenge_id, options } = await webauthnRegisterOptions()
const attResp = await startRegistration({
optionsJSON:
options as unknown as PublicKeyCredentialCreationOptionsJSON,
})
return webauthnRegister(challenge_id, attResp)
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: passkeysQueryKey })
toast.success('Passkey добавлен')
},
onError: (err) => {
toast.error(
err instanceof ApiError ? err.message : 'Не удалось добавить passkey',
)
},
})
const deleteMutation = useMutation({
mutationFn: (id: string) => deletePasskey(id),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: passkeysQueryKey })
toast.success('Passkey удалён')
setPendingDelete(null)
},
onError: (err) => {
toast.error(
err instanceof ApiError ? err.message : 'Не удалось удалить passkey',
)
},
})
return (
<>
<Frame className="w-full">
<FrameHeader className="flex-row items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<FrameTitle>Passkeys</FrameTitle>
<FrameDescription>
Вход без пароля: Windows Hello, Face ID, ключ безопасности
</FrameDescription>
</div>
<Button
type="button"
size="sm"
disabled={!supported || registerMutation.isPending}
onClick={() => registerMutation.mutate()}
>
<PlusIcon aria-hidden="true" />
{registerMutation.isPending ? 'Ожидание…' : 'Добавить passkey'}
</Button>
</FrameHeader>
<FramePanel className="flex flex-col gap-2">
{!supported ? (
<p className="text-muted-foreground text-sm">
Этот браузер не поддерживает WebAuthn.
</p>
) : null}
{isLoading ? (
<div className="flex flex-col gap-2">
<Skeleton className="h-16 w-full rounded-xl" />
<Skeleton className="h-16 w-full rounded-xl" />
</div>
) : passkeys.length === 0 ? (
<p className="text-muted-foreground text-sm">
Ключи не зарегистрированы. Добавьте passkey, чтобы входить без
пароля.
</p>
) : (
passkeys.map((item) => (
<Item key={item.id} variant="outline" className="items-start">
<ItemMedia variant="icon">
<FingerprintIcon className="size-4" aria-hidden="true" />
</ItemMedia>
<ItemContent>
<ItemTitle className="flex flex-wrap items-center gap-2">
{item.name}
{item.device_type === 'multiDevice' ? (
<Badge variant="info-outline" size="sm">
Синхронизируется
</Badge>
) : null}
</ItemTitle>
<ItemDescription>
Добавлен {formatWhen(item.created_at)} · вход{' '}
{formatWhen(item.last_used_at)}
</ItemDescription>
</ItemContent>
<ItemActions>
<Button
type="button"
size="sm"
variant="ghost"
aria-label={`Удалить ${item.name}`}
onClick={() => setPendingDelete(item)}
>
<Trash2Icon aria-hidden="true" />
Удалить
</Button>
</ItemActions>
</Item>
))
)}
</FramePanel>
</Frame>
<AlertDialog
open={Boolean(pendingDelete)}
onOpenChange={(open) => {
if (!open) setPendingDelete(null)
}}
>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Удалить passkey?</AlertDialogTitle>
<AlertDialogDescription>
{pendingDelete
? `«${pendingDelete.name}» больше нельзя будет использовать для входа.`
: null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deleteMutation.isPending}
onClick={() => {
if (pendingDelete) deleteMutation.mutate(pendingDelete.id)
}}
>
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
+63
View File
@@ -0,0 +1,63 @@
import { queryOptions } from '@tanstack/react-query'
import type {
LoginResponse,
PasskeyCredential,
WebauthnOptionsResponse,
} from '@authportal/shared'
import { api } from '@/lib/api-client'
export const passkeysQueryKey = ['webauthn', 'credentials'] as const
export const passkeysQueryOptions = queryOptions({
queryKey: passkeysQueryKey,
queryFn: () => api.get<PasskeyCredential[]>('/api/v1/webauthn/credentials'),
})
export function webauthnLoginOptions() {
return api.post<WebauthnOptionsResponse>('/api/v1/webauthn/login/options')
}
export function webauthnLogin(
challengeId: string,
response: unknown,
returnTo?: string,
) {
return api.post<LoginResponse>('/api/v1/webauthn/login', {
challenge_id: challengeId,
response,
...(returnTo ? { return_to: returnTo } : {}),
})
}
export function webauthnRegisterOptions() {
return api.post<WebauthnOptionsResponse>('/api/v1/webauthn/register/options')
}
export function webauthnRegister(
challengeId: string,
response: unknown,
name?: string,
) {
return api.post<PasskeyCredential>('/api/v1/webauthn/register', {
challenge_id: challengeId,
response,
...(name ? { name } : {}),
})
}
export function renamePasskey(id: string, name: string) {
return api.patch<PasskeyCredential>(`/api/v1/webauthn/credentials/${id}`, {
name,
})
}
export function deletePasskey(id: string) {
return api.delete<{ ok: boolean }>(`/api/v1/webauthn/credentials/${id}`)
}
export function adminResetPasskeys(userId: string) {
return api.delete<{
ok: boolean
removed: number
}>(`/api/v1/admin/users/${userId}/passkeys`)
}
+21
View File
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as AuthRouteImport } from './routes/_auth'
import { Route as LogoutRouteImport } from './routes/logout'
import { Route as AuthAccountRouteImport } from './routes/_auth.account'
import { Route as AuthAdminRouteImport } from './routes/_auth.admin'
import { Route as AuthAppsRouteImport } from './routes/_auth.apps'
import { Route as AuthAdminIndexRouteImport } from './routes/_auth.admin.index'
@@ -35,6 +36,11 @@ const LogoutRoute = LogoutRouteImport.update({
path: '/logout',
getParentRoute: () => rootRouteImport,
} as any)
const AuthAccountRoute = AuthAccountRouteImport.update({
id: '/account',
path: '/account',
getParentRoute: () => AuthRoute,
} as any)
const AuthAdminRoute = AuthAdminRouteImport.update({
id: '/admin',
path: '/admin',
@@ -79,6 +85,7 @@ const AuthAdminUsersUserIdRoute = AuthAdminUsersUserIdRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/logout': typeof LogoutRoute
'/account': typeof AuthAccountRoute
'/admin': typeof AuthAdminRouteWithChildren
'/apps': typeof AuthAppsRoute
'/admin/apps': typeof AuthAdminAppsRoute
@@ -91,6 +98,7 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/logout': typeof LogoutRoute
'/account': typeof AuthAccountRoute
'/apps': typeof AuthAppsRoute
'/admin/apps': typeof AuthAdminAppsRoute
'/admin/audit': typeof AuthAdminAuditRoute
@@ -104,6 +112,7 @@ export interface FileRoutesById {
'/': typeof IndexRoute
'/_auth': typeof AuthRouteWithChildren
'/logout': typeof LogoutRoute
'/_auth/account': typeof AuthAccountRoute
'/_auth/admin': typeof AuthAdminRouteWithChildren
'/_auth/apps': typeof AuthAppsRoute
'/_auth/admin/apps': typeof AuthAdminAppsRoute
@@ -118,6 +127,7 @@ export interface FileRouteTypes {
fullPaths:
| '/'
| '/logout'
| '/account'
| '/admin'
| '/apps'
| '/admin/apps'
@@ -130,6 +140,7 @@ export interface FileRouteTypes {
to:
| '/'
| '/logout'
| '/account'
| '/apps'
| '/admin/apps'
| '/admin/audit'
@@ -142,6 +153,7 @@ export interface FileRouteTypes {
| '/'
| '/_auth'
| '/logout'
| '/_auth/account'
| '/_auth/admin'
| '/_auth/apps'
| '/_auth/admin/apps'
@@ -181,6 +193,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LogoutRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth/account': {
id: '/_auth/account'
path: '/account'
fullPath: '/account'
preLoaderRoute: typeof AuthAccountRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/admin': {
id: '/_auth/admin'
path: '/admin'
@@ -263,11 +282,13 @@ const AuthAdminRouteWithChildren = AuthAdminRoute._addFileChildren(
)
interface AuthRouteChildren {
AuthAccountRoute: typeof AuthAccountRoute
AuthAdminRoute: typeof AuthAdminRouteWithChildren
AuthAppsRoute: typeof AuthAppsRoute
}
const AuthRouteChildren: AuthRouteChildren = {
AuthAccountRoute: AuthAccountRoute,
AuthAdminRoute: AuthAdminRouteWithChildren,
AuthAppsRoute: AuthAppsRoute,
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Account security — passkeys.
* Preview: https://reui.io/preview/base/settings-10 · https://reui.io/preview/base/settings-16
*/
import { createFileRoute } from '@tanstack/react-router'
import { PageShell } from '@/components/page-shell'
import { PasskeySettingsPanel } from '@/components/reui-kit/passkey-settings-panel'
export const Route = createFileRoute('/_auth/account')({
component: AccountPage,
})
function AccountPage() {
return (
<PageShell>
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">Безопасность</h1>
<p className="text-muted-foreground text-sm">
Passkey как альтернатива паролю. Пароль остаётся запасным входом.
</p>
</div>
<PasskeySettingsPanel />
</PageShell>
)
}
+25
View File
@@ -14,6 +14,7 @@ import { UserAccessSheet } from '@/components/reui-kit/user-access-sheet'
import { UserAuditSheet } from '@/components/reui-kit/user-audit-sheet'
import { api, ApiError } from '@/lib/api-client'
import { usersQueryKey, usersQueryOptions } from '@/queries/auth'
import { adminResetPasskeys } from '@/queries/webauthn'
import { Button } from '@authportal/ui/components/button'
export const Route = createFileRoute('/_auth/admin/')({
@@ -104,6 +105,29 @@ function AdminUsersPage() {
[deleteMutation],
)
const resetPasskeysMutation = useMutation({
mutationFn: (id: string) => adminResetPasskeys(id),
onSuccess: async (_data, id) => {
await invalidateUsers()
const user = users.find((u) => u.id === id)
toast.success('Passkeys сброшены', {
description: user?.email,
})
},
onError: (err) => {
toast.error(
err instanceof ApiError ? err.message : 'Не удалось сбросить passkeys',
)
},
})
const handleResetPasskeys = useCallback(
(user: AdminUser) => {
resetPasskeysMutation.mutate(user.id)
},
[resetPasskeysMutation],
)
const handleBulkSetRole = useCallback(
(userIds: string[], isAdmin: boolean) => {
Promise.all(
@@ -170,6 +194,7 @@ function AdminUsersPage() {
onOpenAccess={(user) => setAccessUserId(user.id)}
onDeactivate={handleDeactivate}
onDelete={handleDelete}
onResetPasskeys={handleResetPasskeys}
onBulkSetRole={handleBulkSetRole}
onBulkDeactivate={handleBulkDeactivate}
/>