mirror of
https://github.com/nicotsx/zerobyte.git
synced 2026-06-13 02:47:48 -04:00
feat: allow each user to create API keys
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
organizationClient,
|
||||
inferAdditionalFields,
|
||||
} from "better-auth/client/plugins";
|
||||
import { apiKeyClient } from "@better-auth/api-key/client";
|
||||
import { ssoClient } from "@better-auth/sso/client";
|
||||
import { passkeyClient } from "@better-auth/passkey/client";
|
||||
import type { auth } from "~/server/lib/auth";
|
||||
@@ -19,5 +20,6 @@ export const authClient = createAuthClient({
|
||||
ssoClient(),
|
||||
twoFactorClient(),
|
||||
passkeyClient(),
|
||||
apiKeyClient(),
|
||||
],
|
||||
});
|
||||
|
||||
325
app/client/modules/settings/components/api-tokens-section.tsx
Normal file
325
app/client/modules/settings/components/api-tokens-section.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Copy, KeyRound, Plus, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { CardContent, CardDescription, CardTitle } from "~/client/components/ui/card";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "~/client/components/ui/alert-dialog";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/client/components/ui/dialog";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { Label } from "~/client/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||
import { Alert, AlertDescription, AlertTitle } from "~/client/components/ui/alert";
|
||||
import { CodeBlock } from "~/client/components/ui/code-block";
|
||||
import { authClient } from "~/client/lib/auth-client";
|
||||
import { useOrganizationContext } from "~/client/hooks/use-org-context";
|
||||
import { useTimeFormat } from "~/client/lib/datetime";
|
||||
import { logger } from "~/client/lib/logger";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
type ApiTokenEntry = {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
start?: string | null;
|
||||
prefix?: string | null;
|
||||
createdAt: Date | string;
|
||||
expiresAt?: Date | string | null;
|
||||
lastRequest?: Date | string | null;
|
||||
};
|
||||
|
||||
const EXPIRATION_OPTIONS = [
|
||||
{ value: "30", label: "30 days" },
|
||||
{ value: "90", label: "90 days" },
|
||||
{ value: "365", label: "1 year" },
|
||||
{ value: "never", label: "No expiration" },
|
||||
] as const;
|
||||
|
||||
type ExpirationValue = (typeof EXPIRATION_OPTIONS)[number]["value"];
|
||||
|
||||
export function ApiTokensSection() {
|
||||
const { formatDateTime } = useTimeFormat();
|
||||
const { activeOrganization } = useOrganizationContext();
|
||||
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [newTokenName, setNewTokenName] = useState("");
|
||||
const [newTokenExpiration, setNewTokenExpiration] = useState<ExpirationValue>("365");
|
||||
const [createdToken, setCreatedToken] = useState<string | null>(null);
|
||||
|
||||
const [deleteTarget, setDeleteTarget] = useState<ApiTokenEntry | null>(null);
|
||||
|
||||
const { data: tokens, isPending } = useQuery({
|
||||
queryKey: ["api-tokens"],
|
||||
queryFn: async (): Promise<ApiTokenEntry[]> => {
|
||||
const { data, error } = await authClient.apiKey.list();
|
||||
if (error) throw error;
|
||||
return data?.apiKeys ?? [];
|
||||
},
|
||||
});
|
||||
|
||||
const createTokenMutation = useMutation({
|
||||
mutationFn: async (payload: { name: string; expiresIn: number | null }) => {
|
||||
const { data, error } = await authClient.apiKey.create({
|
||||
name: payload.name,
|
||||
expiresIn: payload.expiresIn ?? undefined,
|
||||
metadata: { organizationId: activeOrganization.id },
|
||||
});
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
toast.success("API token created");
|
||||
setCreatedToken(data?.key ?? null);
|
||||
setNewTokenName("");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
logger.error(error);
|
||||
toast.error("Failed to create API token", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteTokenMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const { error } = await authClient.apiKey.delete({ keyId: id });
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("API token revoked");
|
||||
setDeleteTarget(null);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
logger.error(error);
|
||||
toast.error("Failed to revoke API token", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreate = (e: React.SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
const name = newTokenName.trim();
|
||||
if (!name) {
|
||||
toast.error("Name is required");
|
||||
return;
|
||||
}
|
||||
const expiresIn = newTokenExpiration === "never" ? null : Number(newTokenExpiration) * 24 * 60 * 60;
|
||||
createTokenMutation.mutate({ name, expiresIn });
|
||||
};
|
||||
|
||||
const handleCopyToken = async () => {
|
||||
if (!createdToken) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(createdToken);
|
||||
toast.success("Copied to clipboard");
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
toast.error("Failed to copy");
|
||||
}
|
||||
};
|
||||
|
||||
const closeCreateDialog = (open: boolean) => {
|
||||
if (open) {
|
||||
setCreateDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
setCreateDialogOpen(false);
|
||||
setCreatedToken(null);
|
||||
setNewTokenName("");
|
||||
setNewTokenExpiration("365");
|
||||
};
|
||||
|
||||
const sortedTokens = useMemo(() => {
|
||||
if (!tokens) return [];
|
||||
return [...tokens].sort((a, b) => {
|
||||
const ad = new Date(a.createdAt).getTime();
|
||||
const bd = new Date(b.createdAt).getTime();
|
||||
return bd - ad;
|
||||
});
|
||||
}, [tokens]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="border-t border-border/50 bg-card-header p-6">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<KeyRound className="size-5" />
|
||||
API Tokens
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1.5">
|
||||
Long-lived tokens for accessing the Zerobyte API programmatically
|
||||
</CardDescription>
|
||||
</div>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<p className="text-xs text-muted-foreground max-w-xl">
|
||||
Tokens act on your behalf within the current organization. Send them as the{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">x-api-key</code> header on
|
||||
any API request.
|
||||
</p>
|
||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create token
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className={cn("text-sm text-muted-foreground", { hidden: !isPending })}>Loading tokens...</p>
|
||||
<p
|
||||
className={cn("text-sm text-muted-foreground", {
|
||||
hidden: isPending || sortedTokens.length > 0,
|
||||
})}
|
||||
>
|
||||
No API tokens yet. Create one to authenticate against the API.
|
||||
</p>
|
||||
<ul
|
||||
className={cn("divide-y divide-border/50 rounded-md border border-border/50", {
|
||||
hidden: sortedTokens.length === 0,
|
||||
})}
|
||||
>
|
||||
{sortedTokens.map((token) => (
|
||||
<li key={token.id} className="flex items-center justify-between gap-4 p-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">{token.name?.trim() || "Unnamed token"}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Added {formatDateTime(new Date(token.createdAt))}
|
||||
{token.expiresAt
|
||||
? ` · Expires ${formatDateTime(new Date(token.expiresAt))}`
|
||||
: " · No expiration"}
|
||||
{token.lastRequest
|
||||
? ` · Last used ${formatDateTime(new Date(token.lastRequest))}`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
aria-label={`Revoke token ${token.name?.trim() || "Unnamed token"}`}
|
||||
title={`Revoke token ${token.name?.trim() || "Unnamed token"}`}
|
||||
onClick={() => setDeleteTarget(token)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
|
||||
<Dialog open={createDialogOpen} onOpenChange={closeCreateDialog}>
|
||||
<DialogContent>
|
||||
{!createdToken ? (
|
||||
<form onSubmit={handleCreate}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create API token</DialogTitle>
|
||||
<DialogDescription>
|
||||
This token will inherit your permissions in the current organization.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api-token-name">Name</Label>
|
||||
<Input
|
||||
id="api-token-name"
|
||||
value={newTokenName}
|
||||
onChange={(e) => setNewTokenName(e.target.value)}
|
||||
placeholder="API Client Name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api-token-expiration">Expiration</Label>
|
||||
<Select
|
||||
value={newTokenExpiration}
|
||||
onValueChange={(value) => setNewTokenExpiration(value as ExpirationValue)}
|
||||
>
|
||||
<SelectTrigger id="api-token-expiration">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EXPIRATION_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => closeCreateDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={createTokenMutation.isPending}>
|
||||
<KeyRound className="h-4 w-4 mr-2" />
|
||||
Create token
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Token created</DialogTitle>
|
||||
<DialogDescription>
|
||||
Copy this token now. For security reasons it will not be shown again.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<Alert>
|
||||
<KeyRound className="size-5" />
|
||||
<AlertTitle>Store it somewhere safe</AlertTitle>
|
||||
<AlertDescription>
|
||||
This is the only time you will see the full token value.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<CodeBlock code={createdToken} filename="API token" />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleCopyToken}>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Copy to clipboard
|
||||
</Button>
|
||||
<Button type="button" onClick={() => closeCreateDialog(false)}>
|
||||
Done
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={Boolean(deleteTarget)} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Revoke API token?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
"{deleteTarget?.name?.trim() || "this token"}" will stop working immediately. Any clients
|
||||
still using it will start receiving 401 responses.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteTokenMutation.isPending}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (deleteTarget) deleteTokenMutation.mutate(deleteTarget.id);
|
||||
}}
|
||||
disabled={deleteTokenMutation.isPending}
|
||||
>
|
||||
Revoke
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import { parseError } from "~/client/lib/errors";
|
||||
import { type AppContext } from "~/context";
|
||||
import { TwoFactorSection } from "../components/two-factor-section";
|
||||
import { PasskeysSection } from "../components/passkeys-section";
|
||||
import { ApiTokensSection } from "../components/api-tokens-section";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import { SsoSettingsSection } from "~/client/modules/sso/components/sso-settings-section";
|
||||
import { OrgMembersSection } from "../components/org-members-section";
|
||||
@@ -478,6 +479,8 @@ export function SettingsPage({
|
||||
<TwoFactorSection twoFactorEnabled={appContext.user?.twoFactorEnabled} />
|
||||
|
||||
<PasskeysSection />
|
||||
|
||||
<ApiTokensSection />
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
26
app/drizzle/20260608030548_gray_thunderbolt/migration.sql
Normal file
26
app/drizzle/20260608030548_gray_thunderbolt/migration.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
CREATE TABLE `apikey` (
|
||||
`id` text PRIMARY KEY,
|
||||
`config_id` text DEFAULT 'default' 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` integer,
|
||||
`enabled` integer,
|
||||
`rate_limit_enabled` integer,
|
||||
`rate_limit_time_window` integer,
|
||||
`rate_limit_max` integer,
|
||||
`request_count` integer,
|
||||
`remaining` integer,
|
||||
`last_request` integer,
|
||||
`expires_at` integer,
|
||||
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||
`permissions` text,
|
||||
`metadata` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `apikey_referenceId_idx` ON `apikey` (`reference_id`);
|
||||
3460
app/drizzle/20260608030548_gray_thunderbolt/snapshot.json
Normal file
3460
app/drizzle/20260608030548_gray_thunderbolt/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -574,3 +574,42 @@ export const passkey = sqliteTable(
|
||||
(table) => [index("passkey_userId_idx").on(table.userId), index("passkey_credentialID_idx").on(table.credentialID)],
|
||||
);
|
||||
export type Passkey = typeof passkey.$inferSelect;
|
||||
|
||||
export type ApiKeyMetadata = {
|
||||
organizationId?: string;
|
||||
};
|
||||
|
||||
export const apikey = sqliteTable(
|
||||
"apikey",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
configId: text("config_id").notNull().default("default"),
|
||||
name: text("name"),
|
||||
start: text("start"),
|
||||
prefix: text("prefix"),
|
||||
key: text("key").notNull(),
|
||||
referenceId: text("reference_id").notNull(),
|
||||
refillInterval: integer("refill_interval"),
|
||||
refillAmount: integer("refill_amount"),
|
||||
lastRefillAt: integer("last_refill_at", { mode: "timestamp_ms" }),
|
||||
enabled: integer("enabled", { mode: "boolean" }),
|
||||
rateLimitEnabled: integer("rate_limit_enabled", { mode: "boolean" }),
|
||||
rateLimitTimeWindow: integer("rate_limit_time_window"),
|
||||
rateLimitMax: integer("rate_limit_max"),
|
||||
requestCount: integer("request_count"),
|
||||
remaining: integer("remaining"),
|
||||
lastRequest: integer("last_request", { mode: "timestamp_ms" }),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp_ms" }),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch() * 1000)`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||
.notNull()
|
||||
.$onUpdate(() => new Date())
|
||||
.default(sql`(unixepoch() * 1000)`),
|
||||
permissions: text("permissions"),
|
||||
metadata: text("metadata"),
|
||||
},
|
||||
(table) => [index("apikey_referenceId_idx").on(table.referenceId)],
|
||||
);
|
||||
export type ApiKey = typeof apikey.$inferSelect;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { APIError } from "better-auth/api";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { admin, twoFactor, username, organization, testUtils } from "better-auth/plugins";
|
||||
import { apiKey } from "@better-auth/api-key";
|
||||
import { passkey } from "@better-auth/passkey";
|
||||
import { createAuthMiddleware } from "better-auth/api";
|
||||
import { config } from "../core/config";
|
||||
@@ -193,6 +194,13 @@ export const auth = betterAuth({
|
||||
},
|
||||
},
|
||||
}),
|
||||
apiKey({
|
||||
defaultPrefix: "zb_",
|
||||
enableMetadata: true,
|
||||
rateLimit: {
|
||||
enabled: !config.flags.disableRateLimiting,
|
||||
},
|
||||
}),
|
||||
tanstackStartCookies(),
|
||||
...(process.env.NODE_ENV === "test" ? [testUtils()] : []),
|
||||
],
|
||||
|
||||
@@ -2,6 +2,8 @@ import { createMiddleware } from "hono/factory";
|
||||
import { auth } from "~/server/lib/auth";
|
||||
import { db } from "~/server/db/db";
|
||||
import { withContext } from "~/server/core/request-context";
|
||||
import type { ApiKeyMetadata } from "~/server/db/schema";
|
||||
import type { Context, Next } from "hono";
|
||||
|
||||
declare module "hono" {
|
||||
interface ContextVariableMap {
|
||||
@@ -17,11 +19,57 @@ declare module "hono" {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to require authentication
|
||||
* Verifies the session cookie and attaches user to context
|
||||
*/
|
||||
export const requireAuth = createMiddleware(async (c, next) => {
|
||||
const API_KEY_HEADER = "x-api-key";
|
||||
|
||||
const authenticateWithApiKey = async (c: Context<any, string, {}>, next: Next, apiKeyValue: string) => {
|
||||
const result = await auth.api.verifyApiKey({
|
||||
body: { key: apiKeyValue },
|
||||
});
|
||||
|
||||
if (!result.valid || !result.key) {
|
||||
return c.json<unknown>({ message: "Invalid or expired API key" }, 401);
|
||||
}
|
||||
|
||||
const metadata = result.key.metadata as ApiKeyMetadata | null;
|
||||
const organizationId = metadata?.organizationId;
|
||||
|
||||
if (!organizationId) {
|
||||
return c.json<unknown>({ message: "Invalid organization context" }, 403);
|
||||
}
|
||||
|
||||
const { referenceId: userId } = result.key as { referenceId: string };
|
||||
|
||||
if (!userId) {
|
||||
return c.json<unknown>({ message: "Invalid or expired API key" }, 401);
|
||||
}
|
||||
|
||||
const [user, membership] = await Promise.all([
|
||||
db.query.usersTable.findFirst({ where: { id: userId } }),
|
||||
db.query.member.findFirst({
|
||||
where: {
|
||||
AND: [{ userId }, { organizationId }],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!user) {
|
||||
return c.json<unknown>({ message: "Invalid or expired API key" }, 401);
|
||||
}
|
||||
|
||||
if (!membership) {
|
||||
return c.json<unknown>({ message: "Invalid organization context" }, 403);
|
||||
}
|
||||
|
||||
c.set("user", user);
|
||||
c.set("organizationId", organizationId);
|
||||
c.set("membership", { role: membership.role });
|
||||
|
||||
await withContext({ organizationId, userId: user.id }, async () => {
|
||||
await next();
|
||||
});
|
||||
};
|
||||
|
||||
const authenticWithSession = async (c: Context<any, string, {}>, next: Next) => {
|
||||
const sess = await auth.api.getSession({
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
@@ -50,6 +98,20 @@ export const requireAuth = createMiddleware(async (c, next) => {
|
||||
await withContext({ organizationId: activeOrganizationId, userId: user.id }, async () => {
|
||||
await next();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware to require authentication
|
||||
* Verifies the session cookie or `x-api-key` header and attaches user to context
|
||||
*/
|
||||
export const requireAuth = createMiddleware(async (c, next) => {
|
||||
const apiKeyValue = c.req.header(API_KEY_HEADER);
|
||||
|
||||
if (apiKeyValue) {
|
||||
return authenticateWithApiKey(c, next, apiKeyValue);
|
||||
}
|
||||
|
||||
return authenticWithSession(c, next);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
3
bun.lock
3
bun.lock
@@ -5,6 +5,7 @@
|
||||
"": {
|
||||
"name": "zerobyte",
|
||||
"dependencies": {
|
||||
"@better-auth/api-key": "^1.6.11",
|
||||
"@better-auth/passkey": "^1.6.12",
|
||||
"@better-auth/sso": "^1.6.12",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -270,6 +271,8 @@
|
||||
|
||||
"@base-ui/utils": ["@base-ui/utils@0.2.9", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.11", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-x/PDDCYzoqPpjrdyb3VcyylTI2IjUXEtYDGi5foh7KsnmNJIIaVwA2GLgDH1dps1GgXiJbA60hM+AyuTfQzIvw=="],
|
||||
|
||||
"@better-auth/api-key": ["@better-auth/api-key@1.6.14", "", { "dependencies": { "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.6.14", "@better-auth/utils": "0.4.1", "better-auth": "^1.6.14", "better-call": "1.3.5" } }, "sha512-iMLRcjpGyegI5yy375ZIw83HZGSZe6TwjtCKWdFTYy1PQ0bUcD0H61uKcvO82Co4jJmjakI3POR6lDy5W1OOew=="],
|
||||
|
||||
"@better-auth/core": ["@better-auth/core@1.6.12", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.1", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.5", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-6mXtYSYfo6TvHHCZAZmfjvIQQtBDWzWzwy9iIWPEoede2lP2SuJzkfIQNuTtIGzZcn7a9iuzIm1jWDBzfnBARg=="],
|
||||
|
||||
"@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.6.12", "", { "peerDependencies": { "@better-auth/core": "^1.6.12", "@better-auth/utils": "0.4.1", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["drizzle-orm"] }, "sha512-g0sKQstvXHH70s+TjAXo86cNyWV60ahhJm1sow27RyW41U10vfBehOFinU3GPESyxl/fEr9D27rk3jdl6E3l3A=="],
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"test:codegen": "playwright codegen localhost:4096"
|
||||
},
|
||||
"dependencies": {
|
||||
"@better-auth/api-key": "^1.6.11",
|
||||
"@better-auth/passkey": "^1.6.12",
|
||||
"@better-auth/sso": "^1.6.12",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
||||
Reference in New Issue
Block a user