mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
feat(den): add org API key auth and management (#1368)
* feat(den): add org API key auth and management Let org owners and admins manage named API keys while keeping keys scoped to the issuing member and org. Reuse existing org middleware so API-key-backed requests behave like normal user actions across Den routes. * fix(den): polish API key creation flow Avoid duplicating the visible key prefix in the table preview and make key creation an explicit one-time reveal flow. Show the create form only after the user asks for a new key, then replace it with the copyable secret after issuance. * fix(den): simplify API key table UI Remove rate-limit details from the Den web API keys screen and keep the page focused on naming, reveal, and deletion. Leave the generated next-env types file out of the commit. --------- Co-authored-by: src-opn <src-opn@users.noreply.github.com>
This commit is contained in:
@@ -10,13 +10,14 @@
|
||||
"start": "node dist/server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@better-auth/api-key": "^1.5.6",
|
||||
"@openwork-ee/den-db": "workspace:*",
|
||||
"@openwork-ee/utils": "workspace:*",
|
||||
"@daytonaio/sdk": "^0.150.0",
|
||||
"@hono/node-server": "^1.13.8",
|
||||
"@hono/zod-validator": "^0.7.6",
|
||||
"better-call": "^1.1.8",
|
||||
"better-auth": "^1.4.18",
|
||||
"better-call": "^1.3.2",
|
||||
"better-auth": "^1.5.6",
|
||||
"dotenv": "^16.4.5",
|
||||
"hono": "^4.7.2",
|
||||
"zod": "^4.3.6"
|
||||
|
||||
222
ee/apps/den-api/src/api-keys.ts
Normal file
222
ee/apps/den-api/src/api-keys.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { and, asc, desc, eq, inArray } from "@openwork-ee/den-db/drizzle"
|
||||
import { AuthApiKeyTable, AuthUserTable, MemberTable } from "@openwork-ee/den-db/schema"
|
||||
import type { DenTypeId } from "@openwork-ee/utils/typeid"
|
||||
import { db } from "./db.js"
|
||||
|
||||
export const DEN_API_KEY_HEADER = "x-api-key"
|
||||
export const DEN_API_KEY_DEFAULT_PREFIX = "den_"
|
||||
export const DEN_API_KEY_RATE_LIMIT_MAX = 600
|
||||
export const DEN_API_KEY_RATE_LIMIT_TIME_WINDOW_MS = 60_000
|
||||
|
||||
type UserId = typeof AuthUserTable.$inferSelect.id
|
||||
type OrganizationId = typeof MemberTable.$inferSelect.organizationId
|
||||
type OrganizationMemberId = typeof MemberTable.$inferSelect.id
|
||||
type ApiKeyId = typeof AuthApiKeyTable.$inferSelect.id
|
||||
|
||||
export type DenApiKeyMetadata = {
|
||||
organizationId: OrganizationId
|
||||
orgMembershipId: OrganizationMemberId
|
||||
issuedByUserId: UserId
|
||||
issuedByOrgMembershipId: OrganizationMemberId
|
||||
}
|
||||
|
||||
export type DenApiKeySession = {
|
||||
id: ApiKeyId
|
||||
configId: string
|
||||
referenceId: string
|
||||
metadata: DenApiKeyMetadata | null
|
||||
}
|
||||
|
||||
export type OrganizationApiKeySummary = {
|
||||
id: ApiKeyId
|
||||
configId: string
|
||||
name: string | null
|
||||
start: string | null
|
||||
prefix: string | null
|
||||
enabled: boolean
|
||||
rateLimitEnabled: boolean
|
||||
rateLimitMax: number | null
|
||||
rateLimitTimeWindow: number | null
|
||||
lastRequest: Date | null
|
||||
expiresAt: Date | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
owner: {
|
||||
userId: UserId
|
||||
memberId: OrganizationMemberId
|
||||
name: string
|
||||
email: string
|
||||
image: string | null
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
function parseApiKeyMetadata(value: unknown): DenApiKeyMetadata | null {
|
||||
const parsed = typeof value === "string"
|
||||
? (() => {
|
||||
try {
|
||||
return JSON.parse(value) as unknown
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
: value
|
||||
|
||||
if (!isRecord(parsed)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const organizationId = typeof parsed.organizationId === "string" ? parsed.organizationId : null
|
||||
const orgMembershipId = typeof parsed.orgMembershipId === "string" ? parsed.orgMembershipId : null
|
||||
const issuedByUserId = typeof parsed.issuedByUserId === "string" ? parsed.issuedByUserId : null
|
||||
const issuedByOrgMembershipId = typeof parsed.issuedByOrgMembershipId === "string" ? parsed.issuedByOrgMembershipId : null
|
||||
|
||||
if (!organizationId || !orgMembershipId || !issuedByUserId || !issuedByOrgMembershipId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
organizationId: organizationId as OrganizationId,
|
||||
orgMembershipId: orgMembershipId as OrganizationMemberId,
|
||||
issuedByUserId: issuedByUserId as UserId,
|
||||
issuedByOrgMembershipId: issuedByOrgMembershipId as OrganizationMemberId,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildOrganizationApiKeyMetadata(input: {
|
||||
organizationId: OrganizationId
|
||||
orgMembershipId: OrganizationMemberId
|
||||
issuedByUserId: UserId
|
||||
issuedByOrgMembershipId: OrganizationMemberId
|
||||
}): DenApiKeyMetadata {
|
||||
return {
|
||||
organizationId: input.organizationId,
|
||||
orgMembershipId: input.orgMembershipId,
|
||||
issuedByUserId: input.issuedByUserId,
|
||||
issuedByOrgMembershipId: input.issuedByOrgMembershipId,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getApiKeySessionById(apiKeyId: string): Promise<DenApiKeySession | null> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: AuthApiKeyTable.id,
|
||||
configId: AuthApiKeyTable.configId,
|
||||
referenceId: AuthApiKeyTable.referenceId,
|
||||
metadata: AuthApiKeyTable.metadata,
|
||||
})
|
||||
.from(AuthApiKeyTable)
|
||||
.where(eq(AuthApiKeyTable.id, apiKeyId))
|
||||
.limit(1)
|
||||
|
||||
const row = rows[0]
|
||||
if (!row) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
configId: row.configId,
|
||||
referenceId: row.referenceId,
|
||||
metadata: parseApiKeyMetadata(row.metadata),
|
||||
}
|
||||
}
|
||||
|
||||
export async function listOrganizationApiKeys(organizationId: OrganizationId): Promise<OrganizationApiKeySummary[]> {
|
||||
const members = await db
|
||||
.select({
|
||||
memberId: MemberTable.id,
|
||||
userId: MemberTable.userId,
|
||||
userName: AuthUserTable.name,
|
||||
userEmail: AuthUserTable.email,
|
||||
userImage: AuthUserTable.image,
|
||||
})
|
||||
.from(MemberTable)
|
||||
.innerJoin(AuthUserTable, eq(MemberTable.userId, AuthUserTable.id))
|
||||
.where(eq(MemberTable.organizationId, organizationId))
|
||||
.orderBy(asc(MemberTable.createdAt))
|
||||
|
||||
if (members.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const memberByUserId = new Map(members.map((member) => [member.userId, member]))
|
||||
|
||||
const apiKeys = await db
|
||||
.select()
|
||||
.from(AuthApiKeyTable)
|
||||
.where(inArray(AuthApiKeyTable.referenceId, members.map((member) => member.userId)))
|
||||
.orderBy(desc(AuthApiKeyTable.createdAt))
|
||||
|
||||
return apiKeys
|
||||
.map((apiKey) => {
|
||||
const owner = memberByUserId.get(apiKey.referenceId as UserId)
|
||||
const metadata = parseApiKeyMetadata(apiKey.metadata)
|
||||
|
||||
if (!owner || !metadata || metadata.organizationId !== organizationId || metadata.orgMembershipId !== owner.memberId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: apiKey.id,
|
||||
configId: apiKey.configId,
|
||||
name: apiKey.name,
|
||||
start: apiKey.start,
|
||||
prefix: apiKey.prefix,
|
||||
enabled: apiKey.enabled,
|
||||
rateLimitEnabled: apiKey.rateLimitEnabled,
|
||||
rateLimitMax: apiKey.rateLimitMax,
|
||||
rateLimitTimeWindow: apiKey.rateLimitTimeWindow,
|
||||
lastRequest: apiKey.lastRequest,
|
||||
expiresAt: apiKey.expiresAt,
|
||||
createdAt: apiKey.createdAt,
|
||||
updatedAt: apiKey.updatedAt,
|
||||
owner: {
|
||||
userId: owner.userId,
|
||||
memberId: owner.memberId,
|
||||
name: owner.userName,
|
||||
email: owner.userEmail,
|
||||
image: owner.userImage,
|
||||
},
|
||||
} satisfies OrganizationApiKeySummary
|
||||
})
|
||||
.filter((apiKey): apiKey is OrganizationApiKeySummary => apiKey !== null)
|
||||
}
|
||||
|
||||
export async function getOrganizationApiKeyById(input: {
|
||||
organizationId: OrganizationId
|
||||
apiKeyId: ApiKeyId
|
||||
}) {
|
||||
const keys = await listOrganizationApiKeys(input.organizationId)
|
||||
return keys.find((apiKey) => apiKey.id === input.apiKeyId) ?? null
|
||||
}
|
||||
|
||||
export async function deleteOrganizationApiKey(input: {
|
||||
organizationId: OrganizationId
|
||||
apiKeyId: ApiKeyId
|
||||
}) {
|
||||
const apiKey = await getOrganizationApiKeyById(input)
|
||||
if (!apiKey) {
|
||||
return null
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(AuthApiKeyTable)
|
||||
.where(and(eq(AuthApiKeyTable.id, input.apiKeyId), eq(AuthApiKeyTable.referenceId, apiKey.owner.userId)))
|
||||
|
||||
return apiKey
|
||||
}
|
||||
|
||||
export function isScopedApiKeyForOrganization(input: {
|
||||
apiKey: DenApiKeySession | null
|
||||
organizationId: string
|
||||
}) {
|
||||
return input.apiKey?.metadata?.organizationId === input.organizationId
|
||||
}
|
||||
|
||||
export function getApiKeyScopedOrganizationId(apiKey: DenApiKeySession | null): DenTypeId<"organization"> | null {
|
||||
return apiKey?.metadata?.organizationId ?? null
|
||||
}
|
||||
@@ -32,13 +32,13 @@ app.use("*", async (c, next) => {
|
||||
if (env.corsOrigins.length > 0) {
|
||||
app.use(
|
||||
"*",
|
||||
cors({
|
||||
origin: env.corsOrigins,
|
||||
credentials: true,
|
||||
allowHeaders: ["Content-Type", "Authorization", "X-Request-Id"],
|
||||
allowMethods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
|
||||
exposeHeaders: ["Content-Length", "X-Request-Id"],
|
||||
maxAge: 600,
|
||||
cors({
|
||||
origin: env.corsOrigins,
|
||||
credentials: true,
|
||||
allowHeaders: ["Content-Type", "Authorization", "X-Api-Key", "X-Request-Id"],
|
||||
allowMethods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
|
||||
exposeHeaders: ["Content-Length", "X-Request-Id"],
|
||||
maxAge: 600,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,16 @@ import { db } from "./db.js"
|
||||
import { env } from "./env.js"
|
||||
import { sendDenOrganizationInvitationEmail, sendDenVerificationEmail } from "./email.js"
|
||||
import { syncDenSignupContact } from "./loops.js"
|
||||
import {
|
||||
DEN_API_KEY_DEFAULT_PREFIX,
|
||||
DEN_API_KEY_RATE_LIMIT_MAX,
|
||||
DEN_API_KEY_RATE_LIMIT_TIME_WINDOW_MS,
|
||||
} from "./api-keys.js"
|
||||
import { denOrganizationAccess, denOrganizationStaticRoles } from "./organization-access.js"
|
||||
import { seedDefaultOrganizationRoles } from "./orgs.js"
|
||||
import { createDenTypeId, normalizeDenTypeId } from "@openwork-ee/utils/typeid"
|
||||
import * as schema from "@openwork-ee/den-db/schema"
|
||||
import { apiKey } from "@better-auth/api-key"
|
||||
import { APIError } from "better-call"
|
||||
import { betterAuth } from "better-auth"
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle"
|
||||
@@ -71,6 +77,9 @@ export const auth = betterAuth({
|
||||
return createDenTypeId("account")
|
||||
case "verification":
|
||||
return createDenTypeId("verification")
|
||||
case "apikey":
|
||||
case "apiKey":
|
||||
return createDenTypeId("apiKey")
|
||||
case "rateLimit":
|
||||
return createDenTypeId("rateLimit")
|
||||
case "organization":
|
||||
@@ -201,5 +210,18 @@ export const auth = betterAuth({
|
||||
},
|
||||
},
|
||||
}),
|
||||
apiKey({
|
||||
defaultPrefix: DEN_API_KEY_DEFAULT_PREFIX,
|
||||
enableMetadata: true,
|
||||
enableSessionForAPIKeys: true,
|
||||
maximumNameLength: 64,
|
||||
requireName: true,
|
||||
storage: "database",
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
maxRequests: DEN_API_KEY_RATE_LIMIT_MAX,
|
||||
timeWindow: DEN_API_KEY_RATE_LIMIT_TIME_WINDOW_MS,
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { normalizeDenTypeId } from "@openwork-ee/utils/typeid"
|
||||
import type { MiddlewareHandler } from "hono"
|
||||
import { isScopedApiKeyForOrganization } from "../api-keys.js"
|
||||
import { getOrganizationContextForUser, type OrganizationContext } from "../orgs.js"
|
||||
import type { AuthContextVariables } from "../session.js"
|
||||
|
||||
@@ -37,6 +38,21 @@ export const resolveOrganizationContextMiddleware: MiddlewareHandler<{
|
||||
return c.json({ error: "organization_not_found" }, 404) as never
|
||||
}
|
||||
|
||||
const apiKey = c.get("apiKey")
|
||||
if (apiKey && !isScopedApiKeyForOrganization({ apiKey, organizationId })) {
|
||||
return c.json({
|
||||
error: "forbidden",
|
||||
message: "This API key is scoped to a different organization.",
|
||||
}, 403) as never
|
||||
}
|
||||
|
||||
if (apiKey?.metadata?.orgMembershipId && apiKey.metadata.orgMembershipId !== context.currentMember.id) {
|
||||
return c.json({
|
||||
error: "forbidden",
|
||||
message: "This API key is no longer valid for the current organization member.",
|
||||
}, 403) as never
|
||||
}
|
||||
|
||||
c.set("organizationContext", context)
|
||||
await next()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { normalizeDenTypeId } from "@openwork-ee/utils/typeid"
|
||||
import type { MiddlewareHandler } from "hono"
|
||||
import { getApiKeyScopedOrganizationId } from "../api-keys.js"
|
||||
import { resolveUserOrganizations, type UserOrgSummary } from "../orgs.js"
|
||||
import type { AuthContextVariables } from "../session.js"
|
||||
|
||||
@@ -18,13 +19,24 @@ export const resolveUserOrganizationsMiddleware: MiddlewareHandler<{
|
||||
}
|
||||
|
||||
const session = c.get("session")
|
||||
const apiKey = c.get("apiKey")
|
||||
const scopedOrganizationId = getApiKeyScopedOrganizationId(apiKey)
|
||||
const resolved = await resolveUserOrganizations({
|
||||
activeOrganizationId: session?.activeOrganizationId ?? null,
|
||||
activeOrganizationId: scopedOrganizationId ?? session?.activeOrganizationId ?? null,
|
||||
userId: normalizeDenTypeId("user", user.id),
|
||||
})
|
||||
|
||||
c.set("userOrganizations", resolved.orgs)
|
||||
c.set("activeOrganizationId", resolved.activeOrgId)
|
||||
c.set("activeOrganizationSlug", resolved.activeOrgSlug)
|
||||
const scopedOrgs = scopedOrganizationId
|
||||
? resolved.orgs.filter((org) => org.id === scopedOrganizationId)
|
||||
: resolved.orgs
|
||||
|
||||
c.set("userOrganizations", scopedOrgs)
|
||||
c.set("activeOrganizationId", scopedOrganizationId ? scopedOrgs[0]?.id ?? null : resolved.activeOrgId)
|
||||
c.set(
|
||||
"activeOrganizationSlug",
|
||||
scopedOrganizationId
|
||||
? scopedOrgs[0]?.slug ?? null
|
||||
: resolved.activeOrgSlug,
|
||||
)
|
||||
await next()
|
||||
}
|
||||
|
||||
@@ -178,6 +178,12 @@ export function serializePermissionRecord(value: Record<string, string[]>) {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
function clonePermissionRecord(value: Record<string, readonly string[]>) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([resource, actions]) => [resource, [...actions]]),
|
||||
) as Record<string, string[]>
|
||||
}
|
||||
|
||||
async function listMembershipRows(userId: UserId) {
|
||||
return db
|
||||
.select()
|
||||
@@ -213,17 +219,18 @@ async function getInvitationById(invitationIdRaw: string) {
|
||||
|
||||
async function ensureDefaultDynamicRoles(orgId: OrgId) {
|
||||
for (const [role, permission] of Object.entries(denDefaultDynamicOrganizationRoles)) {
|
||||
const serializedPermission = serializePermissionRecord(clonePermissionRecord(permission))
|
||||
await db
|
||||
.insert(OrganizationRoleTable)
|
||||
.values({
|
||||
id: createDenTypeId("organizationRole"),
|
||||
organizationId: orgId,
|
||||
role,
|
||||
permission: serializePermissionRecord(permission),
|
||||
permission: serializedPermission,
|
||||
})
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
permission: serializePermissionRecord(permission),
|
||||
permission: serializedPermission,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -656,7 +663,7 @@ export async function getOrganizationContextForUser(input: {
|
||||
{
|
||||
id: "builtin-owner",
|
||||
role: "owner",
|
||||
permission: denOrganizationStaticRoles.owner.statements,
|
||||
permission: clonePermissionRecord(denOrganizationStaticRoles.owner.statements),
|
||||
builtIn: true,
|
||||
protected: true,
|
||||
createdAt: null,
|
||||
|
||||
112
ee/apps/den-api/src/routes/org/api-keys.ts
Normal file
112
ee/apps/den-api/src/routes/org/api-keys.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { Hono } from "hono"
|
||||
import { z } from "zod"
|
||||
import {
|
||||
buildOrganizationApiKeyMetadata,
|
||||
deleteOrganizationApiKey,
|
||||
DEN_API_KEY_RATE_LIMIT_MAX,
|
||||
DEN_API_KEY_RATE_LIMIT_TIME_WINDOW_MS,
|
||||
listOrganizationApiKeys,
|
||||
} from "../../api-keys.js"
|
||||
import { jsonValidator, paramValidator, requireUserMiddleware, resolveOrganizationContextMiddleware } from "../../middleware/index.js"
|
||||
import { auth } from "../../auth.js"
|
||||
import type { OrgRouteVariables } from "./shared.js"
|
||||
import { ensureApiKeyManager, idParamSchema, orgIdParamSchema } from "./shared.js"
|
||||
|
||||
const createOrganizationApiKeySchema = z.object({
|
||||
name: z.string().trim().min(2).max(64),
|
||||
})
|
||||
|
||||
const apiKeyIdParamSchema = orgIdParamSchema.extend(idParamSchema("apiKeyId").shape)
|
||||
|
||||
export function registerOrgApiKeyRoutes<T extends { Variables: OrgRouteVariables }>(app: Hono<T>) {
|
||||
app.get(
|
||||
"/v1/orgs/:orgId/api-keys",
|
||||
requireUserMiddleware,
|
||||
paramValidator(orgIdParamSchema),
|
||||
resolveOrganizationContextMiddleware,
|
||||
async (c) => {
|
||||
const access = ensureApiKeyManager(c)
|
||||
if (!access.ok) {
|
||||
return c.json(access.response, access.response.error === "forbidden" ? 403 : 404)
|
||||
}
|
||||
|
||||
const payload = c.get("organizationContext")
|
||||
const apiKeys = await listOrganizationApiKeys(payload.organization.id)
|
||||
return c.json({ apiKeys })
|
||||
},
|
||||
)
|
||||
|
||||
app.post(
|
||||
"/v1/orgs/:orgId/api-keys",
|
||||
requireUserMiddleware,
|
||||
paramValidator(orgIdParamSchema),
|
||||
resolveOrganizationContextMiddleware,
|
||||
jsonValidator(createOrganizationApiKeySchema),
|
||||
async (c) => {
|
||||
const access = ensureApiKeyManager(c)
|
||||
if (!access.ok) {
|
||||
return c.json(access.response, access.response.error === "forbidden" ? 403 : 404)
|
||||
}
|
||||
|
||||
const payload = c.get("organizationContext")
|
||||
const input = c.req.valid("json")
|
||||
const created = await auth.api.createApiKey({
|
||||
body: {
|
||||
userId: payload.currentMember.userId,
|
||||
name: input.name,
|
||||
metadata: buildOrganizationApiKeyMetadata({
|
||||
organizationId: payload.organization.id,
|
||||
orgMembershipId: payload.currentMember.id,
|
||||
issuedByUserId: payload.currentMember.userId,
|
||||
issuedByOrgMembershipId: payload.currentMember.id,
|
||||
}),
|
||||
rateLimitEnabled: true,
|
||||
rateLimitMax: DEN_API_KEY_RATE_LIMIT_MAX,
|
||||
rateLimitTimeWindow: DEN_API_KEY_RATE_LIMIT_TIME_WINDOW_MS,
|
||||
},
|
||||
})
|
||||
|
||||
return c.json({
|
||||
apiKey: {
|
||||
id: created.id,
|
||||
name: created.name,
|
||||
start: created.start,
|
||||
prefix: created.prefix,
|
||||
enabled: created.enabled,
|
||||
rateLimitEnabled: created.rateLimitEnabled,
|
||||
rateLimitMax: created.rateLimitMax,
|
||||
rateLimitTimeWindow: created.rateLimitTimeWindow,
|
||||
createdAt: created.createdAt,
|
||||
updatedAt: created.updatedAt,
|
||||
},
|
||||
key: created.key,
|
||||
}, 201)
|
||||
},
|
||||
)
|
||||
|
||||
app.delete(
|
||||
"/v1/orgs/:orgId/api-keys/:apiKeyId",
|
||||
requireUserMiddleware,
|
||||
paramValidator(apiKeyIdParamSchema),
|
||||
resolveOrganizationContextMiddleware,
|
||||
async (c) => {
|
||||
const access = ensureApiKeyManager(c)
|
||||
if (!access.ok) {
|
||||
return c.json(access.response, access.response.error === "forbidden" ? 403 : 404)
|
||||
}
|
||||
|
||||
const payload = c.get("organizationContext")
|
||||
const params = c.req.valid("param")
|
||||
const deleted = await deleteOrganizationApiKey({
|
||||
organizationId: payload.organization.id,
|
||||
apiKeyId: params.apiKeyId,
|
||||
})
|
||||
|
||||
if (!deleted) {
|
||||
return c.json({ error: "api_key_not_found" }, 404)
|
||||
}
|
||||
|
||||
return c.body(null, 204)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -24,8 +24,27 @@ const acceptInvitationSchema = z.object({
|
||||
id: z.string().trim().min(1),
|
||||
})
|
||||
|
||||
function getStoredSessionId(session: { id?: string | null } | null) {
|
||||
if (!session?.id) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return normalizeDenTypeId("session", session.id)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function registerOrgCoreRoutes<T extends { Variables: OrgRouteVariables }>(app: Hono<T>) {
|
||||
app.post("/v1/orgs", requireUserMiddleware, jsonValidator(createOrganizationSchema), async (c) => {
|
||||
if (c.get("apiKey")) {
|
||||
return c.json({
|
||||
error: "forbidden",
|
||||
message: "API keys cannot create organizations.",
|
||||
}, 403)
|
||||
}
|
||||
|
||||
const user = c.get("user")
|
||||
const session = c.get("session")
|
||||
const input = c.req.valid("json")
|
||||
@@ -58,8 +77,9 @@ export function registerOrgCoreRoutes<T extends { Variables: OrgRouteVariables }
|
||||
name: input.name,
|
||||
})
|
||||
|
||||
if (session?.id) {
|
||||
await setSessionActiveOrganization(normalizeDenTypeId("session", session.id), organizationId)
|
||||
const sessionId = getStoredSessionId(session)
|
||||
if (sessionId) {
|
||||
await setSessionActiveOrganization(sessionId, organizationId)
|
||||
}
|
||||
|
||||
const organization = await db
|
||||
@@ -83,6 +103,13 @@ export function registerOrgCoreRoutes<T extends { Variables: OrgRouteVariables }
|
||||
})
|
||||
|
||||
app.post("/v1/orgs/invitations/accept", requireUserMiddleware, jsonValidator(acceptInvitationSchema), async (c) => {
|
||||
if (c.get("apiKey")) {
|
||||
return c.json({
|
||||
error: "forbidden",
|
||||
message: "API keys cannot accept organization invitations.",
|
||||
}, 403)
|
||||
}
|
||||
|
||||
const user = c.get("user")
|
||||
const session = c.get("session")
|
||||
const input = c.req.valid("json")
|
||||
@@ -102,8 +129,9 @@ export function registerOrgCoreRoutes<T extends { Variables: OrgRouteVariables }
|
||||
return c.json({ error: "invitation_not_found" }, 404)
|
||||
}
|
||||
|
||||
if (session?.id) {
|
||||
await setSessionActiveOrganization(normalizeDenTypeId("session", session.id), accepted.member.organizationId)
|
||||
const sessionId = getStoredSessionId(session)
|
||||
if (sessionId) {
|
||||
await setSessionActiveOrganization(sessionId, accepted.member.organizationId)
|
||||
}
|
||||
|
||||
const orgRows = await db
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Hono } from "hono"
|
||||
import { registerOrgApiKeyRoutes } from "./api-keys.js"
|
||||
import type { OrgRouteVariables } from "./shared.js"
|
||||
import { registerOrgCoreRoutes } from "./core.js"
|
||||
import { registerOrgInvitationRoutes } from "./invitations.js"
|
||||
@@ -11,6 +12,7 @@ import { registerOrgTemplateRoutes } from "./templates.js"
|
||||
|
||||
export function registerOrgRoutes<T extends { Variables: OrgRouteVariables }>(app: Hono<T>) {
|
||||
registerOrgCoreRoutes(app)
|
||||
registerOrgApiKeyRoutes(app)
|
||||
registerOrgInvitationRoutes(app)
|
||||
registerOrgLlmProviderRoutes(app)
|
||||
registerOrgMemberRoutes(app)
|
||||
|
||||
@@ -128,6 +128,30 @@ export function ensureTeamManager(c: { get: (key: "organizationContext") => OrgR
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureApiKeyManager(c: { get: (key: "organizationContext") => OrgRouteVariables["organizationContext"] }) {
|
||||
const payload = c.get("organizationContext")
|
||||
if (!payload) {
|
||||
return {
|
||||
ok: false as const,
|
||||
response: {
|
||||
error: "organization_not_found",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.currentMember.isOwner || memberHasRole(payload.currentMember.role, "admin")) {
|
||||
return { ok: true as const }
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false as const,
|
||||
response: {
|
||||
error: "forbidden",
|
||||
message: "Only organization owners and admins can manage API keys.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createInvitationId() {
|
||||
return createDenTypeId("invitation")
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { and, eq, gt } from "@openwork-ee/den-db/drizzle"
|
||||
import { AuthSessionTable, AuthUserTable } from "@openwork-ee/den-db/schema"
|
||||
import { normalizeDenTypeId } from "@openwork-ee/utils/typeid"
|
||||
import type { MiddlewareHandler } from "hono"
|
||||
import { DEN_API_KEY_HEADER, getApiKeySessionById, type DenApiKeySession } from "./api-keys.js"
|
||||
import { auth } from "./auth.js"
|
||||
import { db } from "./db.js"
|
||||
|
||||
@@ -11,6 +12,7 @@ type AuthSessionValue = NonNullable<AuthSessionLike>
|
||||
export type AuthContextVariables = {
|
||||
user: AuthSessionValue["user"] | null
|
||||
session: AuthSessionValue["session"] | null
|
||||
apiKey: DenApiKeySession | null
|
||||
}
|
||||
|
||||
function readBearerToken(headers: Headers): string | null {
|
||||
@@ -73,7 +75,13 @@ async function getSessionFromBearerToken(token: string): Promise<AuthSessionLike
|
||||
}
|
||||
|
||||
export async function getRequestSession(headers: Headers): Promise<AuthSessionLike> {
|
||||
const cookieSession = await auth.api.getSession({ headers })
|
||||
let cookieSession: AuthSessionLike
|
||||
try {
|
||||
cookieSession = await auth.api.getSession({ headers })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (cookieSession?.user?.id) {
|
||||
return {
|
||||
...cookieSession,
|
||||
@@ -92,9 +100,19 @@ export async function getRequestSession(headers: Headers): Promise<AuthSessionLi
|
||||
return getSessionFromBearerToken(bearerToken)
|
||||
}
|
||||
|
||||
async function getRequestApiKeySession(headers: Headers, session: AuthSessionLike): Promise<DenApiKeySession | null> {
|
||||
if (!headers.has(DEN_API_KEY_HEADER) || !session?.session?.id) {
|
||||
return null
|
||||
}
|
||||
|
||||
return getApiKeySessionById(session.session.id)
|
||||
}
|
||||
|
||||
export const sessionMiddleware: MiddlewareHandler<{ Variables: AuthContextVariables }> = async (c, next) => {
|
||||
const resolved = await getRequestSession(c.req.raw.headers)
|
||||
const apiKey = await getRequestApiKeySession(c.req.raw.headers, resolved)
|
||||
c.set("user", resolved?.user ?? null)
|
||||
c.set("session", resolved?.session ?? null)
|
||||
c.set("apiKey", apiKey)
|
||||
await next()
|
||||
}
|
||||
|
||||
@@ -77,6 +77,29 @@ export type DenOrgRole = {
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type DenOrgApiKey = {
|
||||
id: string;
|
||||
configId: string;
|
||||
name: string | null;
|
||||
start: string | null;
|
||||
prefix: string | null;
|
||||
enabled: boolean;
|
||||
rateLimitEnabled: boolean;
|
||||
rateLimitMax: number | null;
|
||||
rateLimitTimeWindow: number | null;
|
||||
lastRequest: string | null;
|
||||
expiresAt: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
owner: {
|
||||
userId: string;
|
||||
memberId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
image: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type DenOrgContext = {
|
||||
organization: {
|
||||
id: string;
|
||||
@@ -161,6 +184,7 @@ export function getOrgAccessFlags(roleValue: string, isOwner: boolean) {
|
||||
canManageMembers: isOwner,
|
||||
canManageRoles: isOwner,
|
||||
canManageTeams: isAdmin,
|
||||
canManageApiKeys: isAdmin,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -220,6 +244,10 @@ export function getBillingRoute(orgSlug: string): string {
|
||||
return `${getOrgDashboardRoute(orgSlug)}/billing`;
|
||||
}
|
||||
|
||||
export function getApiKeysRoute(orgSlug: string): string {
|
||||
return `${getOrgDashboardRoute(orgSlug)}/api-keys`;
|
||||
}
|
||||
|
||||
export function getSkillHubsRoute(orgSlug: string): string {
|
||||
return `${getOrgDashboardRoute(orgSlug)}/skill-hubs`;
|
||||
}
|
||||
@@ -509,3 +537,52 @@ export function parseInvitationPreviewPayload(payload: unknown): DenInvitationPr
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function parseOrgApiKeysPayload(payload: unknown): DenOrgApiKey[] {
|
||||
if (!isRecord(payload) || !Array.isArray(payload.apiKeys)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return payload.apiKeys
|
||||
.map((entry) => {
|
||||
if (!isRecord(entry) || !isRecord(entry.owner)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = asString(entry.id);
|
||||
const configId = asString(entry.configId);
|
||||
const owner = entry.owner;
|
||||
const ownerUserId = asString(owner.userId);
|
||||
const ownerMemberId = asString(owner.memberId);
|
||||
const ownerName = asString(owner.name);
|
||||
const ownerEmail = asString(owner.email);
|
||||
|
||||
if (!id || !configId || !ownerUserId || !ownerMemberId || !ownerName || !ownerEmail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
configId,
|
||||
name: asString(entry.name),
|
||||
start: asString(entry.start),
|
||||
prefix: asString(entry.prefix),
|
||||
enabled: asBoolean(entry.enabled),
|
||||
rateLimitEnabled: asBoolean(entry.rateLimitEnabled),
|
||||
rateLimitMax: typeof entry.rateLimitMax === "number" ? entry.rateLimitMax : null,
|
||||
rateLimitTimeWindow: typeof entry.rateLimitTimeWindow === "number" ? entry.rateLimitTimeWindow : null,
|
||||
lastRequest: asIsoString(entry.lastRequest),
|
||||
expiresAt: asIsoString(entry.expiresAt),
|
||||
createdAt: asIsoString(entry.createdAt),
|
||||
updatedAt: asIsoString(entry.updatedAt),
|
||||
owner: {
|
||||
userId: ownerUserId,
|
||||
memberId: ownerMemberId,
|
||||
name: ownerName,
|
||||
email: ownerEmail,
|
||||
image: asString(owner.image),
|
||||
},
|
||||
} satisfies DenOrgApiKey;
|
||||
})
|
||||
.filter((entry): entry is DenOrgApiKey => entry !== null);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
"use client";
|
||||
|
||||
import { type FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import { Copy, KeyRound, Trash2 } from "lucide-react";
|
||||
import { DashboardPageTemplate } from "../../../../_components/ui/dashboard-page-template";
|
||||
import { DenButton } from "../../../../_components/ui/button";
|
||||
import { DenInput } from "../../../../_components/ui/input";
|
||||
import { getErrorMessage, requestJson } from "../../../../_lib/den-flow";
|
||||
import { getOrgAccessFlags, parseOrgApiKeysPayload, type DenOrgApiKey } from "../../../../_lib/den-org";
|
||||
import { useOrgDashboard } from "../_providers/org-dashboard-provider";
|
||||
|
||||
function formatDateTime(value: string | null) {
|
||||
if (!value) {
|
||||
return "Never";
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return "Never";
|
||||
}
|
||||
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function formatKeyPreview(apiKey: DenOrgApiKey) {
|
||||
if (apiKey.start) {
|
||||
return `${apiKey.start}...`;
|
||||
}
|
||||
|
||||
if (apiKey.prefix) {
|
||||
return `${apiKey.prefix}${apiKey.id.slice(0, 6)}...`;
|
||||
}
|
||||
|
||||
return `${apiKey.id.slice(0, 6)}...`;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function getCreatedKey(payload: unknown) {
|
||||
if (!isRecord(payload) || typeof payload.key !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return payload.key;
|
||||
}
|
||||
|
||||
export function ApiKeysScreen() {
|
||||
const { orgId, orgContext } = useOrgDashboard();
|
||||
const [apiKeys, setApiKeys] = useState<DenOrgApiKey[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [createdKey, setCreatedKey] = useState<string | null>(null);
|
||||
const [createdKeyName, setCreatedKeyName] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const access = useMemo(
|
||||
() => getOrgAccessFlags(orgContext?.currentMember.role ?? "member", orgContext?.currentMember.isOwner ?? false),
|
||||
[orgContext?.currentMember.isOwner, orgContext?.currentMember.role],
|
||||
);
|
||||
|
||||
async function loadApiKeys() {
|
||||
if (!orgId || !access.canManageApiKeys) {
|
||||
setApiKeys([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { response, payload } = await requestJson(`/v1/orgs/${encodeURIComponent(orgId)}/api-keys`, { method: "GET" }, 12000);
|
||||
if (!response.ok) {
|
||||
throw new Error(getErrorMessage(payload, `Failed to load API keys (${response.status}).`));
|
||||
}
|
||||
|
||||
setApiKeys(parseOrgApiKeysPayload(payload));
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : "Failed to load API keys.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadApiKeys();
|
||||
}, [orgId, access.canManageApiKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!copied) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = window.setTimeout(() => setCopied(false), 1500);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [copied]);
|
||||
|
||||
async function handleCreate(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!orgId) {
|
||||
setError("Organization not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
setCreatedKey(null);
|
||||
setCreatedKeyName(null);
|
||||
setCopied(false);
|
||||
try {
|
||||
const { response, payload } = await requestJson(
|
||||
`/v1/orgs/${encodeURIComponent(orgId)}/api-keys`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name }),
|
||||
},
|
||||
12000,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(getErrorMessage(payload, `Failed to create API key (${response.status}).`));
|
||||
}
|
||||
|
||||
const nextKey = getCreatedKey(payload);
|
||||
if (!nextKey) {
|
||||
throw new Error("API key was created, but the secret was not returned.");
|
||||
}
|
||||
|
||||
setCreatedKey(nextKey);
|
||||
setCreatedKeyName(name);
|
||||
setName("");
|
||||
setShowCreateForm(false);
|
||||
await loadApiKeys();
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : "Failed to create API key.");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateForm() {
|
||||
setError(null);
|
||||
setCopied(false);
|
||||
setCreatedKey(null);
|
||||
setCreatedKeyName(null);
|
||||
setName("");
|
||||
setShowCreateForm(true);
|
||||
}
|
||||
|
||||
function closeCreateForm() {
|
||||
setName("");
|
||||
setShowCreateForm(false);
|
||||
}
|
||||
|
||||
async function handleDelete(apiKey: DenOrgApiKey) {
|
||||
if (!orgId || !window.confirm(`Delete ${apiKey.name ?? apiKey.start ?? "this API key"}? This cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingId(apiKey.id);
|
||||
setError(null);
|
||||
try {
|
||||
const { response, payload } = await requestJson(
|
||||
`/v1/orgs/${encodeURIComponent(orgId)}/api-keys/${encodeURIComponent(apiKey.id)}`,
|
||||
{ method: "DELETE" },
|
||||
12000,
|
||||
);
|
||||
|
||||
if (response.status !== 204 && !response.ok) {
|
||||
throw new Error(getErrorMessage(payload, `Failed to delete API key (${response.status}).`));
|
||||
}
|
||||
|
||||
await loadApiKeys();
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : "Failed to delete API key.");
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyCreatedKey() {
|
||||
if (!createdKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(createdKey);
|
||||
setCopied(true);
|
||||
} catch {
|
||||
setError("Could not copy the API key. Copy it manually before leaving this page.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!orgContext) {
|
||||
return (
|
||||
<DashboardPageTemplate
|
||||
icon={KeyRound}
|
||||
badgeLabel="Admin"
|
||||
title="API Keys"
|
||||
description="Create named, rate-limited API keys for your own org membership and revoke any key in the workspace when needed."
|
||||
colors={["#E6FFFA", "#0F766E", "#14B8A6", "#99F6E4"]}
|
||||
>
|
||||
<div className="rounded-[28px] border border-gray-200 bg-white px-6 py-10 text-[15px] text-gray-500">
|
||||
Loading organization details...
|
||||
</div>
|
||||
</DashboardPageTemplate>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardPageTemplate
|
||||
icon={KeyRound}
|
||||
badgeLabel="Admin"
|
||||
title="API Keys"
|
||||
description="Create named, rate-limited API keys for your own org membership and revoke any key in the workspace when needed."
|
||||
colors={["#E6FFFA", "#0F766E", "#14B8A6", "#99F6E4"]}
|
||||
>
|
||||
{!access.canManageApiKeys ? (
|
||||
<div className="rounded-[28px] border border-amber-200 bg-amber-50 px-6 py-5 text-[14px] text-amber-900">
|
||||
Only organization owners and admins can view or manage API keys.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{error ? (
|
||||
<div className="mb-6 rounded-[28px] border border-red-200 bg-red-50 px-6 py-4 text-[14px] text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mb-6 rounded-[30px] border border-gray-200 bg-white p-6 shadow-[0_18px_48px_-34px_rgba(15,23,42,0.22)]">
|
||||
{createdKey ? (
|
||||
<div className="rounded-[24px] bg-[#0f172a] p-6 text-white">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[16px] font-semibold tracking-[-0.03em]">
|
||||
{createdKeyName ? `${createdKeyName} is ready` : "Your new API key is ready"}
|
||||
</p>
|
||||
<p className="mt-1 text-[14px] leading-6 text-slate-300">
|
||||
Copy it now. After this state closes, only the name and leading characters remain visible in the table.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-[20px] border border-white/10 bg-white/5 p-4">
|
||||
<code className="block break-all text-[13px] leading-6 text-emerald-200">{createdKey}</code>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap justify-end gap-3">
|
||||
<DenButton variant="secondary" icon={Copy} onClick={() => void copyCreatedKey()}>
|
||||
{copied ? "Copied" : "Copy key"}
|
||||
</DenButton>
|
||||
<DenButton onClick={openCreateForm}>Create another key</DenButton>
|
||||
</div>
|
||||
</div>
|
||||
) : showCreateForm ? (
|
||||
<form onSubmit={handleCreate}>
|
||||
<div className="mb-5 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[16px] font-semibold tracking-[-0.03em] text-gray-900">Issue a new key</p>
|
||||
<p className="mt-1 text-[14px] leading-6 text-gray-500">
|
||||
Keys are always issued for your own membership in this workspace and inherit the built-in request limit.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="grid gap-3">
|
||||
<span className="text-[14px] font-medium text-gray-700">Key name</span>
|
||||
<DenInput
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="CI worker"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="mt-5 flex flex-wrap justify-end gap-3">
|
||||
<DenButton type="button" variant="secondary" onClick={closeCreateForm}>
|
||||
Cancel
|
||||
</DenButton>
|
||||
<DenButton type="submit" loading={creating}>
|
||||
Create API key
|
||||
</DenButton>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[16px] font-semibold tracking-[-0.03em] text-gray-900">Create a new API key</p>
|
||||
<p className="mt-1 text-[14px] leading-6 text-gray-500">
|
||||
Issue a named, rate-limited key for your own org membership when you need one.
|
||||
</p>
|
||||
</div>
|
||||
<DenButton onClick={openCreateForm}>New key</DenButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-[28px] border border-gray-100 bg-white">
|
||||
<div className="grid grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)_180px_120px] gap-4 border-b border-gray-100 px-6 py-3 text-[11px] font-medium uppercase tracking-wide text-gray-400">
|
||||
<span>Key</span>
|
||||
<span>Owner</span>
|
||||
<span>Last used</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{busy ? (
|
||||
<div className="px-6 py-8 text-center text-[13px] text-gray-400">Loading API keys...</div>
|
||||
) : apiKeys.length === 0 ? (
|
||||
<div className="px-6 py-8 text-center text-[13px] text-gray-400">No API keys for this workspace yet.</div>
|
||||
) : (
|
||||
apiKeys.map((apiKey) => (
|
||||
<div
|
||||
key={apiKey.id}
|
||||
className="grid grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)_180px_120px] items-center gap-4 border-b border-gray-100 px-6 py-4 transition hover:bg-gray-50/70 last:border-b-0"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-[14px] font-medium text-gray-900">
|
||||
{apiKey.name ?? apiKey.start ?? "Untitled key"}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-[12px] text-gray-400">
|
||||
{formatKeyPreview(apiKey)} {formatDateTime(apiKey.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-[13px] font-medium text-gray-900">{apiKey.owner.name}</p>
|
||||
<p className="truncate text-[12px] text-gray-400">{apiKey.owner.email}</p>
|
||||
</div>
|
||||
|
||||
<span className="text-[13px] text-gray-500">{formatDateTime(apiKey.lastRequest)}</span>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<DenButton
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
icon={Trash2}
|
||||
onClick={() => void handleDelete(apiKey)}
|
||||
disabled={deletingId === apiKey.id}
|
||||
>
|
||||
{deletingId === apiKey.id ? "Deleting..." : "Delete"}
|
||||
</DenButton>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DashboardPageTemplate>
|
||||
);
|
||||
}
|
||||
@@ -5,14 +5,17 @@ import {
|
||||
Bot,
|
||||
CreditCard,
|
||||
Cpu,
|
||||
KeyRound,
|
||||
Monitor,
|
||||
Share2,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
getBackgroundAgentsRoute,
|
||||
getApiKeysRoute,
|
||||
getBillingRoute,
|
||||
getCustomLlmProvidersRoute,
|
||||
getOrgAccessFlags,
|
||||
getMembersRoute,
|
||||
getSharedSetupsRoute,
|
||||
} from "../../../../_lib/den-org";
|
||||
@@ -48,6 +51,10 @@ export function DashboardOverviewScreen() {
|
||||
const { orgSlug, activeOrg, orgContext } = useOrgDashboard();
|
||||
const { user } = useDenFlow();
|
||||
const { templates } = useOrgTemplates(orgSlug);
|
||||
const access = getOrgAccessFlags(
|
||||
orgContext?.currentMember.role ?? "member",
|
||||
orgContext?.currentMember.isOwner ?? false,
|
||||
);
|
||||
|
||||
const quickActions = [
|
||||
{
|
||||
@@ -62,6 +69,14 @@ export function DashboardOverviewScreen() {
|
||||
href: getMembersRoute(orgSlug),
|
||||
tint: "bg-cyan-50 text-cyan-600 group-hover:bg-cyan-100",
|
||||
},
|
||||
...(access.canManageApiKeys
|
||||
? [{
|
||||
label: "API Keys",
|
||||
icon: KeyRound,
|
||||
href: getApiKeysRoute(orgSlug),
|
||||
tint: "bg-emerald-50 text-emerald-600 group-hover:bg-emerald-100",
|
||||
}]
|
||||
: []),
|
||||
{
|
||||
label: "Shared Workspace",
|
||||
icon: Bot,
|
||||
|
||||
@@ -6,11 +6,11 @@ import { useMemo, useState } from "react";
|
||||
import {
|
||||
BookOpen,
|
||||
Bot,
|
||||
ChevronDown,
|
||||
CreditCard,
|
||||
Cpu,
|
||||
FileText,
|
||||
Home,
|
||||
KeyRound,
|
||||
LogOut,
|
||||
MessageSquare,
|
||||
Share2,
|
||||
@@ -20,8 +20,10 @@ import { useDenFlow } from "../../../../_providers/den-flow-provider";
|
||||
import {
|
||||
formatRoleLabel,
|
||||
getBackgroundAgentsRoute,
|
||||
getApiKeysRoute,
|
||||
getBillingRoute,
|
||||
getCustomLlmProvidersRoute,
|
||||
getOrgAccessFlags,
|
||||
getMembersRoute,
|
||||
getOrgDashboardRoute,
|
||||
getSharedSetupsRoute,
|
||||
@@ -96,6 +98,9 @@ function getDashboardPageTitle(pathname: string, orgSlug: string | null) {
|
||||
if (pathname.startsWith(getMembersRoute(orgSlug))) {
|
||||
return "Members";
|
||||
}
|
||||
if (pathname.startsWith(getApiKeysRoute(orgSlug))) {
|
||||
return "API Keys";
|
||||
}
|
||||
if (pathname.startsWith(getBackgroundAgentsRoute(orgSlug))) {
|
||||
return "Shared Workspaces";
|
||||
}
|
||||
@@ -118,14 +123,18 @@ export function OrgDashboardShell({ children }: { children: React.ReactNode }) {
|
||||
const {
|
||||
activeOrg,
|
||||
orgDirectory,
|
||||
orgContext,
|
||||
orgBusy,
|
||||
orgError,
|
||||
mutationBusy,
|
||||
createOrganization,
|
||||
switchOrganization,
|
||||
} = useOrgDashboard();
|
||||
const [switcherOpen, setSwitcherOpen] = useState(false);
|
||||
|
||||
const access = getOrgAccessFlags(
|
||||
orgContext?.currentMember.role ?? "member",
|
||||
orgContext?.currentMember.isOwner ?? false,
|
||||
);
|
||||
|
||||
const pageTitle = getDashboardPageTitle(pathname, activeOrg?.slug ?? null);
|
||||
const feedbackHref = buildDenFeedbackUrl({
|
||||
pathname,
|
||||
@@ -166,6 +175,13 @@ export function OrgDashboardShell({ children }: { children: React.ReactNode }) {
|
||||
label: "Members",
|
||||
icon: Users,
|
||||
},
|
||||
...(access.canManageApiKeys
|
||||
? [{
|
||||
href: activeOrg ? getApiKeysRoute(activeOrg.slug) : "#",
|
||||
label: "API Keys",
|
||||
icon: KeyRound,
|
||||
}]
|
||||
: []),
|
||||
{
|
||||
href: activeOrg ? getBillingRoute(activeOrg.slug) : "/checkout",
|
||||
label: "Billing",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ApiKeysScreen } from "../_components/api-keys-screen";
|
||||
|
||||
export default function ApiKeysPage() {
|
||||
return <ApiKeysScreen />;
|
||||
}
|
||||
@@ -133,6 +133,7 @@ function buildHeaders(request: NextRequest, contentType: string | null): Headers
|
||||
"cookie",
|
||||
"user-agent",
|
||||
"x-requested-with",
|
||||
"x-api-key",
|
||||
"origin",
|
||||
"x-forwarded-for",
|
||||
];
|
||||
|
||||
28
ee/packages/den-db/drizzle/0009_api_keys.sql
Normal file
28
ee/packages/den-db/drizzle/0009_api_keys.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
CREATE TABLE IF NOT EXISTS `apikey` (
|
||||
`id` varchar(64) NOT NULL,
|
||||
`config_id` varchar(255) NOT NULL DEFAULT 'default',
|
||||
`name` varchar(255) DEFAULT NULL,
|
||||
`start` varchar(32) DEFAULT NULL,
|
||||
`prefix` varchar(255) DEFAULT NULL,
|
||||
`key` varchar(255) NOT NULL,
|
||||
`reference_id` varchar(64) NOT NULL,
|
||||
`refill_interval` bigint DEFAULT NULL,
|
||||
`refill_amount` int DEFAULT NULL,
|
||||
`last_refill_at` timestamp(3) NULL,
|
||||
`enabled` boolean NOT NULL DEFAULT true,
|
||||
`rate_limit_enabled` boolean NOT NULL DEFAULT true,
|
||||
`rate_limit_time_window` bigint DEFAULT NULL,
|
||||
`rate_limit_max` int DEFAULT NULL,
|
||||
`request_count` int DEFAULT 0,
|
||||
`remaining` int DEFAULT NULL,
|
||||
`last_request` timestamp(3) NULL,
|
||||
`expires_at` timestamp(3) NULL,
|
||||
`created_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
`permissions` text,
|
||||
`metadata` text,
|
||||
CONSTRAINT `apikey_id` PRIMARY KEY (`id`),
|
||||
KEY `apikey_config_id` (`config_id`),
|
||||
KEY `apikey_reference_id` (`reference_id`),
|
||||
KEY `apikey_key` (`key`)
|
||||
);
|
||||
@@ -57,6 +57,13 @@
|
||||
"when": 1775261156284,
|
||||
"tag": "0008_cynical_boomerang",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "5",
|
||||
"when": 1775350000000,
|
||||
"tag": "0009_api_keys",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { sql } from "drizzle-orm"
|
||||
import { boolean, index, mysqlTable, text, timestamp, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
|
||||
import { bigint, boolean, index, int, mysqlTable, text, timestamp, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
|
||||
import { denTypeIdColumn } from "../columns"
|
||||
|
||||
export const AuthUserTable = mysqlTable(
|
||||
@@ -74,7 +74,43 @@ export const AuthVerificationTable = mysqlTable(
|
||||
(table) => [index("verification_identifier").on(table.identifier)],
|
||||
)
|
||||
|
||||
export const AuthApiKeyTable = mysqlTable(
|
||||
"apikey",
|
||||
{
|
||||
id: varchar("id", { length: 64 }).notNull().primaryKey(),
|
||||
configId: varchar("config_id", { length: 255 }).notNull().default("default"),
|
||||
name: varchar("name", { length: 255 }),
|
||||
start: varchar("start", { length: 32 }),
|
||||
prefix: varchar("prefix", { length: 255 }),
|
||||
key: varchar("key", { length: 255 }).notNull(),
|
||||
referenceId: varchar("reference_id", { length: 64 }).notNull(),
|
||||
refillInterval: bigint("refill_interval", { mode: "number" }),
|
||||
refillAmount: int("refill_amount"),
|
||||
lastRefillAt: timestamp("last_refill_at", { fsp: 3 }),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
rateLimitEnabled: boolean("rate_limit_enabled").notNull().default(true),
|
||||
rateLimitTimeWindow: bigint("rate_limit_time_window", { mode: "number" }),
|
||||
rateLimitMax: int("rate_limit_max"),
|
||||
requestCount: int("request_count").default(0),
|
||||
remaining: int("remaining"),
|
||||
lastRequest: timestamp("last_request", { fsp: 3 }),
|
||||
expiresAt: timestamp("expires_at", { fsp: 3 }),
|
||||
createdAt: timestamp("created_at", { fsp: 3 }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { fsp: 3 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)`),
|
||||
permissions: text("permissions"),
|
||||
metadata: text("metadata"),
|
||||
},
|
||||
(table) => [
|
||||
index("apikey_config_id").on(table.configId),
|
||||
index("apikey_reference_id").on(table.referenceId),
|
||||
index("apikey_key").on(table.key),
|
||||
],
|
||||
)
|
||||
|
||||
export const user = AuthUserTable
|
||||
export const session = AuthSessionTable
|
||||
export const account = AuthAccountTable
|
||||
export const verification = AuthVerificationTable
|
||||
export const apikey = AuthApiKeyTable
|
||||
|
||||
@@ -6,6 +6,7 @@ export const denTypeIdPrefixes = {
|
||||
session: "ses",
|
||||
account: "acc",
|
||||
verification: "ver",
|
||||
apiKey: "apk",
|
||||
rateLimit: "rli",
|
||||
org: "org",
|
||||
organization: "org",
|
||||
|
||||
BIN
packages/app/pr/screenshots/den-api-keys/api-key-created.png
Normal file
BIN
packages/app/pr/screenshots/den-api-keys/api-key-created.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 173 KiB |
BIN
packages/app/pr/screenshots/den-api-keys/api-key-deleted.png
Normal file
BIN
packages/app/pr/screenshots/den-api-keys/api-key-deleted.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 166 KiB |
BIN
packages/app/pr/screenshots/den-api-keys/api-keys-page.png
Normal file
BIN
packages/app/pr/screenshots/den-api-keys/api-keys-page.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 161 KiB |
214
pnpm-lock.yaml
generated
214
pnpm-lock.yaml
generated
@@ -425,6 +425,9 @@ importers:
|
||||
|
||||
ee/apps/den-api:
|
||||
dependencies:
|
||||
'@better-auth/api-key':
|
||||
specifier: ^1.5.6
|
||||
version: 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(better-auth@1.5.6(@opentelemetry/api@1.9.0)(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.6)(kysely@0.28.15)(mysql2@3.17.4))(mysql2@3.17.4)(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.10))
|
||||
'@daytonaio/sdk':
|
||||
specifier: ^0.150.0
|
||||
version: 0.150.0(ws@8.19.0)
|
||||
@@ -441,11 +444,11 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/utils
|
||||
better-auth:
|
||||
specifier: ^1.4.18
|
||||
version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.6)(kysely@0.28.11)(mysql2@3.17.4))(mysql2@3.17.4)(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.10)
|
||||
specifier: ^1.5.6
|
||||
version: 1.5.6(@opentelemetry/api@1.9.0)(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.6)(kysely@0.28.15)(mysql2@3.17.4))(mysql2@3.17.4)(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.10)
|
||||
better-call:
|
||||
specifier: ^1.1.8
|
||||
version: 1.1.8(zod@4.3.6)
|
||||
specifier: ^1.3.2
|
||||
version: 1.3.2(zod@4.3.6)
|
||||
dotenv:
|
||||
specifier: ^16.4.5
|
||||
version: 16.6.1
|
||||
@@ -602,7 +605,7 @@ importers:
|
||||
version: 1.19.0
|
||||
drizzle-orm:
|
||||
specifier: ^0.45.1
|
||||
version: 0.45.1(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.6)(kysely@0.28.11)(mysql2@3.17.4)
|
||||
version: 0.45.1(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.6)(kysely@0.28.15)(mysql2@3.17.4)
|
||||
mysql2:
|
||||
specifier: ^3.11.3
|
||||
version: 3.17.4
|
||||
@@ -1038,23 +1041,84 @@ packages:
|
||||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@better-auth/core@1.4.18':
|
||||
resolution: {integrity: sha512-q+awYgC7nkLEBdx2sW0iJjkzgSHlIxGnOpsN1r/O1+a4m7osJNHtfK2mKJSL1I+GfNyIlxJF8WvD/NLuYMpmcg==}
|
||||
'@better-auth/api-key@1.5.6':
|
||||
resolution: {integrity: sha512-jr3m4/caFxn9BuY9pGDJ4B1HP1Qoqmyd7heBHm4KUFel+a9Whe/euROgZ/L+o7mbmUdZtreneaU15dpn0tJZ5g==}
|
||||
peerDependencies:
|
||||
'@better-auth/utils': 0.3.0
|
||||
'@better-auth/core': 1.5.6
|
||||
'@better-auth/utils': 0.3.1
|
||||
better-auth: 1.5.6
|
||||
|
||||
'@better-auth/core@1.5.6':
|
||||
resolution: {integrity: sha512-Ez9DZdIMFyxHremmoLz1emFPGNQomDC1jqqBPnZ6Ci+6TiGN3R9w/Y03cJn6I8r1ycKgOzeVMZtJ/erOZ27Gsw==}
|
||||
peerDependencies:
|
||||
'@better-auth/utils': 0.3.1
|
||||
'@better-fetch/fetch': 1.1.21
|
||||
better-call: 1.1.8
|
||||
'@cloudflare/workers-types': '>=4'
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
better-call: 1.3.2
|
||||
jose: ^6.1.0
|
||||
kysely: ^0.28.5
|
||||
nanostores: ^1.0.1
|
||||
peerDependenciesMeta:
|
||||
'@cloudflare/workers-types':
|
||||
optional: true
|
||||
|
||||
'@better-auth/telemetry@1.4.18':
|
||||
resolution: {integrity: sha512-e5rDF8S4j3Um/0LIVATL2in9dL4lfO2fr2v1Wio4qTMRbfxqnUDTa+6SZtwdeJrbc4O+a3c+IyIpjG9Q/6GpfQ==}
|
||||
'@better-auth/drizzle-adapter@1.5.6':
|
||||
resolution: {integrity: sha512-VfFFmaoFw3ug12SiSuIwzrMoHyIVmkMGWm9gZ4sXdYYVX4HboCL4m3fjzOhppcmK5OGatRuU+N1UX6wxCITcXw==}
|
||||
peerDependencies:
|
||||
'@better-auth/core': 1.4.18
|
||||
'@better-auth/core': 1.5.6
|
||||
'@better-auth/utils': ^0.3.0
|
||||
drizzle-orm: '>=0.41.0'
|
||||
peerDependenciesMeta:
|
||||
drizzle-orm:
|
||||
optional: true
|
||||
|
||||
'@better-auth/utils@0.3.0':
|
||||
resolution: {integrity: sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==}
|
||||
'@better-auth/kysely-adapter@1.5.6':
|
||||
resolution: {integrity: sha512-Fnf+h8WVKtw6lEOmVmiVVzDf3shJtM60AYf9XTnbdCeUd6MxN/KnaJZpkgtYnRs7a+nwtkVB+fg4lGETebGFXQ==}
|
||||
peerDependencies:
|
||||
'@better-auth/core': 1.5.6
|
||||
'@better-auth/utils': ^0.3.0
|
||||
kysely: ^0.27.0 || ^0.28.0
|
||||
peerDependenciesMeta:
|
||||
kysely:
|
||||
optional: true
|
||||
|
||||
'@better-auth/memory-adapter@1.5.6':
|
||||
resolution: {integrity: sha512-rS7ZsrIl5uvloUgNN0u9LOZJMMXnsZXVdUZ3MrTBKWM2KpoJjzPr9yN3Szyma5+0V7SltnzSGHPkYj2bEzzmlA==}
|
||||
peerDependencies:
|
||||
'@better-auth/core': 1.5.6
|
||||
'@better-auth/utils': ^0.3.0
|
||||
|
||||
'@better-auth/mongo-adapter@1.5.6':
|
||||
resolution: {integrity: sha512-6+M3MS2mor8fTUV3EI1FBLP0cs6QfbN+Ovx9+XxR/GdfKIBoNFzmPEPRbdGt+ft6PvrITsUm+T70+kkHgVSP6w==}
|
||||
peerDependencies:
|
||||
'@better-auth/core': 1.5.6
|
||||
'@better-auth/utils': ^0.3.0
|
||||
mongodb: ^6.0.0 || ^7.0.0
|
||||
peerDependenciesMeta:
|
||||
mongodb:
|
||||
optional: true
|
||||
|
||||
'@better-auth/prisma-adapter@1.5.6':
|
||||
resolution: {integrity: sha512-UxY9vQJs1Tt+O+T2YQnseDMlWmUSQvFZSBb5YiFRg7zcm+TEzujh4iX2/csA0YiZptLheovIuVWTP9nriewEBA==}
|
||||
peerDependencies:
|
||||
'@better-auth/core': 1.5.6
|
||||
'@better-auth/utils': ^0.3.0
|
||||
'@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
prisma: ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
peerDependenciesMeta:
|
||||
'@prisma/client':
|
||||
optional: true
|
||||
prisma:
|
||||
optional: true
|
||||
|
||||
'@better-auth/telemetry@1.5.6':
|
||||
resolution: {integrity: sha512-yXC7NSxnIFlxDkGdpD7KA+J9nqIQAPCJKe77GoaC5bWoe/DALo1MYorZfTgOafS7wrslNtsPT4feV/LJi1ubqQ==}
|
||||
peerDependencies:
|
||||
'@better-auth/core': 1.5.6
|
||||
|
||||
'@better-auth/utils@0.3.1':
|
||||
resolution: {integrity: sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg==}
|
||||
|
||||
'@better-fetch/fetch@1.1.21':
|
||||
resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==}
|
||||
@@ -3444,8 +3508,8 @@ packages:
|
||||
resolution: {integrity: sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==}
|
||||
hasBin: true
|
||||
|
||||
better-auth@1.4.18:
|
||||
resolution: {integrity: sha512-bnyifLWBPcYVltH3RhS7CM62MoelEqC6Q+GnZwfiDWNfepXoQZBjEvn4urcERC7NTKgKq5zNBM8rvPvRBa6xcg==}
|
||||
better-auth@1.5.6:
|
||||
resolution: {integrity: sha512-QSpJTqaT1XVfWRQe/fm3PgeuwOIlz1nWX/Dx7nsHStJ382bLzmDbQk2u7IT0IJ6wS5SRxfqEE1Ev9TXontgyAQ==}
|
||||
peerDependencies:
|
||||
'@lynx-js/react': '*'
|
||||
'@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
@@ -3506,8 +3570,8 @@ packages:
|
||||
vue:
|
||||
optional: true
|
||||
|
||||
better-call@1.1.8:
|
||||
resolution: {integrity: sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw==}
|
||||
better-call@1.3.2:
|
||||
resolution: {integrity: sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw==}
|
||||
peerDependencies:
|
||||
zod: ^4.0.0
|
||||
peerDependenciesMeta:
|
||||
@@ -4466,8 +4530,8 @@ packages:
|
||||
khroma@2.1.0:
|
||||
resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==}
|
||||
|
||||
kysely@0.28.11:
|
||||
resolution: {integrity: sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==}
|
||||
kysely@0.28.15:
|
||||
resolution: {integrity: sha512-r2clcf7HLWvDXaVUEvQymXJY4i3bSOIV3xsL/Upy3ZfSv5HeKsk9tsqbBptLvth5qHEIhxeHTA2jNLyQABkLBA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
langium@4.2.1:
|
||||
@@ -4836,8 +4900,8 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
nanostores@1.1.0:
|
||||
resolution: {integrity: sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA==}
|
||||
nanostores@1.2.0:
|
||||
resolution: {integrity: sha512-F0wCzbsH80G7XXo0Jd9/AVQC7ouWY6idUCTnMwW5t/Rv9W8qmO6endavDwg7TNp5GbugwSukFMVZqzPSrSMndg==}
|
||||
engines: {node: ^20.0.0 || >=22.0.0}
|
||||
|
||||
next@14.2.35:
|
||||
@@ -5345,8 +5409,8 @@ packages:
|
||||
resolution: {integrity: sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
set-cookie-parser@2.7.2:
|
||||
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
|
||||
set-cookie-parser@3.1.0:
|
||||
resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==}
|
||||
|
||||
sharp@0.34.5:
|
||||
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
||||
@@ -6694,24 +6758,62 @@ snapshots:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
||||
'@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)':
|
||||
'@better-auth/api-key@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(better-auth@1.5.6(@opentelemetry/api@1.9.0)(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.6)(kysely@0.28.15)(mysql2@3.17.4))(mysql2@3.17.4)(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.10))':
|
||||
dependencies:
|
||||
'@better-auth/utils': 0.3.0
|
||||
'@better-fetch/fetch': 1.1.21
|
||||
'@standard-schema/spec': 1.1.0
|
||||
better-call: 1.1.8(zod@4.3.6)
|
||||
jose: 6.1.3
|
||||
kysely: 0.28.11
|
||||
nanostores: 1.1.0
|
||||
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0)
|
||||
'@better-auth/utils': 0.3.1
|
||||
better-auth: 1.5.6(@opentelemetry/api@1.9.0)(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.6)(kysely@0.28.15)(mysql2@3.17.4))(mysql2@3.17.4)(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.10)
|
||||
zod: 4.3.6
|
||||
|
||||
'@better-auth/telemetry@1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))':
|
||||
'@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0)':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
|
||||
'@better-auth/utils': 0.3.0
|
||||
'@better-auth/utils': 0.3.1
|
||||
'@better-fetch/fetch': 1.1.21
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@opentelemetry/semantic-conventions': 1.40.0
|
||||
'@standard-schema/spec': 1.1.0
|
||||
better-call: 1.3.2(zod@4.3.6)
|
||||
jose: 6.1.3
|
||||
kysely: 0.28.15
|
||||
nanostores: 1.2.0
|
||||
zod: 4.3.6
|
||||
|
||||
'@better-auth/drizzle-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.6)(kysely@0.28.15)(mysql2@3.17.4))':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0)
|
||||
'@better-auth/utils': 0.3.1
|
||||
optionalDependencies:
|
||||
drizzle-orm: 0.45.1(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.6)(kysely@0.28.15)(mysql2@3.17.4)
|
||||
|
||||
'@better-auth/kysely-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(kysely@0.28.15)':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0)
|
||||
'@better-auth/utils': 0.3.1
|
||||
optionalDependencies:
|
||||
kysely: 0.28.15
|
||||
|
||||
'@better-auth/memory-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0)
|
||||
'@better-auth/utils': 0.3.1
|
||||
|
||||
'@better-auth/mongo-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0)
|
||||
'@better-auth/utils': 0.3.1
|
||||
|
||||
'@better-auth/prisma-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0)
|
||||
'@better-auth/utils': 0.3.1
|
||||
|
||||
'@better-auth/telemetry@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0))':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0)
|
||||
'@better-auth/utils': 0.3.1
|
||||
'@better-fetch/fetch': 1.1.21
|
||||
|
||||
'@better-auth/utils@0.3.0': {}
|
||||
'@better-auth/utils@0.3.1': {}
|
||||
|
||||
'@better-fetch/fetch@1.1.21': {}
|
||||
|
||||
@@ -9145,35 +9247,43 @@ snapshots:
|
||||
|
||||
baseline-browser-mapping@2.9.14: {}
|
||||
|
||||
better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.6)(kysely@0.28.11)(mysql2@3.17.4))(mysql2@3.17.4)(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.10):
|
||||
better-auth@1.5.6(@opentelemetry/api@1.9.0)(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.6)(kysely@0.28.15)(mysql2@3.17.4))(mysql2@3.17.4)(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.10):
|
||||
dependencies:
|
||||
'@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
|
||||
'@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))
|
||||
'@better-auth/utils': 0.3.0
|
||||
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0)
|
||||
'@better-auth/drizzle-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.6)(kysely@0.28.15)(mysql2@3.17.4))
|
||||
'@better-auth/kysely-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(kysely@0.28.15)
|
||||
'@better-auth/memory-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)
|
||||
'@better-auth/mongo-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)
|
||||
'@better-auth/prisma-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)
|
||||
'@better-auth/telemetry': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0))
|
||||
'@better-auth/utils': 0.3.1
|
||||
'@better-fetch/fetch': 1.1.21
|
||||
'@noble/ciphers': 2.1.1
|
||||
'@noble/hashes': 2.0.1
|
||||
better-call: 1.1.8(zod@4.3.6)
|
||||
better-call: 1.3.2(zod@4.3.6)
|
||||
defu: 6.1.4
|
||||
jose: 6.1.3
|
||||
kysely: 0.28.11
|
||||
nanostores: 1.1.0
|
||||
kysely: 0.28.15
|
||||
nanostores: 1.2.0
|
||||
zod: 4.3.6
|
||||
optionalDependencies:
|
||||
drizzle-kit: 0.31.9
|
||||
drizzle-orm: 0.45.1(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.6)(kysely@0.28.11)(mysql2@3.17.4)
|
||||
drizzle-orm: 0.45.1(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.6)(kysely@0.28.15)(mysql2@3.17.4)
|
||||
mysql2: 3.17.4
|
||||
next: 16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
solid-js: 1.9.10
|
||||
transitivePeerDependencies:
|
||||
- '@cloudflare/workers-types'
|
||||
- '@opentelemetry/api'
|
||||
|
||||
better-call@1.1.8(zod@4.3.6):
|
||||
better-call@1.3.2(zod@4.3.6):
|
||||
dependencies:
|
||||
'@better-auth/utils': 0.3.0
|
||||
'@better-auth/utils': 0.3.1
|
||||
'@better-fetch/fetch': 1.1.21
|
||||
rou3: 0.7.12
|
||||
set-cookie-parser: 2.7.2
|
||||
set-cookie-parser: 3.1.0
|
||||
optionalDependencies:
|
||||
zod: 4.3.6
|
||||
|
||||
@@ -9600,12 +9710,12 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.6)(kysely@0.28.11)(mysql2@3.17.4):
|
||||
drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.6)(kysely@0.28.15)(mysql2@3.17.4):
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@planetscale/database': 1.19.0
|
||||
bun-types: 1.3.6
|
||||
kysely: 0.28.11
|
||||
kysely: 0.28.15
|
||||
mysql2: 3.17.4
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
@@ -10137,7 +10247,7 @@ snapshots:
|
||||
|
||||
khroma@2.1.0: {}
|
||||
|
||||
kysely@0.28.11: {}
|
||||
kysely@0.28.15: {}
|
||||
|
||||
langium@4.2.1:
|
||||
dependencies:
|
||||
@@ -10702,7 +10812,7 @@ snapshots:
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
nanostores@1.1.0: {}
|
||||
nanostores@1.2.0: {}
|
||||
|
||||
next@14.2.35(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
|
||||
dependencies:
|
||||
@@ -11243,7 +11353,7 @@ snapshots:
|
||||
|
||||
seroval@1.3.2: {}
|
||||
|
||||
set-cookie-parser@2.7.2: {}
|
||||
set-cookie-parser@3.1.0: {}
|
||||
|
||||
sharp@0.34.5:
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user