mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-25 13:15:02 +02:00
refactor: rename passkeyChallenge to webauthnChallenge
- Renamed table for consistency with webauthnCredentials - Created migration script 1.8.1.ts for table rename - Updated schema definitions in SQLite and PostgreSQL - Maintains WebAuthn standard naming convention
This commit is contained in:
parent
baee745d3c
commit
db76558944
19 changed files with 1735 additions and 387 deletions
|
@ -1132,5 +1132,22 @@
|
|||
"initialSetupTitle": "Initial Server Setup",
|
||||
"initialSetupDescription": "Create the intial server admin account. Only one server admin can exist. You can always change these credentials later.",
|
||||
"createAdminAccount": "Create Admin Account",
|
||||
"setupErrorCreateAdmin": "An error occurred while creating the server admin account."
|
||||
"setupErrorCreateAdmin": "An error occurred while creating the server admin account.",
|
||||
"passkeyManage": "Manage Passkeys",
|
||||
"passkeyDescription": "Add or remove passkeys for passwordless authentication",
|
||||
"passkeyRegister": "Register New Passkey",
|
||||
"passkeyList": "Your Passkeys",
|
||||
"passkeyNone": "No passkeys registered yet",
|
||||
"passkeyNameRequired": "Name is required",
|
||||
"passkeyRemove": "Remove",
|
||||
"passkeyLastUsed": "Last used: {date}",
|
||||
"passkeyNameLabel": "Name",
|
||||
"passkeyNamePlaceholder": "Enter a name for this passkey",
|
||||
"passkeyRegisterSuccess": "Passkey registered successfully",
|
||||
"passkeyRegisterError": "Failed to register passkey",
|
||||
"passkeyRemoveSuccess": "Passkey removed successfully",
|
||||
"passkeyRemoveError": "Failed to remove passkey",
|
||||
"passkeyLoadError": "Failed to load passkeys",
|
||||
"passkeyLogin": "Login with Passkey",
|
||||
"passkeyAuthError": "Failed to authenticate with passkey"
|
||||
}
|
||||
|
|
948
package-lock.json
generated
948
package-lock.json
generated
File diff suppressed because it is too large
Load diff
11
package.json
11
package.json
|
@ -52,6 +52,8 @@
|
|||
"@react-email/components": "0.0.41",
|
||||
"@react-email/render": "^1.1.2",
|
||||
"@react-email/tailwind": "1.0.5",
|
||||
"@simplewebauthn/browser": "^13.1.0",
|
||||
"@simplewebauthn/server": "^9.0.3",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"arctic": "^3.7.0",
|
||||
|
@ -97,7 +99,7 @@
|
|||
"react-hook-form": "7.56.4",
|
||||
"react-icons": "^5.5.0",
|
||||
"rebuild": "0.1.2",
|
||||
"semver": "7.7.2",
|
||||
"semver": "^7.7.2",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"tailwind-merge": "2.6.0",
|
||||
"tw-animate-css": "^1.3.3",
|
||||
|
@ -106,9 +108,9 @@
|
|||
"winston": "3.17.0",
|
||||
"winston-daily-rotate-file": "5.0.0",
|
||||
"ws": "8.18.2",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "3.25.56",
|
||||
"zod-validation-error": "3.4.1",
|
||||
"yargs": "18.0.0"
|
||||
"zod-validation-error": "3.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dotenvx/dotenvx": "1.44.1",
|
||||
|
@ -119,6 +121,7 @@
|
|||
"@types/cors": "2.8.19",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/express": "5.0.0",
|
||||
"@types/express-session": "^1.18.2",
|
||||
"@types/jmespath": "^0.15.2",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
|
@ -126,7 +129,7 @@
|
|||
"@types/nodemailer": "6.4.17",
|
||||
"@types/react": "19.1.7",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"@types/semver": "7.7.0",
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/swagger-ui-express": "^4.1.8",
|
||||
"@types/ws": "8.18.1",
|
||||
"@types/yargs": "17.0.33",
|
||||
|
|
|
@ -491,6 +491,16 @@ export const idpOrg = pgTable("idpOrg", {
|
|||
orgMapping: varchar("orgMapping")
|
||||
});
|
||||
|
||||
export const webauthnChallenge = pgTable("webauthnChallenge", {
|
||||
sessionId: varchar("sessionId").primaryKey(),
|
||||
challenge: varchar("challenge").notNull(),
|
||||
passkeyName: varchar("passkeyName"),
|
||||
userId: varchar("userId").references(() => users.userId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
expiresAt: bigint("expiresAt", { mode: "number" }).notNull() // Unix timestamp
|
||||
});
|
||||
|
||||
export type Org = InferSelectModel<typeof orgs>;
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type Site = InferSelectModel<typeof sites>;
|
||||
|
|
|
@ -13,6 +13,8 @@ bootstrapVolume();
|
|||
|
||||
function createDb() {
|
||||
const sqlite = new Database(location);
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
sqlite.exec('VACUUM;'); // This will initialize the database file with a valid SQLite header
|
||||
return DrizzleSqlite(sqlite, { schema });
|
||||
}
|
||||
|
||||
|
|
|
@ -1,12 +1,20 @@
|
|||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import db from "./driver";
|
||||
import path from "path";
|
||||
import { location } from "./driver";
|
||||
import Database from "better-sqlite3";
|
||||
import type { Database as BetterSqlite3Database } from "better-sqlite3";
|
||||
|
||||
const migrationsFolder = path.join("server/migrations");
|
||||
|
||||
const runMigrations = async () => {
|
||||
console.log("Running migrations...");
|
||||
try {
|
||||
// Initialize the database file with a valid SQLite header
|
||||
const sqlite = new Database(location) as BetterSqlite3Database;
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
|
||||
// Run the migrations
|
||||
migrate(db as any, {
|
||||
migrationsFolder: migrationsFolder,
|
||||
});
|
||||
|
|
|
@ -135,6 +135,29 @@ export const users = sqliteTable("user", {
|
|||
.default(false)
|
||||
});
|
||||
|
||||
export const passkeys = sqliteTable("webauthnCredentials", {
|
||||
credentialId: text("credentialId").primaryKey(),
|
||||
userId: text("userId").notNull().references(() => users.userId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
publicKey: text("publicKey").notNull(),
|
||||
signCount: integer("signCount").notNull(),
|
||||
transports: text("transports"),
|
||||
name: text("name"),
|
||||
lastUsed: text("lastUsed").notNull(),
|
||||
dateCreated: text("dateCreated").notNull()
|
||||
});
|
||||
|
||||
export const webauthnChallenge = sqliteTable("webauthnChallenge", {
|
||||
sessionId: text("sessionId").primaryKey(),
|
||||
challenge: text("challenge").notNull(),
|
||||
passkeyName: text("passkeyName"),
|
||||
userId: text("userId").references(() => users.userId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
expiresAt: integer("expiresAt").notNull() // Unix timestamp
|
||||
});
|
||||
|
||||
export const newts = sqliteTable("newt", {
|
||||
newtId: text("id").primaryKey(),
|
||||
secretHash: text("secretHash").notNull(),
|
||||
|
|
|
@ -35,7 +35,7 @@ declare global {
|
|||
interface Request {
|
||||
apiKey?: ApiKey;
|
||||
user?: User;
|
||||
session?: Session;
|
||||
session: Session;
|
||||
userOrg?: UserOrg;
|
||||
apiKeyOrg?: ApiKeyOrg;
|
||||
userOrgRoleId?: number;
|
||||
|
|
|
@ -12,3 +12,4 @@ export * from "./resetPassword";
|
|||
export * from "./checkResourceSession";
|
||||
export * from "./setServerAdmin";
|
||||
export * from "./initialSetupComplete";
|
||||
export * from "./passkey";
|
||||
|
|
606
server/routers/auth/passkey.ts
Normal file
606
server/routers/auth/passkey.ts
Normal file
|
@ -0,0 +1,606 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { User, passkeys, users, webauthnChallenge } from "@server/db";
|
||||
import { eq, and, lt } from "drizzle-orm";
|
||||
import { response } from "@server/lib";
|
||||
import logger from "@server/logger";
|
||||
import {
|
||||
generateRegistrationOptions,
|
||||
verifyRegistrationResponse,
|
||||
generateAuthenticationOptions,
|
||||
verifyAuthenticationResponse
|
||||
} from "@simplewebauthn/server";
|
||||
import type {
|
||||
GenerateRegistrationOptionsOpts,
|
||||
VerifyRegistrationResponseOpts,
|
||||
GenerateAuthenticationOptionsOpts,
|
||||
VerifyAuthenticationResponseOpts,
|
||||
VerifiedRegistrationResponse,
|
||||
VerifiedAuthenticationResponse
|
||||
} from "@simplewebauthn/server";
|
||||
import config from "@server/lib/config";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
|
||||
// The RP ID is the domain name of your application
|
||||
const rpID = new URL(config.getRawConfig().app.dashboard_url).hostname;
|
||||
const rpName = "Pangolin";
|
||||
const origin = config.getRawConfig().app.dashboard_url;
|
||||
|
||||
// Database-based challenge storage (replaces in-memory storage)
|
||||
// Challenges are stored in the webauthnChallenge table with automatic expiration
|
||||
// This supports clustered deployments and persists across server restarts
|
||||
|
||||
// Clean up expired challenges every 5 minutes
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const now = Date.now();
|
||||
await db
|
||||
.delete(webauthnChallenge)
|
||||
.where(lt(webauthnChallenge.expiresAt, now));
|
||||
logger.debug("Cleaned up expired passkey challenges");
|
||||
} catch (error) {
|
||||
logger.error("Failed to clean up expired passkey challenges", error);
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
// Helper functions for challenge management
|
||||
async function storeChallenge(sessionId: string, challenge: string, passkeyName?: string, userId?: string) {
|
||||
const expiresAt = Date.now() + (10 * 60 * 1000); // 10 minutes
|
||||
|
||||
// Delete any existing challenge for this session
|
||||
await db.delete(webauthnChallenge).where(eq(webauthnChallenge.sessionId, sessionId));
|
||||
|
||||
// Insert new challenge
|
||||
await db.insert(webauthnChallenge).values({
|
||||
sessionId,
|
||||
challenge,
|
||||
passkeyName,
|
||||
userId,
|
||||
expiresAt
|
||||
});
|
||||
}
|
||||
|
||||
async function getChallenge(sessionId: string) {
|
||||
const [challengeData] = await db
|
||||
.select()
|
||||
.from(webauthnChallenge)
|
||||
.where(eq(webauthnChallenge.sessionId, sessionId))
|
||||
.limit(1);
|
||||
|
||||
if (!challengeData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if (challengeData.expiresAt < Date.now()) {
|
||||
await db.delete(webauthnChallenge).where(eq(webauthnChallenge.sessionId, sessionId));
|
||||
return null;
|
||||
}
|
||||
|
||||
return challengeData;
|
||||
}
|
||||
|
||||
async function clearChallenge(sessionId: string) {
|
||||
await db.delete(webauthnChallenge).where(eq(webauthnChallenge.sessionId, sessionId));
|
||||
}
|
||||
|
||||
export const registerPasskeyBody = z.object({
|
||||
name: z.string().min(1)
|
||||
}).strict();
|
||||
|
||||
export const verifyRegistrationBody = z.object({
|
||||
credential: z.any()
|
||||
}).strict();
|
||||
|
||||
export const startAuthenticationBody = z.object({
|
||||
email: z.string().email().optional()
|
||||
}).strict();
|
||||
|
||||
export const verifyAuthenticationBody = z.object({
|
||||
credential: z.any()
|
||||
}).strict();
|
||||
|
||||
export async function startRegistration(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
const parsedBody = registerPasskeyBody.safeParse(req.body);
|
||||
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { name } = parsedBody.data;
|
||||
const user = req.user as User;
|
||||
|
||||
// Only allow internal users to use passkeys
|
||||
if (user.type !== UserType.Internal) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Passkeys are only available for internal users"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Get existing passkeys for user
|
||||
const existingPasskeys = await db
|
||||
.select()
|
||||
.from(passkeys)
|
||||
.where(eq(passkeys.userId, user.userId));
|
||||
|
||||
const excludeCredentials = existingPasskeys.map(key => ({
|
||||
id: Buffer.from(key.credentialId, 'base64'),
|
||||
type: 'public-key' as const,
|
||||
transports: key.transports ? JSON.parse(key.transports) : undefined
|
||||
}));
|
||||
|
||||
const options: GenerateRegistrationOptionsOpts = {
|
||||
rpName,
|
||||
rpID,
|
||||
userID: user.userId,
|
||||
userName: user.email || user.username,
|
||||
attestationType: 'none',
|
||||
excludeCredentials,
|
||||
authenticatorSelection: {
|
||||
residentKey: 'preferred',
|
||||
userVerification: 'preferred',
|
||||
}
|
||||
};
|
||||
|
||||
const registrationOptions = await generateRegistrationOptions(options);
|
||||
|
||||
// Store challenge in database
|
||||
await storeChallenge(req.session.sessionId, registrationOptions.challenge, name, user.userId);
|
||||
|
||||
return response(res, {
|
||||
data: registrationOptions,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Registration options generated",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to generate registration options"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyRegistration(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
const parsedBody = verifyRegistrationBody.safeParse(req.body);
|
||||
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { credential } = parsedBody.data;
|
||||
const user = req.user as User;
|
||||
|
||||
// Only allow internal users to use passkeys
|
||||
if (user.type !== UserType.Internal) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Passkeys are only available for internal users"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Get challenge from database
|
||||
const challengeData = await getChallenge(req.session.sessionId);
|
||||
|
||||
if (!challengeData) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"No challenge found in session or challenge expired"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response: credential,
|
||||
expectedChallenge: challengeData.challenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpID,
|
||||
requireUserVerification: false
|
||||
});
|
||||
|
||||
const { verified, registrationInfo } = verification;
|
||||
|
||||
if (!verified || !registrationInfo) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Verification failed"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Store the passkey in the database
|
||||
await db.insert(passkeys).values({
|
||||
credentialId: Buffer.from(registrationInfo.credentialID).toString('base64'),
|
||||
userId: user.userId,
|
||||
publicKey: Buffer.from(registrationInfo.credentialPublicKey).toString('base64'),
|
||||
signCount: registrationInfo.counter || 0,
|
||||
transports: credential.response.transports ? JSON.stringify(credential.response.transports) : null,
|
||||
name: challengeData.passkeyName,
|
||||
lastUsed: new Date().toISOString(),
|
||||
dateCreated: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Clear challenge data
|
||||
await clearChallenge(req.session.sessionId);
|
||||
|
||||
return response<null>(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Passkey registered successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to verify registration"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listPasskeys(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
const user = req.user as User;
|
||||
|
||||
// Only allow internal users to use passkeys
|
||||
if (user.type !== UserType.Internal) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Passkeys are only available for internal users"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const userPasskeys = await db
|
||||
.select()
|
||||
.from(passkeys)
|
||||
.where(eq(passkeys.userId, user.userId));
|
||||
|
||||
return response<typeof userPasskeys>(res, {
|
||||
data: userPasskeys,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Passkeys retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to retrieve passkeys"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deletePasskey(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
const { credentialId: encodedCredentialId } = req.params;
|
||||
const credentialId = decodeURIComponent(encodedCredentialId);
|
||||
const user = req.user as User;
|
||||
|
||||
// Only allow internal users to use passkeys
|
||||
if (user.type !== UserType.Internal) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Passkeys are only available for internal users"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.delete(passkeys)
|
||||
.where(and(
|
||||
eq(passkeys.credentialId, credentialId),
|
||||
eq(passkeys.userId, user.userId)
|
||||
));
|
||||
|
||||
return response<null>(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Passkey deleted successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to delete passkey"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function startAuthentication(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
const parsedBody = startAuthenticationBody.safeParse(req.body);
|
||||
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { email } = parsedBody.data;
|
||||
|
||||
try {
|
||||
let allowCredentials: Array<{
|
||||
id: Buffer;
|
||||
type: 'public-key';
|
||||
transports?: string[];
|
||||
}> = [];
|
||||
let userId;
|
||||
|
||||
// If email is provided, get passkeys for that specific user
|
||||
if (email) {
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.limit(1);
|
||||
|
||||
if (!user || user.type !== UserType.Internal) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"No passkeys available for this user"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
userId = user.userId;
|
||||
|
||||
const userPasskeys = await db
|
||||
.select()
|
||||
.from(passkeys)
|
||||
.where(eq(passkeys.userId, user.userId));
|
||||
|
||||
if (userPasskeys.length === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"No passkeys registered for this user"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
allowCredentials = userPasskeys.map(key => ({
|
||||
id: Buffer.from(key.credentialId, 'base64'),
|
||||
type: 'public-key' as const,
|
||||
transports: key.transports ? JSON.parse(key.transports) : undefined
|
||||
}));
|
||||
} else {
|
||||
// If no email provided, allow any passkey (for resident key authentication)
|
||||
allowCredentials = [];
|
||||
}
|
||||
|
||||
const options: GenerateAuthenticationOptionsOpts = {
|
||||
rpID,
|
||||
allowCredentials,
|
||||
userVerification: 'preferred',
|
||||
};
|
||||
|
||||
const authenticationOptions = await generateAuthenticationOptions(options);
|
||||
|
||||
// Generate a temporary session ID for unauthenticated users
|
||||
const tempSessionId = email ? `temp_${email}_${Date.now()}` : `temp_${Date.now()}`;
|
||||
|
||||
// Store challenge in database
|
||||
await storeChallenge(tempSessionId, authenticationOptions.challenge, undefined, userId);
|
||||
|
||||
return response(res, {
|
||||
data: { ...authenticationOptions, tempSessionId },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Authentication options generated",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to generate authentication options"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyAuthentication(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
const parsedBody = verifyAuthenticationBody.safeParse(req.body);
|
||||
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { credential } = parsedBody.data;
|
||||
const tempSessionId = req.headers['x-temp-session-id'] as string;
|
||||
|
||||
if (!tempSessionId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Missing temp session ID"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Get challenge from database
|
||||
const challengeData = await getChallenge(tempSessionId);
|
||||
|
||||
if (!challengeData) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"No challenge found or challenge expired"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Find the passkey in database
|
||||
const credentialId = Buffer.from(credential.id, 'base64').toString('base64');
|
||||
const [passkey] = await db
|
||||
.select()
|
||||
.from(passkeys)
|
||||
.where(eq(passkeys.credentialId, credentialId))
|
||||
.limit(1);
|
||||
|
||||
if (!passkey) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Passkey not found"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Get the user
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.userId, passkey.userId))
|
||||
.limit(1);
|
||||
|
||||
if (!user || user.type !== UserType.Internal) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"User not found or not authorized for passkey authentication"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response: credential,
|
||||
expectedChallenge: challengeData.challenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpID,
|
||||
authenticator: {
|
||||
credentialID: Buffer.from(passkey.credentialId, 'base64'),
|
||||
credentialPublicKey: Buffer.from(passkey.publicKey, 'base64'),
|
||||
counter: passkey.signCount,
|
||||
transports: passkey.transports ? JSON.parse(passkey.transports) : undefined
|
||||
},
|
||||
requireUserVerification: false
|
||||
});
|
||||
|
||||
const { verified, authenticationInfo } = verification;
|
||||
|
||||
if (!verified) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Authentication failed"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Update sign count
|
||||
await db
|
||||
.update(passkeys)
|
||||
.set({
|
||||
signCount: authenticationInfo.newCounter,
|
||||
lastUsed: new Date().toISOString()
|
||||
})
|
||||
.where(eq(passkeys.credentialId, credentialId));
|
||||
|
||||
// Create session for the user
|
||||
const { createSession, generateSessionToken, serializeSessionCookie } = await import("@server/auth/sessions/app");
|
||||
const token = generateSessionToken();
|
||||
const session = await createSession(token, user.userId);
|
||||
const isSecure = req.protocol === "https";
|
||||
const cookie = serializeSessionCookie(
|
||||
token,
|
||||
isSecure,
|
||||
new Date(session.expiresAt)
|
||||
);
|
||||
|
||||
res.setHeader("Set-Cookie", cookie);
|
||||
|
||||
// Clear challenge data
|
||||
await clearChallenge(tempSessionId);
|
||||
|
||||
return response<null>(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Authentication successful",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to verify authentication"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -788,3 +788,36 @@ authRouter.post("/idp/:idpId/oidc/validate-callback", idp.validateOidcCallback);
|
|||
|
||||
authRouter.put("/set-server-admin", auth.setServerAdmin);
|
||||
authRouter.get("/initial-setup-complete", auth.initialSetupComplete);
|
||||
|
||||
// Passkey routes
|
||||
authRouter.post(
|
||||
"/passkey/register/start",
|
||||
rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 5, // Allow 5 passkey registrations per 15 minutes per IP
|
||||
keyGenerator: (req) => `passkeyRegister:${req.ip}:${req.user?.userId}`,
|
||||
handler: (req, res, next) => {
|
||||
const message = `You can only register ${5} passkeys every ${15} minutes. Please try again later.`;
|
||||
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
||||
}
|
||||
}),
|
||||
verifySessionUserMiddleware,
|
||||
auth.startRegistration
|
||||
);
|
||||
authRouter.post("/passkey/register/verify", verifySessionUserMiddleware, auth.verifyRegistration);
|
||||
authRouter.post(
|
||||
"/passkey/authenticate/start",
|
||||
rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 10, // Allow 10 authentication attempts per 15 minutes per IP
|
||||
keyGenerator: (req) => `passkeyAuth:${req.ip}`,
|
||||
handler: (req, res, next) => {
|
||||
const message = `You can only attempt passkey authentication ${10} times every ${15} minutes. Please try again later.`;
|
||||
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
||||
}
|
||||
}),
|
||||
auth.startAuthentication
|
||||
);
|
||||
authRouter.post("/passkey/authenticate/verify", auth.verifyAuthentication);
|
||||
authRouter.get("/passkey/list", verifySessionUserMiddleware, auth.listPasskeys);
|
||||
authRouter.delete("/passkey/:credentialId", verifySessionUserMiddleware, auth.deletePasskey);
|
||||
|
|
|
@ -22,6 +22,8 @@ import m18 from "./scriptsSqlite/1.2.0";
|
|||
import m19 from "./scriptsSqlite/1.3.0";
|
||||
import m20 from "./scriptsSqlite/1.5.0";
|
||||
import m21 from "./scriptsSqlite/1.6.0";
|
||||
import m22 from "./scriptsSqlite/1.7.0";
|
||||
import m23 from "./scriptsSqlite/1.8.0";
|
||||
|
||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||
|
@ -43,7 +45,9 @@ const migrations = [
|
|||
{ version: "1.2.0", run: m18 },
|
||||
{ version: "1.3.0", run: m19 },
|
||||
{ version: "1.5.0", run: m20 },
|
||||
{ version: "1.6.0", run: m21 }
|
||||
{ version: "1.6.0", run: m21 },
|
||||
{ version: "1.7.0", run: m22 },
|
||||
{ version: "1.8.0", run: m23 }
|
||||
// Add new migrations here as they are created
|
||||
] as const;
|
||||
|
||||
|
@ -79,17 +83,21 @@ export async function runMigrations() {
|
|||
try {
|
||||
const appVersion = APP_VERSION;
|
||||
|
||||
if (exists) {
|
||||
// Check if the database file exists and has tables
|
||||
const hasTables = await db.select().from(versionMigrations).limit(1).catch(() => false);
|
||||
|
||||
if (hasTables) {
|
||||
await executeScripts();
|
||||
} else {
|
||||
console.log("Running migrations...");
|
||||
console.log("Running initial migrations...");
|
||||
try {
|
||||
migrate(db, {
|
||||
migrationsFolder: path.join(__DIRNAME, "init") // put here during the docker build
|
||||
migrationsFolder: path.join(APP_PATH, "server", "migrations")
|
||||
});
|
||||
console.log("Migrations completed successfully.");
|
||||
console.log("Initial migrations completed successfully.");
|
||||
} catch (error) {
|
||||
console.error("Error running migrations:", error);
|
||||
console.error("Error running initial migrations:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
await db
|
||||
|
|
31
server/setup/scriptsSqlite/1.4.0.ts
Normal file
31
server/setup/scriptsSqlite/1.4.0.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
import { db } from "../../db/sqlite";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
const version = "1.4.0";
|
||||
|
||||
export default async function migration() {
|
||||
console.log(`Running setup script ${version}...`);
|
||||
|
||||
try {
|
||||
db.transaction((trx) => {
|
||||
trx.run(sql`CREATE TABLE 'passkey' (
|
||||
'credentialId' text PRIMARY KEY NOT NULL,
|
||||
'userId' text NOT NULL,
|
||||
'publicKey' text NOT NULL,
|
||||
'signCount' integer NOT NULL,
|
||||
'transports' text,
|
||||
'name' text,
|
||||
'lastUsed' text NOT NULL,
|
||||
'dateCreated' text NOT NULL,
|
||||
FOREIGN KEY ('userId') REFERENCES 'user'('id') ON DELETE CASCADE
|
||||
);`);
|
||||
});
|
||||
|
||||
console.log(`Migrated database schema`);
|
||||
} catch (e) {
|
||||
console.log("Unable to migrate database schema");
|
||||
throw e;
|
||||
}
|
||||
|
||||
console.log(`${version} migration complete`);
|
||||
}
|
39
server/setup/scriptsSqlite/1.7.0.ts
Normal file
39
server/setup/scriptsSqlite/1.7.0.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { APP_PATH } from "@server/lib/consts";
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
|
||||
const version = "1.7.0";
|
||||
|
||||
export default async function migration() {
|
||||
console.log(`Running setup script ${version}...`);
|
||||
|
||||
const location = path.join(APP_PATH, "db", "db.sqlite");
|
||||
const db = new Database(location);
|
||||
|
||||
try {
|
||||
db.pragma("foreign_keys = OFF");
|
||||
db.transaction(() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS passkey (
|
||||
credentialId TEXT PRIMARY KEY,
|
||||
userId TEXT NOT NULL,
|
||||
publicKey TEXT NOT NULL,
|
||||
signCount INTEGER NOT NULL,
|
||||
transports TEXT,
|
||||
name TEXT,
|
||||
lastUsed TEXT NOT NULL,
|
||||
dateCreated TEXT NOT NULL,
|
||||
FOREIGN KEY (userId) REFERENCES user(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
})(); // executes the transaction immediately
|
||||
db.pragma("foreign_keys = ON");
|
||||
console.log(`Created passkey table`);
|
||||
} catch (e) {
|
||||
console.error("Unable to create passkey table");
|
||||
console.error(e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
console.log(`${version} migration complete`);
|
||||
}
|
38
server/setup/scriptsSqlite/1.8.0.ts
Normal file
38
server/setup/scriptsSqlite/1.8.0.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { APP_PATH } from "@server/lib/consts";
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
|
||||
const version = "1.8.0";
|
||||
|
||||
export default async function migration() {
|
||||
console.log(`Running setup script ${version}...`);
|
||||
|
||||
const location = path.join(APP_PATH, "db", "db.sqlite");
|
||||
const db = new Database(location);
|
||||
|
||||
try {
|
||||
db.pragma("foreign_keys = OFF");
|
||||
db.transaction(() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS passkeyChallenge (
|
||||
sessionId TEXT PRIMARY KEY,
|
||||
challenge TEXT NOT NULL,
|
||||
passkeyName TEXT,
|
||||
userId TEXT,
|
||||
expiresAt INTEGER NOT NULL,
|
||||
FOREIGN KEY (userId) REFERENCES user(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_passkeyChallenge_expiresAt ON passkeyChallenge(expiresAt);
|
||||
`);
|
||||
})(); // executes the transaction immediately
|
||||
db.pragma("foreign_keys = ON");
|
||||
console.log(`Created passkeyChallenge table`);
|
||||
} catch (e) {
|
||||
console.error("Unable to create passkeyChallenge table");
|
||||
console.error(e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
console.log(`${version} migration complete`);
|
||||
}
|
27
server/setup/scriptsSqlite/1.8.1.ts
Normal file
27
server/setup/scriptsSqlite/1.8.1.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { db } from "@server/db";
|
||||
|
||||
export default async function migrate() {
|
||||
try {
|
||||
console.log("Starting table rename migration...");
|
||||
|
||||
// Rename the table
|
||||
await db.run(`
|
||||
ALTER TABLE passkeyChallenge RENAME TO webauthnChallenge;
|
||||
`);
|
||||
console.log("Successfully renamed table");
|
||||
|
||||
// Rename the index
|
||||
await db.run(`
|
||||
DROP INDEX IF EXISTS idx_passkeyChallenge_expiresAt;
|
||||
CREATE INDEX IF NOT EXISTS idx_webauthnChallenge_expiresAt ON webauthnChallenge(expiresAt);
|
||||
`);
|
||||
console.log("Successfully updated index");
|
||||
|
||||
console.log(`Renamed passkeyChallenge table to webauthnChallenge`);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
console.error("Unable to rename passkeyChallenge table:", error);
|
||||
console.error("Error details:", error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -26,7 +26,7 @@ import { LoginResponse } from "@server/routers/auth";
|
|||
import { useRouter } from "next/navigation";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { LockIcon } from "lucide-react";
|
||||
import { LockIcon, FingerprintIcon } from "lucide-react";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import {
|
||||
|
@ -41,6 +41,7 @@ import Image from "next/image";
|
|||
import { GenerateOidcUrlResponse } from "@server/routers/idp";
|
||||
import { Separator } from "./ui/separator";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { startAuthentication } from "@simplewebauthn/browser";
|
||||
|
||||
export type LoginFormIDP = {
|
||||
idpId: number;
|
||||
|
@ -165,6 +166,52 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
|||
}
|
||||
}
|
||||
|
||||
async function loginWithPasskey() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const email = form.getValues().email;
|
||||
|
||||
// Start passkey authentication
|
||||
const startRes = await api.post("/auth/passkey/authenticate/start", {
|
||||
email: email || undefined
|
||||
});
|
||||
|
||||
if (!startRes) {
|
||||
setError(t('passkeyAuthError'));
|
||||
return;
|
||||
}
|
||||
|
||||
const { tempSessionId, ...options } = startRes.data.data;
|
||||
|
||||
// Perform passkey authentication
|
||||
const credential = await startAuthentication(options);
|
||||
|
||||
// Verify authentication
|
||||
const verifyRes = await api.post(
|
||||
"/auth/passkey/authenticate/verify",
|
||||
{ credential },
|
||||
{
|
||||
headers: {
|
||||
'X-Temp-Session-Id': tempSessionId
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (verifyRes) {
|
||||
if (onLogin) {
|
||||
await onLogin();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError(formatAxiosError(e, t('passkeyAuthError')));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{!mfaRequested && (
|
||||
|
@ -321,6 +368,18 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
|||
{t('login')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={loginWithPasskey}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
<FingerprintIcon className="w-4 h-4 mr-2" />
|
||||
{t('passkeyLogin')}
|
||||
</Button>
|
||||
|
||||
{hasIdp && (
|
||||
<>
|
||||
<div className="relative my-4">
|
||||
|
|
234
src/components/PasskeyForm.tsx
Normal file
234
src/components/PasskeyForm.tsx
Normal file
|
@ -0,0 +1,234 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { startRegistration } from "@simplewebauthn/browser";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
|
||||
type PasskeyFormProps = {
|
||||
open: boolean;
|
||||
setOpen: (val: boolean) => void;
|
||||
};
|
||||
|
||||
type Passkey = {
|
||||
credentialId: string;
|
||||
name: string;
|
||||
dateCreated: string;
|
||||
lastUsed: string;
|
||||
};
|
||||
|
||||
export default function PasskeyForm({ open, setOpen }: PasskeyFormProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [passkeys, setPasskeys] = useState<Passkey[]>([]);
|
||||
const { user } = useUserContext();
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const registerSchema = z.object({
|
||||
name: z.string().min(1, { message: t('passkeyNameRequired') })
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof registerSchema>>({
|
||||
resolver: zodResolver(registerSchema),
|
||||
defaultValues: {
|
||||
name: ""
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadPasskeys();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const loadPasskeys = async () => {
|
||||
try {
|
||||
const response = await api.get("/auth/passkey/list");
|
||||
setPasskeys(response.data.data);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: formatAxiosError(error, t('passkeyLoadError')),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegisterPasskey = async (values: z.infer<typeof registerSchema>) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Start registration
|
||||
const startRes = await api.post("/auth/passkey/register/start", {
|
||||
name: values.name
|
||||
});
|
||||
const options = startRes.data.data;
|
||||
|
||||
// Create passkey
|
||||
const credential = await startRegistration(options);
|
||||
|
||||
// Verify registration
|
||||
await api.post("/auth/passkey/register/verify", {
|
||||
credential
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "Success",
|
||||
description: t('passkeyRegisterSuccess')
|
||||
});
|
||||
|
||||
// Reload passkeys
|
||||
await loadPasskeys();
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: formatAxiosError(error, t('passkeyRegisterError')),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePasskey = async (credentialId: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const encodedCredentialId = encodeURIComponent(credentialId);
|
||||
await api.delete(`/auth/passkey/${encodedCredentialId}`);
|
||||
|
||||
toast({
|
||||
title: "Success",
|
||||
description: t('passkeyRemoveSuccess')
|
||||
});
|
||||
|
||||
// Reload passkeys
|
||||
await loadPasskeys();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: formatAxiosError(error, t('passkeyRemoveError')),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Credenza open={open} onOpenChange={setOpen}>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t('passkeyManage')}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t('passkeyDescription')}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold">{t('passkeyList')}</h3>
|
||||
{passkeys.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('passkeyNone')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{passkeys.map((passkey) => (
|
||||
<div
|
||||
key={passkey.credentialId}
|
||||
className="flex items-center justify-between p-3 border rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{passkey.name}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('passkeyLastUsed', {
|
||||
date: new Date(passkey.lastUsed).toLocaleDateString()
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDeletePasskey(passkey.credentialId)}
|
||||
disabled={loading}
|
||||
>
|
||||
{t('passkeyRemove')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold">{t('passkeyRegister')}</h3>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleRegisterPasskey)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('passkeyNameLabel')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('passkeyNamePlaceholder')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t('passkeyRegister')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</CredenzaClose>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
|
@ -21,12 +21,12 @@ import { useState } from "react";
|
|||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import Disable2FaForm from "./Disable2FaForm";
|
||||
import Enable2FaForm from "./Enable2FaForm";
|
||||
import PasskeyForm from "./PasskeyForm";
|
||||
import SupporterStatus from "./SupporterStatus";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import LocaleSwitcher from '@app/components/LocaleSwitcher';
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
|
||||
export default function ProfileIcon() {
|
||||
const { setTheme, theme } = useTheme();
|
||||
const { env } = useEnvContext();
|
||||
|
@ -40,6 +40,7 @@ export default function ProfileIcon() {
|
|||
|
||||
const [openEnable2fa, setOpenEnable2fa] = useState(false);
|
||||
const [openDisable2fa, setOpenDisable2fa] = useState(false);
|
||||
const [openPasskey, setOpenPasskey] = useState(false);
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
|
@ -73,6 +74,7 @@ export default function ProfileIcon() {
|
|||
<>
|
||||
<Enable2FaForm open={openEnable2fa} setOpen={setOpenEnable2fa} />
|
||||
<Disable2FaForm open={openDisable2fa} setOpen={setOpenDisable2fa} />
|
||||
<PasskeyForm open={openPasskey} setOpen={setOpenPasskey} />
|
||||
|
||||
<div className="flex items-center md:gap-2 grow min-w-0 gap-2 md:gap-0">
|
||||
<span className="truncate max-w-full font-medium min-w-0">
|
||||
|
@ -130,6 +132,11 @@ export default function ProfileIcon() {
|
|||
<span>{t('otpDisable')}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => setOpenPasskey(true)}
|
||||
>
|
||||
<span>{t('passkeyManage')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue