mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
feat: added the api key list, delete, create in profile modal.
This commit is contained in:
+31
-30
@@ -15,10 +15,11 @@
|
|||||||
"release": "release-it"
|
"release": "release-it"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@better-auth/core": "1.6.2",
|
"@better-auth/api-key": "1.6.11",
|
||||||
"@better-auth/passkey": "1.6.2",
|
"@better-auth/core": "1.6.11",
|
||||||
"@better-auth/sso": "1.6.2",
|
"@better-auth/passkey": "1.6.11",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@better-auth/sso": "1.6.11",
|
||||||
|
"@hookform/resolvers": "^5.4.0",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
||||||
@@ -49,19 +50,19 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@react-email/components": "^0.0.41",
|
"@react-email/components": "^0.0.41",
|
||||||
"@t3-oss/env-nextjs": "^0.13.11",
|
"@t3-oss/env-nextjs": "^0.13.11",
|
||||||
"@tanstack/react-query": "^5.97.0",
|
"@tanstack/react-query": "^5.100.14",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@types/nodemailer": "^6.4.23",
|
"@types/nodemailer": "^6.4.23",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"@zenstackhq/runtime": "2.14.2",
|
"@zenstackhq/runtime": "2.14.2",
|
||||||
"argon2": "^0.43.1",
|
"argon2": "^0.43.1",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"better-auth": "1.6.2",
|
"better-auth": "1.6.11",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.3.0",
|
||||||
"dockerode": "^4.0.10",
|
"dockerode": "^4.0.12",
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
"drizzle-orm": "0.45.2",
|
"drizzle-orm": "0.45.2",
|
||||||
"drizzle-zod": "0.8.3",
|
"drizzle-zod": "0.8.3",
|
||||||
@@ -70,7 +71,7 @@
|
|||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.553.0",
|
"lucide-react": "^0.553.0",
|
||||||
"minio": "^8.0.7",
|
"minio": "^8.0.7",
|
||||||
"motion": "^12.38.0",
|
"motion": "^12.40.0",
|
||||||
"next": "16.2.3",
|
"next": "16.2.3",
|
||||||
"next-safe-action": "^7.10.8",
|
"next-safe-action": "^7.10.8",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
@@ -78,17 +79,17 @@
|
|||||||
"node-forge": "^1.4.0",
|
"node-forge": "^1.4.0",
|
||||||
"nodemailer": "8.0.5",
|
"nodemailer": "8.0.5",
|
||||||
"npm-check-updates": "^18.3.1",
|
"npm-check-updates": "^18.3.1",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.21.0",
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"prettier": "^3.8.2",
|
"prettier": "^3.8.3",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.6",
|
||||||
"react-day-picker": "9.7.0",
|
"react-day-picker": "9.7.0",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.6",
|
||||||
"react-dropzone": "^14.4.1",
|
"react-dropzone": "^14.4.1",
|
||||||
"react-email": "^4.3.2",
|
"react-email": "^4.3.2",
|
||||||
"react-hook-form": "^7.72.1",
|
"react-hook-form": "^7.76.1",
|
||||||
"react-qr-code": "^2.0.18",
|
"react-qr-code": "^2.0.21",
|
||||||
"react-resizable-panels": "^3.0.6",
|
"react-resizable-panels": "^3.0.6",
|
||||||
"react-twc": "^1.5.1",
|
"react-twc": "^1.5.1",
|
||||||
"react-use-measure": "^2.1.7",
|
"react-use-measure": "^2.1.7",
|
||||||
@@ -97,44 +98,44 @@
|
|||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"swiper": "^12.1.3",
|
"swiper": "^12.1.4",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.1",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"ws": "^8.20.0",
|
"ws": "^8.21.0",
|
||||||
"zod": "4.3.6"
|
"zod": "4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify/react": "^6.0.2",
|
"@iconify/react": "^6.0.2",
|
||||||
"@playwright/test": "1.58.2",
|
"@playwright/test": "1.58.2",
|
||||||
"@react-email/preview-server": "4.3.2",
|
"@react-email/preview-server": "4.3.2",
|
||||||
"@react-email/render": "^2.0.6",
|
"@react-email/render": "^2.0.8",
|
||||||
"@release-it/bumper": "^7.0.5",
|
"@release-it/bumper": "^7.0.5",
|
||||||
"@release-it/conventional-changelog": "^10.0.6",
|
"@release-it/conventional-changelog": "^10.0.6",
|
||||||
"@tailwindcss/postcss": "^4.2.2",
|
"@tailwindcss/postcss": "^4.3.0",
|
||||||
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
||||||
"@types/node": "^22.19.17",
|
"@types/node": "^22.19.19",
|
||||||
"@types/node-forge": "^1.3.14",
|
"@types/node-forge": "^1.3.14",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.15",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@zenstackhq/openapi": "^2.22.1",
|
"@zenstackhq/openapi": "^2.22.1",
|
||||||
"@zenstackhq/tanstack-query": "^2.22.2",
|
"@zenstackhq/tanstack-query": "^2.22.2",
|
||||||
"baseline-browser-mapping": "^2.10.17",
|
"baseline-browser-mapping": "^2.10.32",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"esbuild": "^0.27.7",
|
"esbuild": "^0.27.7",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"eslint-config-next": "^16.2.3",
|
"eslint-config-next": "^16.2.6",
|
||||||
"eslint-plugin-tailwindcss": "^3.18.2",
|
"eslint-plugin-tailwindcss": "^3.18.3",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.40.0",
|
||||||
"node-pty": "^1.1.0",
|
"node-pty": "^1.1.0",
|
||||||
"postcss": "8.5.10",
|
"postcss": "8.5.10",
|
||||||
"release-it": "^19.2.4",
|
"release-it": "^19.2.4",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.3.0",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.22.3",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"zenstack": "2.14.2"
|
"zenstack": "2.14.2"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.1.3"
|
"packageManager": "pnpm@11.3.0"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1009
-969
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -16,6 +16,7 @@ import * as storageChannel from "./schema/12_storage-channel";
|
|||||||
import * as storagePolicy from "@/db/schema/13_storage-policy";
|
import * as storagePolicy from "@/db/schema/13_storage-policy";
|
||||||
import * as backupStorage from "@/db/schema/14_storage-backup";
|
import * as backupStorage from "@/db/schema/14_storage-backup";
|
||||||
import * as healthcheckLog from "@/db/schema/15_healthcheck-log";
|
import * as healthcheckLog from "@/db/schema/15_healthcheck-log";
|
||||||
|
import * as apiKey from "@/db/schema/16_apikey";
|
||||||
|
|
||||||
const log = logger.child({module: "db"});
|
const log = logger.child({module: "db"});
|
||||||
|
|
||||||
@@ -51,7 +52,8 @@ export const schemas = {
|
|||||||
...storageChannel,
|
...storageChannel,
|
||||||
...storagePolicy,
|
...storagePolicy,
|
||||||
...backupStorage,
|
...backupStorage,
|
||||||
...healthcheckLog
|
...healthcheckLog,
|
||||||
|
...apiKey
|
||||||
};
|
};
|
||||||
|
|
||||||
export const db = drizzle({
|
export const db = drizzle({
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
CREATE TABLE "apikey" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"config_id" text NOT NULL,
|
||||||
|
"name" text,
|
||||||
|
"start" text,
|
||||||
|
"prefix" text,
|
||||||
|
"key" text NOT NULL,
|
||||||
|
"reference_id" text NOT NULL,
|
||||||
|
"refill_interval" integer,
|
||||||
|
"refill_amount" integer,
|
||||||
|
"last_refill_at" timestamp (6) with time zone,
|
||||||
|
"enabled" boolean,
|
||||||
|
"rate_limit_enabled" boolean,
|
||||||
|
"rate_limit_time_window" integer,
|
||||||
|
"rate_limit_max" integer,
|
||||||
|
"request_count" integer,
|
||||||
|
"remaining" integer,
|
||||||
|
"last_request" timestamp (6) with time zone,
|
||||||
|
"expires_at" timestamp (6) with time zone,
|
||||||
|
"created_at" timestamp (6) with time zone NOT NULL,
|
||||||
|
"updated_at" timestamp (6) with time zone NOT NULL,
|
||||||
|
"permissions" text,
|
||||||
|
"metadata" text
|
||||||
|
);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -400,6 +400,13 @@
|
|||||||
"when": 1779380470656,
|
"when": 1779380470656,
|
||||||
"tag": "0056_lazy_cyclops",
|
"tag": "0056_lazy_cyclops",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 57,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1779698258492,
|
||||||
|
"tag": "0057_cooing_nocturne",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import {pgTable, uuid} from "drizzle-orm/pg-core";
|
||||||
|
import * as t from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
export const apikey = pgTable("apikey", {
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
configId: t.text("config_id").notNull(),
|
||||||
|
name: t.text("name"),
|
||||||
|
start: t.text("start"),
|
||||||
|
prefix: t.text("prefix"),
|
||||||
|
key: t.text("key").notNull(),
|
||||||
|
referenceId: t.text("reference_id").notNull(),
|
||||||
|
refillInterval: t.integer("refill_interval"),
|
||||||
|
refillAmount: t.integer("refill_amount"),
|
||||||
|
lastRefillAt: t.timestamp("last_refill_at", { precision: 6, withTimezone: true }),
|
||||||
|
enabled: t.boolean("enabled"),
|
||||||
|
rateLimitEnabled: t.boolean("rate_limit_enabled"),
|
||||||
|
rateLimitTimeWindow: t.integer("rate_limit_time_window"),
|
||||||
|
rateLimitMax: t.integer("rate_limit_max"),
|
||||||
|
requestCount: t.integer("request_count"),
|
||||||
|
remaining: t.integer("remaining"),
|
||||||
|
lastRequest: t.timestamp("last_request", { precision: 6, withTimezone: true }),
|
||||||
|
expiresAt: t.timestamp("expires_at", { precision: 6, withTimezone: true }),
|
||||||
|
createdAt: t.timestamp("created_at", { precision: 6, withTimezone: true }).notNull(),
|
||||||
|
updatedAt: t.timestamp("updated_at", { precision: 6, withTimezone: true }).notNull(),
|
||||||
|
permissions: t.text("permissions"),
|
||||||
|
metadata: t.text("metadata"),
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { UserIcon, Settings, Palette, ShieldHalf, Workflow } from "lucide-react";
|
import { UserIcon, Settings, Palette, ShieldHalf, Workflow, KeyRound } from "lucide-react";
|
||||||
import { User } from "@/db/schema/02_user";
|
import { User } from "@/db/schema/02_user";
|
||||||
|
|
||||||
interface ProfileSidebarProps {
|
interface ProfileSidebarProps {
|
||||||
|
|||||||
@@ -1,26 +1,63 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {Button} from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {Input} from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {AlertCircle, Loader2} from "lucide-react";
|
import {
|
||||||
import {useMutation} from "@tanstack/react-query";
|
AlertCircle,
|
||||||
import {toast} from "sonner";
|
Check,
|
||||||
import {useRouter} from "next/navigation";
|
Copy,
|
||||||
import {authClient} from "@/lib/auth/auth-client";
|
KeyRound,
|
||||||
import {User} from "@/db/schema/02_user";
|
Loader2,
|
||||||
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
|
Plus,
|
||||||
import {EmailSchema, EmailSchemaType} from "./account.schema";
|
Trash2,
|
||||||
import {BetterAuthError} from "@/types/auth";
|
} from "lucide-react";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { authClient } from "@/lib/auth/auth-client";
|
||||||
|
import { User } from "@/db/schema/02_user";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
useZodForm,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { EmailSchema, EmailSchemaType } from "./account.schema";
|
||||||
|
import { BetterAuthError } from "@/types/auth";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { timeAgo } from "@/utils/date-formatting";
|
||||||
|
import {
|
||||||
|
deleteApiKeyAction,
|
||||||
|
getApiKeysAction,
|
||||||
|
} from "@/features/profile/profile.action";
|
||||||
|
import { copyToClipboardWithMeta } from "@/components/common/copy-button";
|
||||||
|
|
||||||
interface ProfileAccountProps {
|
interface ProfileAccountProps {
|
||||||
user: User;
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProfileAccount({user}: ProfileAccountProps) {
|
export function ProfileAccount({ user }: ProfileAccountProps) {
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [isAddApiKeyOpen, setIsAddApiKeyOpen] = useState(false);
|
||||||
|
const [apiKeyName, setApiKeyName] = useState("");
|
||||||
|
|
||||||
|
const [createdApiKey, setCreatedApiKey] = useState<string | null>(null);
|
||||||
|
const [copiedApiKey, setCopiedApiKey] = useState(false);
|
||||||
|
|
||||||
const emailForm = useZodForm({
|
const emailForm = useZodForm({
|
||||||
schema: EmailSchema,
|
schema: EmailSchema,
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -28,51 +65,64 @@ export function ProfileAccount({user}: ProfileAccountProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const {mutate: updateEmail, isPending: isUpdatingEmail} = useMutation({
|
const { mutate: updateEmail, isPending: isUpdatingEmail } = useMutation({
|
||||||
mutationFn: async (values: EmailSchemaType) => {
|
mutationFn: async (values: EmailSchemaType) => {
|
||||||
const {error} = await authClient.changeEmail({
|
const { error } = await authClient.changeEmail({
|
||||||
newEmail: values.email,
|
newEmail: values.email,
|
||||||
callbackURL: window.location.href,
|
callbackURL: window.location.href,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
return values.email;
|
return values.email;
|
||||||
},
|
},
|
||||||
onSuccess: (newEmail) => {
|
onSuccess: (newEmail) => {
|
||||||
toast.success("Email updated successfully.");
|
toast.success("Email updated successfully.");
|
||||||
emailForm.reset({email: newEmail});
|
|
||||||
|
emailForm.reset({ email: newEmail });
|
||||||
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
},
|
},
|
||||||
onError: (error: BetterAuthError) => {
|
onError: (error: BetterAuthError) => {
|
||||||
if (error.code === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") {
|
if (error.code === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") {
|
||||||
toast.error("User already exists, use another email address!");
|
toast.error(
|
||||||
emailForm.reset({email: user.email});
|
"User already exists, use another email address!",
|
||||||
router.refresh()
|
);
|
||||||
|
|
||||||
|
emailForm.reset({ email: user.email });
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
} else {
|
} else {
|
||||||
toast.error("An error occurred while trying to update your password!");
|
toast.error(
|
||||||
|
"An error occurred while trying to update your password!",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const {mutate: resendVerificationEmail, isPending: isResendingVerification} = useMutation({
|
const {
|
||||||
|
mutate: resendVerificationEmail,
|
||||||
|
isPending: isResendingVerification,
|
||||||
|
} = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const currentEmailInput = emailForm.getValues("email");
|
const currentEmailInput = emailForm.getValues("email");
|
||||||
|
|
||||||
|
|
||||||
let error: BetterAuthError | null = null;
|
let error: BetterAuthError | null = null;
|
||||||
|
|
||||||
if (currentEmailInput === user.email) {
|
if (currentEmailInput === user.email) {
|
||||||
const sendVerification = await authClient.sendVerificationEmail({
|
const sendVerification =
|
||||||
email: currentEmailInput,
|
await authClient.sendVerificationEmail({
|
||||||
callbackURL: window.location.href,
|
email: currentEmailInput,
|
||||||
});
|
callbackURL: window.location.href,
|
||||||
|
});
|
||||||
|
|
||||||
error = sendVerification.error;
|
error = sendVerification.error;
|
||||||
} else {
|
} else {
|
||||||
const result = await authClient.changeEmail({
|
const result = await authClient.changeEmail({
|
||||||
callbackURL: window.location.href,
|
callbackURL: window.location.href,
|
||||||
newEmail: currentEmailInput,
|
newEmail: currentEmailInput,
|
||||||
});
|
});
|
||||||
|
|
||||||
error = result.error;
|
error = result.error;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,72 +131,408 @@ export function ProfileAccount({user}: ProfileAccountProps) {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Verification email resent successfully.");
|
toast.success("Verification email resent successfully.");
|
||||||
},
|
},
|
||||||
onError: (_e: BetterAuthError) => {
|
onError: () => {
|
||||||
toast.error("Failed to resend verification email.");
|
toast.error("Failed to resend verification email.");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: apikeys,
|
||||||
|
isLoading: isLoadingApiKeys,
|
||||||
|
refetch: refetchApiKeys,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["apikeys"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const result = await getApiKeysAction();
|
||||||
|
|
||||||
|
if (result?.data?.success) {
|
||||||
|
return result.data.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
throw new Error("Failed to fetch API Keys");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: addApiKey, isPending: isAddingApikey } = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const result = await authClient.apiKey.create({
|
||||||
|
name: apiKeyName || "My API Key",
|
||||||
|
prefix: "sk_"
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
throw result.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
onSuccess: (result: any) => {
|
||||||
|
setCreatedApiKey(result.data.key);
|
||||||
|
|
||||||
|
toast.success("API Key created successfully");
|
||||||
|
|
||||||
|
setIsAddApiKeyOpen(false);
|
||||||
|
setApiKeyName("");
|
||||||
|
|
||||||
|
refetchApiKeys();
|
||||||
|
},
|
||||||
|
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.message || "Failed to create API Key");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: revokeApiKey, isPending: isRevokingApiKey } =
|
||||||
|
useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
const result = await deleteApiKeyAction({ id });
|
||||||
|
|
||||||
|
if (!result?.data?.success) {
|
||||||
|
throw new Error("Failed to revoke API Key");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("API Key revoked successfully");
|
||||||
|
|
||||||
|
refetchApiKeys();
|
||||||
|
},
|
||||||
|
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Failed to revoke API Key");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCopyApiKey = async () => {
|
||||||
|
if (!createdApiKey) return;
|
||||||
|
|
||||||
|
await copyToClipboardWithMeta(createdApiKey);
|
||||||
|
|
||||||
|
setCopiedApiKey(true);
|
||||||
|
|
||||||
|
toast.success("API Key copied");
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setCopiedApiKey(false);
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 animate-in fade-in-50 duration-300">
|
<>
|
||||||
<div className="mb-6 space-y-1">
|
<div className="space-y-8 animate-in fade-in-50 duration-300">
|
||||||
<h2 className="text-2xl font-semibold tracking-tight">Account Settings</h2>
|
<div className="mb-6 space-y-1">
|
||||||
<p className="text-sm text-muted-foreground">Update your email and preferences.</p>
|
<h2 className="text-2xl font-semibold tracking-tight">
|
||||||
</div>
|
Account Settings
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<p className="text-sm text-muted-foreground">
|
||||||
<Form form={emailForm} onSubmit={(values) => updateEmail(values)}>
|
Update your email and preferences.
|
||||||
<div className="grid gap-3">
|
</p>
|
||||||
<FormField
|
</div>
|
||||||
control={emailForm.control}
|
|
||||||
name="email"
|
|
||||||
render={({field}) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Email Address</FormLabel>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<div className="flex flex-col sm:flex-row gap-3 max-w-xl">
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} placeholder="Your email address"/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row gap-3">
|
<div className="space-y-4">
|
||||||
<Button type="submit" variant="secondary"
|
<Form
|
||||||
disabled={isUpdatingEmail || !emailForm.formState.isDirty}>
|
form={emailForm}
|
||||||
{isUpdatingEmail &&
|
onSubmit={(values) => updateEmail(values)}
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin"/>}
|
>
|
||||||
{"Update"}
|
<div className="grid gap-3">
|
||||||
</Button>
|
<FormField
|
||||||
|
control={emailForm.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Email Address
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
{!user.emailVerified && (
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 max-w-xl">
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder="Your email address"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<div className="flex flex-col md:flex-row gap-3">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="submit"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => resendVerificationEmail()}
|
disabled={
|
||||||
disabled={isResendingVerification || emailForm.formState.errors.email !== undefined}
|
isUpdatingEmail ||
|
||||||
|
!emailForm.formState
|
||||||
|
.isDirty
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isResendingVerification &&
|
{isUpdatingEmail && (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin"/>}
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
{"Resend Verification"}
|
)}
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<FormMessage/>
|
|
||||||
</div>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{!user.emailVerified && (
|
Update
|
||||||
<div
|
</Button>
|
||||||
className="flex items-center gap-2 text-sm text-amber-600 bg-amber-50 p-2 rounded-md border border-amber-100 dark:bg-amber-950/30 dark:border-amber-900 dark:text-amber-400 max-w-xl">
|
|
||||||
<AlertCircle className="w-4 h-4"/>
|
{!user.emailVerified && (
|
||||||
<span>Your email is not verified. Please check your inbox.</span>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() =>
|
||||||
|
resendVerificationEmail()
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
isResendingVerification ||
|
||||||
|
emailForm
|
||||||
|
.formState
|
||||||
|
.errors
|
||||||
|
.email !==
|
||||||
|
undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isResendingVerification && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
Resend Verification
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!user.emailVerified && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-amber-600 bg-amber-50 p-2 rounded-md border border-amber-100 dark:bg-amber-950/30 dark:border-amber-900 dark:text-amber-400 max-w-xl">
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
|
||||||
|
<span>
|
||||||
|
Your email is not verified. Please check
|
||||||
|
your inbox.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-lg font-medium">
|
||||||
|
API Keys
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Manage your personal access tokens for API
|
||||||
|
authentication
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={isAddApiKeyOpen}
|
||||||
|
onOpenChange={setIsAddApiKeyOpen}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add API Key
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
Add New API Key
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
Give a name to your API Key to identify
|
||||||
|
it later.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="name">
|
||||||
|
Key Name
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
placeholder="e.g. flow-1, LLM, Backend"
|
||||||
|
value={apiKeyName}
|
||||||
|
onChange={(e) =>
|
||||||
|
setApiKeyName(e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
setIsAddApiKeyOpen(false)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => addApiKey()}
|
||||||
|
disabled={isAddingApikey}
|
||||||
|
>
|
||||||
|
{isAddingApikey && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
Create API Key
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border rounded-lg divide-y">
|
||||||
|
{isLoadingApiKeys ? (
|
||||||
|
<div className="flex items-center justify-center p-4">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : apikeys && apikeys.length > 0 ? (
|
||||||
|
apikeys.map((ak: any) => (
|
||||||
|
<ApiKeyRow
|
||||||
|
key={ak.id}
|
||||||
|
apikey={ak}
|
||||||
|
onRevoke={(id) => revokeApiKey(id)}
|
||||||
|
isRevoking={isRevokingApiKey}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="p-4 text-center text-muted-foreground">
|
||||||
|
No API Key found.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={!!createdApiKey}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setCreatedApiKey(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Your API Key</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
This API Key will only be displayed once.
|
||||||
|
<br />
|
||||||
|
Copy it now before closing this dialog.
|
||||||
|
<br />
|
||||||
|
For security reasons, it cannot be viewed again.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
readOnly
|
||||||
|
value={createdApiKey || ""}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleCopyApiKey}
|
||||||
|
>
|
||||||
|
{copiedApiKey ? (
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-900 dark:bg-amber-950/30 dark:text-amber-300">
|
||||||
|
Store this API Key securely. You will not be able
|
||||||
|
to see it again after closing this dialog.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setCreatedApiKey(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
I copied my API Key
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ApiKeyRow({
|
||||||
|
apikey,
|
||||||
|
onRevoke,
|
||||||
|
isRevoking,
|
||||||
|
}: {
|
||||||
|
apikey: any;
|
||||||
|
onRevoke: (id: string) => void;
|
||||||
|
isRevoking: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between p-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center text-muted-foreground">
|
||||||
|
<KeyRound className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="font-medium text-sm">
|
||||||
|
{apikey.name || "Unnamed API Key"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{apikey?.start && apikey?.prefix ? (
|
||||||
|
<div className="text-xs font-mono text-muted-foreground">
|
||||||
|
{apikey.start}••••••••
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Created {timeAgo(new Date(apikey.createdAt))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => onRevoke(apikey.id)}
|
||||||
|
disabled={isRevoking}
|
||||||
|
>
|
||||||
|
{isRevoking ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="sr-only">Revoke</span>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@ import { eq } from "drizzle-orm";
|
|||||||
import { ServerActionResult } from "@/types/action-type";
|
import { ServerActionResult } from "@/types/action-type";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { auth } from "@/lib/auth/auth";
|
import {auth, deleteApiKey, getApiKeys, getPasskeys, revokePasskey} from "@/lib/auth/auth";
|
||||||
import { user } from "@/db/schema/02_user";
|
import { user } from "@/db/schema/02_user";
|
||||||
import {userAction} from "@/lib/safe-actions/actions";
|
import {userAction} from "@/lib/safe-actions/actions";
|
||||||
|
|
||||||
@@ -53,3 +53,50 @@ export const updateProfileSettingsAction = userAction.schema(UpdateProfileSchema
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
export const getApiKeysAction = userAction.action(async (): Promise<ServerActionResult<any[]>> => {
|
||||||
|
try {
|
||||||
|
const apikeys = await getApiKeys();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
value: apikeys.apiKeys || [],
|
||||||
|
actionSuccess: {
|
||||||
|
message: "apikeys_fetched",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
actionError: {
|
||||||
|
message: "error_fetching_apikeys",
|
||||||
|
cause: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const DeleteApiKeySchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteApiKeyAction = userAction.schema(DeleteApiKeySchema).action(async ({ parsedInput }): Promise<ServerActionResult<{}>> => {
|
||||||
|
try {
|
||||||
|
await deleteApiKey(parsedInput.id);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
value: {},
|
||||||
|
actionSuccess: {
|
||||||
|
message: "apikey_revoked",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
actionError: {
|
||||||
|
message: "error_deleting_apikey",
|
||||||
|
cause: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import {createAuthClient} from "better-auth/react";
|
import {createAuthClient} from "better-auth/react";
|
||||||
|
|
||||||
import {adminClient, inferAdditionalFields, organizationClient, twoFactorClient} from "better-auth/client/plugins";
|
import {adminClient, inferAdditionalFields, organizationClient, twoFactorClient} from "better-auth/client/plugins";
|
||||||
import {ac, user, admin as adminRole, pending, superadmin, orgAdmin, orgMember, orgOwner} from "./permissions";
|
import {ac, user, admin as adminRole, pending, superadmin, orgAdmin, orgMember, orgOwner} from "./permissions";
|
||||||
import type {auth} from "@/lib/auth/auth";
|
import type {auth} from "@/lib/auth/auth";
|
||||||
import {getServerUrl} from "@/utils/get-server-url";
|
import {getServerUrl} from "@/utils/get-server-url";
|
||||||
import { ssoClient } from "@better-auth/sso/client";
|
import { ssoClient } from "@better-auth/sso/client";
|
||||||
import { passkeyClient } from "@better-auth/passkey/client"
|
import { passkeyClient } from "@better-auth/passkey/client"
|
||||||
|
import {apiKeyClient} from "@better-auth/api-key/client";
|
||||||
|
|
||||||
const res = await fetch(`${getServerUrl()}/api/config`);
|
const res = await fetch(`${getServerUrl()}/api/config`);
|
||||||
const {PROJECT_URL} = await res.json();
|
const {PROJECT_URL} = await res.json();
|
||||||
@@ -14,6 +14,7 @@ const {PROJECT_URL} = await res.json();
|
|||||||
export const authClient = createAuthClient({
|
export const authClient = createAuthClient({
|
||||||
baseURL: PROJECT_URL,
|
baseURL: PROJECT_URL,
|
||||||
plugins: [
|
plugins: [
|
||||||
|
apiKeyClient(),
|
||||||
passkeyClient(),
|
passkeyClient(),
|
||||||
twoFactorClient(),
|
twoFactorClient(),
|
||||||
ssoClient(),
|
ssoClient(),
|
||||||
|
|||||||
+20
-1
@@ -22,7 +22,8 @@ import {passkey} from "@better-auth/passkey";
|
|||||||
import {getOidcProviders} from "./oidc";
|
import {getOidcProviders} from "./oidc";
|
||||||
import {APIError} from "better-auth/api";
|
import {APIError} from "better-auth/api";
|
||||||
import {getOAuthProviders} from "./oauth";
|
import {getOAuthProviders} from "./oauth";
|
||||||
import { logger } from "@/lib/logger";
|
import {logger} from "@/lib/logger";
|
||||||
|
import {apiKey} from "@better-auth/api-key"
|
||||||
|
|
||||||
const log = logger.child({ module: "lib/auth" });
|
const log = logger.child({ module: "lib/auth" });
|
||||||
|
|
||||||
@@ -132,6 +133,7 @@ export const auth = betterAuth({
|
|||||||
},
|
},
|
||||||
|
|
||||||
plugins: [
|
plugins: [
|
||||||
|
apiKey(),
|
||||||
sso({
|
sso({
|
||||||
defaultSSO: oidcProviders.map((p) => ({
|
defaultSSO: oidcProviders.map((p) => ({
|
||||||
oidcConfig: {
|
oidcConfig: {
|
||||||
@@ -660,6 +662,23 @@ export const getOrganization = async ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const getApiKeys = async () => {
|
||||||
|
return await auth.api.listApiKeys({
|
||||||
|
headers: await headers(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const deleteApiKey = async (keyId: string) => {
|
||||||
|
await auth.api.deleteApiKey({
|
||||||
|
body: {
|
||||||
|
keyId: keyId,
|
||||||
|
},
|
||||||
|
headers: await headers(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const getPasskeys = async () => {
|
export const getPasskeys = async () => {
|
||||||
if (env.AUTH_PASSKEY_ENABLED !== "true") return [];
|
if (env.AUTH_PASSKEY_ENABLED !== "true") return [];
|
||||||
const passkeys = await auth.api.listPasskeys({
|
const passkeys = await auth.api.listPasskeys({
|
||||||
|
|||||||
Reference in New Issue
Block a user