feat: 🎸 cleaning up oauth
This commit is contained in:
@@ -1,4 +1,10 @@
|
||||
import { AppEnv, oauthAccessToken, oauthConsent } from "@autumn/shared";
|
||||
import {
|
||||
AppEnv,
|
||||
ErrCode,
|
||||
oauthAccessToken,
|
||||
oauthConsent,
|
||||
RecaseError,
|
||||
} from "@autumn/shared";
|
||||
import { and, eq, gt } from "drizzle-orm";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
|
||||
import { hashOAuthToken } from "@/utils/oauthUtils.js";
|
||||
@@ -19,15 +25,13 @@ export const handleCreateOAuthApiKeys = createRoute({
|
||||
|
||||
// Get Bearer token from Authorization header
|
||||
const authHeader = c.req.header("Authorization");
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
throw new RecaseError({
|
||||
message: "Missing or invalid Authorization header",
|
||||
code: ErrCode.Unauthorized,
|
||||
code: ErrCode.InvalidRequest,
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const accessToken = authHeader.substring(7);
|
||||
|
||||
@@ -49,7 +53,7 @@ export const handleCreateOAuthApiKeys = createRoute({
|
||||
if (tokenRecords.length === 0) {
|
||||
throw new RecaseError({
|
||||
message: "Invalid or expired access token",
|
||||
code: ErrCode.Unauthorized,
|
||||
code: ErrCode.InvalidRequest,
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
@@ -60,7 +64,7 @@ export const handleCreateOAuthApiKeys = createRoute({
|
||||
if (!userId) {
|
||||
throw new RecaseError({
|
||||
message: "Token missing user information",
|
||||
code: ErrCode.Unauthorized,
|
||||
code: ErrCode.InvalidRequest,
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
@@ -74,7 +78,6 @@ export const handleCreateOAuthApiKeys = createRoute({
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const clientId = tokenRecord.clientId;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { oauthConsent } from "@autumn/shared";
|
||||
import { ErrCode, oauthConsent, RecaseError } from "@autumn/shared";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import {
|
||||
apiKeys,
|
||||
ErrCode,
|
||||
oauthAccessToken,
|
||||
oauthConsent,
|
||||
oauthRefreshToken,
|
||||
RecaseError,
|
||||
} from "@autumn/shared";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { z } from "zod/v4";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
|
||||
import { clearSecretKeyCache } from "../../api-keys/cacheApiKeyUtils.js";
|
||||
import { clearSecretKeyCache } from "../../../dev/api-keys/cacheApiKeyUtils.js";
|
||||
|
||||
/**
|
||||
* Revoke an OAuth consent and delete all linked resources:
|
||||
@@ -52,7 +54,7 @@ export const handleRevokeConsent = createRoute({
|
||||
if (consentRecords.length === 0) {
|
||||
throw new RecaseError({
|
||||
message: "Consent not found",
|
||||
code: ErrCode.NotFound,
|
||||
code: "not_found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
@@ -62,11 +64,10 @@ export const handleRevokeConsent = createRoute({
|
||||
if (consent.referenceId !== org.id) {
|
||||
throw new RecaseError({
|
||||
message: "Consent does not belong to this organization",
|
||||
code: ErrCode.Forbidden,
|
||||
code: "forbidden",
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const { clientId, referenceId } = consent;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import {
|
||||
ApiVersion,
|
||||
type CreatePlanParams,
|
||||
planToProductV2,
|
||||
ProductNotFoundError,
|
||||
type ProductV2,
|
||||
productsAreSame,
|
||||
@@ -11,7 +14,7 @@ export const handlePlanHasCustomersV2 = createRoute({
|
||||
handler: async (c) => {
|
||||
const { product_id } = c.req.param();
|
||||
const ctx = c.get("ctx");
|
||||
const { db, features, org, env } = ctx;
|
||||
const { db, features, org, env, apiVersion } = ctx;
|
||||
|
||||
const body = await c.req.json();
|
||||
|
||||
@@ -32,8 +35,14 @@ export const handlePlanHasCustomersV2 = createRoute({
|
||||
internalProductId: product.internal_id,
|
||||
});
|
||||
|
||||
// V2.0+ (CLI): body is CreatePlanParams, convert to ProductV2
|
||||
// < V2.0 (Dashboard): body is already ProductV2
|
||||
const productV2 = apiVersion.gte(ApiVersion.V2_0)
|
||||
? (planToProductV2({ plan: body as CreatePlanParams, features }) as ProductV2)
|
||||
: (body as ProductV2);
|
||||
|
||||
const { itemsSame, freeTrialsSame } = productsAreSame({
|
||||
newProductV2: body as ProductV2,
|
||||
newProductV2: productV2,
|
||||
curProductV1: product,
|
||||
features,
|
||||
});
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import {
|
||||
type CreatePlanParams,
|
||||
CreatePlanParamsSchema,
|
||||
planToProductV2,
|
||||
ProductNotFoundError,
|
||||
type ProductV2,
|
||||
productsAreSame,
|
||||
} from "@autumn/shared";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler";
|
||||
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
|
||||
import { ProductService } from "@/internal/products/ProductService";
|
||||
|
||||
/**
|
||||
* V3 endpoint that accepts CreatePlanParams format (what the CLI sends)
|
||||
* instead of requiring full ApiPlan format with server-side metadata.
|
||||
*
|
||||
* This allows the CLI to send just the plan configuration fields
|
||||
* without needing to know about version, created_at, env, etc.
|
||||
*/
|
||||
export const handlePlanHasCustomersV3 = createRoute({
|
||||
body: CreatePlanParamsSchema,
|
||||
handler: async (c) => {
|
||||
const { product_id } = c.req.param();
|
||||
const ctx = c.get("ctx");
|
||||
const { db, features, org, env } = ctx;
|
||||
|
||||
const body = c.req.valid("json") as CreatePlanParams;
|
||||
|
||||
const product = await ProductService.getFull({
|
||||
db,
|
||||
idOrInternalId: product_id,
|
||||
orgId: org.id,
|
||||
env: env,
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
throw new ProductNotFoundError({ productId: product_id });
|
||||
}
|
||||
|
||||
const cusProductsCurVersion =
|
||||
await CusProductService.getByInternalProductId({
|
||||
db,
|
||||
internalProductId: product.internal_id,
|
||||
});
|
||||
|
||||
// Convert plan params to ProductV2 format for comparison
|
||||
// Cast is safe because productsAreSame only uses the items and free_trial fields
|
||||
const productV2 = planToProductV2({ plan: body, features }) as ProductV2;
|
||||
|
||||
const { itemsSame, freeTrialsSame } = productsAreSame({
|
||||
newProductV2: productV2,
|
||||
curProductV1: product,
|
||||
features,
|
||||
});
|
||||
|
||||
const productSame = itemsSame && freeTrialsSame;
|
||||
|
||||
return c.json({
|
||||
current_version: product.version,
|
||||
will_version: !productSame && cusProductsCurVersion.length > 0,
|
||||
archived: product.archived,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import express from "express";
|
||||
import { Hono } from "hono";
|
||||
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
|
||||
import { handlePlanHasCustomersV2 } from "@/internal/products/handlers/handlePlanHasCustomersV2.js";
|
||||
import { handlePlanHasCustomersV3 } from "@/internal/products/handlers/handlePlanHasCustomersV3.js";
|
||||
import { handleCopyProductV2 } from "./handlers/handleCopyProduct/handleCopyProductV2.js";
|
||||
import { handleCreatePlan } from "./handlers/handleCreatePlan.js";
|
||||
import { handleDeleteProduct as handleDeleteProductHono } from "./handlers/handleDeleteProduct.js";
|
||||
@@ -40,10 +39,6 @@ honoProductRouter.post("/:product_id/copy", ...handleCopyProductV2);
|
||||
honoProductRouter.get("/:product_id/has_customers", ...handlePlanHasCustomersV2);
|
||||
honoProductRouter.post(
|
||||
"/:product_id/has_customers",
|
||||
...handlePlanHasCustomersV2, // V2 for dashboard (expects ProductV2 format)
|
||||
);
|
||||
honoProductRouter.post(
|
||||
"/:product_id/has_customers_v3",
|
||||
...handlePlanHasCustomersV3, // V3 for CLI (accepts CreatePlanParams format)
|
||||
...handlePlanHasCustomersV2,
|
||||
);
|
||||
honoProductRouter.get("/:product_id/deletion_info", ...handleGetPlanDeleteInfo);
|
||||
|
||||
@@ -12,8 +12,8 @@ import type { HonoEnv } from "../honoUtils/HonoEnv";
|
||||
import { honoAdminRouter } from "../internal/admin/adminRouter";
|
||||
import { internalAnalyticsRouter } from "../internal/analytics/internalAnalyticsRouter";
|
||||
import { internalCusRouter } from "../internal/customers/internalCusRouter";
|
||||
import { consentRouter } from "../internal/dev/consent/consentRouter";
|
||||
import { internalDevRouter } from "../internal/dev/devRouter";
|
||||
import { consentRouter } from "../internal/misc/consent/consentRouter";
|
||||
import { feedbackRouter } from "../internal/misc/feedback/feedbackRouter";
|
||||
import { pricingAgentRouter } from "../internal/misc/pricingAgent/pricingAgentRouter";
|
||||
import { savedViewsRouter } from "../internal/misc/savedViews/savedViewsRouter";
|
||||
|
||||
@@ -21,7 +21,7 @@ import sendOTPEmail from "@/internal/emails/sendOTPEmail.js";
|
||||
import { afterOrgCreated } from "./authUtils/afterOrgCreated.js";
|
||||
import { beforeSessionCreated } from "./authUtils/beforeSessionCreated.js";
|
||||
import { ADMIN_USER_IDs } from "./constants.js";
|
||||
import { ALL_SCOPES } from "./scopeDefinitions.js";
|
||||
import { ALL_SCOPES } from "@autumn/shared";
|
||||
|
||||
export const auth = betterAuth({
|
||||
baseURL: process.env.BETTER_AUTH_URL,
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
/**
|
||||
* OAuth 2.1 Scope Definitions for Autumn
|
||||
*
|
||||
* Scopes follow the format: resource:action
|
||||
* - Resources: organisation, customers, features, plans, apiKeys
|
||||
* - Actions: create, read, update, delete, list
|
||||
*/
|
||||
|
||||
export type ResourceType =
|
||||
| "organisation"
|
||||
| "customers"
|
||||
| "features"
|
||||
| "plans"
|
||||
| "apiKeys";
|
||||
|
||||
export type ActionType = "create" | "read" | "update" | "delete" | "list";
|
||||
|
||||
export type ScopeString = `${ResourceType}:${ActionType}`;
|
||||
|
||||
/**
|
||||
* Standard OpenID Connect scopes (for compatibility)
|
||||
*/
|
||||
export const OPENID_SCOPES = [
|
||||
"openid",
|
||||
"profile",
|
||||
"email",
|
||||
"offline_access"
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Legacy scopes (for backward compatibility with existing clients)
|
||||
* @deprecated Use resource:action format instead (e.g., apiKeys:read)
|
||||
*/
|
||||
export const LEGACY_SCOPES = [
|
||||
"apiKeys", // Old format, maps to all apiKeys:* permissions
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Custom resource-based scopes
|
||||
*/
|
||||
export const RESOURCE_SCOPES: ScopeString[] = [
|
||||
// Organisation (no list - user is already scoped to an org)
|
||||
"organisation:create",
|
||||
"organisation:read",
|
||||
"organisation:update",
|
||||
"organisation:delete",
|
||||
|
||||
// Customers
|
||||
"customers:create",
|
||||
"customers:read",
|
||||
"customers:update",
|
||||
"customers:delete",
|
||||
"customers:list",
|
||||
|
||||
// Features
|
||||
"features:create",
|
||||
"features:read",
|
||||
"features:update",
|
||||
"features:delete",
|
||||
"features:list",
|
||||
|
||||
// Plans
|
||||
"plans:create",
|
||||
"plans:read",
|
||||
"plans:update",
|
||||
"plans:delete",
|
||||
"plans:list",
|
||||
|
||||
// API Keys
|
||||
"apiKeys:create",
|
||||
"apiKeys:read",
|
||||
"apiKeys:update",
|
||||
"apiKeys:delete",
|
||||
"apiKeys:list",
|
||||
];
|
||||
|
||||
/**
|
||||
* All valid scopes in the system (OpenID + Legacy + Resource scopes)
|
||||
*/
|
||||
export const ALL_SCOPES = [...OPENID_SCOPES, ...LEGACY_SCOPES, ...RESOURCE_SCOPES];
|
||||
|
||||
/**
|
||||
* Resource metadata for display purposes
|
||||
*/
|
||||
export const RESOURCE_METADATA: Record<
|
||||
ResourceType,
|
||||
{
|
||||
name: string;
|
||||
namePlural: string;
|
||||
description: string;
|
||||
}
|
||||
> = {
|
||||
organisation: {
|
||||
name: "Organisation",
|
||||
namePlural: "Organisations",
|
||||
description: "Your organization settings and information",
|
||||
},
|
||||
customers: {
|
||||
name: "Customer",
|
||||
namePlural: "Customers",
|
||||
description: "Your customer data and records",
|
||||
},
|
||||
features: {
|
||||
name: "Feature",
|
||||
namePlural: "Features",
|
||||
description: "Product features and configurations",
|
||||
},
|
||||
plans: {
|
||||
name: "Plan",
|
||||
namePlural: "Plans",
|
||||
description: "Pricing plans and subscriptions",
|
||||
},
|
||||
apiKeys: {
|
||||
name: "API Key",
|
||||
namePlural: "API Keys",
|
||||
description: "API keys for authentication",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Action metadata for display purposes
|
||||
*/
|
||||
export const ACTION_METADATA: Record<
|
||||
ActionType,
|
||||
{
|
||||
verb: string;
|
||||
description: string;
|
||||
}
|
||||
> = {
|
||||
create: {
|
||||
verb: "Create",
|
||||
description: "Create new records",
|
||||
},
|
||||
read: {
|
||||
verb: "Read",
|
||||
description: "View existing records",
|
||||
},
|
||||
update: {
|
||||
verb: "Update",
|
||||
description: "Modify existing records",
|
||||
},
|
||||
delete: {
|
||||
verb: "Delete",
|
||||
description: "Remove records",
|
||||
},
|
||||
list: {
|
||||
verb: "List",
|
||||
description: "List and search records",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a scope string into resource and action
|
||||
*/
|
||||
export function parseScope(scope: string): {
|
||||
resource: ResourceType | null;
|
||||
action: ActionType | null;
|
||||
} {
|
||||
const parts = scope.split(":");
|
||||
if (parts.length !== 2) {
|
||||
return { resource: null, action: null };
|
||||
}
|
||||
|
||||
const [resource, action] = parts;
|
||||
return {
|
||||
resource: resource as ResourceType,
|
||||
action: action as ActionType,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Group scopes by resource
|
||||
*/
|
||||
export function groupScopesByResource(scopes: string[]): Map<
|
||||
ResourceType,
|
||||
ActionType[]
|
||||
> {
|
||||
const grouped = new Map<ResourceType, ActionType[]>();
|
||||
|
||||
for (const scope of scopes) {
|
||||
const { resource, action } = parseScope(scope);
|
||||
if (!resource || !action) continue;
|
||||
|
||||
const existingActions = grouped.get(resource) || [];
|
||||
if (!existingActions.includes(action)) {
|
||||
existingActions.push(action);
|
||||
}
|
||||
grouped.set(resource, existingActions);
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format actions into a human-readable string
|
||||
* Examples:
|
||||
* - ["read"] -> "Read"
|
||||
* - ["read", "update"] -> "Read and update"
|
||||
* - ["read", "update", "delete"] -> "Read, update, and delete"
|
||||
*/
|
||||
export function formatActions(actions: ActionType[]): string {
|
||||
if (actions.length === 0) return "";
|
||||
if (actions.length === 1) return ACTION_METADATA[actions[0]].verb;
|
||||
|
||||
const verbs = actions.map((action) => ACTION_METADATA[action].verb.toLowerCase());
|
||||
|
||||
if (actions.length === 2) {
|
||||
return `${verbs[0]} and ${verbs[1]}`;
|
||||
}
|
||||
|
||||
const lastVerb = verbs.pop();
|
||||
return `${verbs.join(", ")}, and ${lastVerb}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a resource with actions into a human-readable description
|
||||
* Examples:
|
||||
* - (customers, ["read"]) -> "Read customers"
|
||||
* - (plans, ["read", "update"]) -> "Read and update plans"
|
||||
* - (apiKeys, ["create", "read", "delete"]) -> "Create, read, and delete API keys"
|
||||
*/
|
||||
export function formatResourcePermission(
|
||||
resource: ResourceType,
|
||||
actions: ActionType[]
|
||||
): string {
|
||||
const actionString = formatActions(actions);
|
||||
const resourceName = RESOURCE_METADATA[resource].namePlural.toLowerCase();
|
||||
|
||||
// Capitalize first letter
|
||||
return actionString.charAt(0).toUpperCase() + actionString.slice(1) + " " + resourceName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a scope is valid
|
||||
*/
|
||||
export function isValidScope(scope: string): scope is ScopeString {
|
||||
return ALL_SCOPES.includes(scope as ScopeString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an array of scopes
|
||||
*/
|
||||
export function validateScopes(scopes: string[]): {
|
||||
valid: ScopeString[];
|
||||
invalid: string[];
|
||||
} {
|
||||
const valid: ScopeString[] = [];
|
||||
const invalid: string[] = [];
|
||||
|
||||
for (const scope of scopes) {
|
||||
if (isValidScope(scope)) {
|
||||
valid.push(scope);
|
||||
} else {
|
||||
invalid.push(scope);
|
||||
}
|
||||
}
|
||||
|
||||
return { valid, invalid };
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { AttachScenario } from "@models/checkModels/checkPreviewModels.js";
|
||||
import { AppEnv } from "@models/genModels/genEnums.js";
|
||||
import { FreeTrialDuration } from "@models/productModels/freeTrialModels/freeTrialEnums.js";
|
||||
import { BillingInterval } from "@models/productModels/intervals/billingInterval.js";
|
||||
import { ResetInterval } from "@models/productModels/intervals/resetInterval.js";
|
||||
import { z } from "zod/v4";
|
||||
import { DisplaySchema } from "./components/display.js";
|
||||
import { ApiPlanFeatureSchema } from "./planFeature/apiPlanFeature.js";
|
||||
@@ -29,7 +28,7 @@ export const ApiPlanSchema = z.object({
|
||||
price: z
|
||||
.object({
|
||||
amount: z.number(),
|
||||
interval: z.enum(BillingInterval).or(z.enum(ResetInterval)),
|
||||
interval: z.enum(BillingInterval),
|
||||
interval_count: z.number().optional(),
|
||||
display: DisplaySchema.optional(),
|
||||
})
|
||||
|
||||
@@ -210,3 +210,4 @@ export * from "./utils/productV2Utils/productItemUtils/getProductItemRes.js";
|
||||
export * from "./utils/productV2Utils/productItemUtils/itemIntervalUtils.js";
|
||||
export * from "./utils/productV3Utils/productItemUtils/productV3ItemUtils.js";
|
||||
export * from "./utils/rewardUtils/rewardMigrationUtils";
|
||||
export * from "./utils/scopeDefinitions.js";
|
||||
|
||||
@@ -1,21 +1,87 @@
|
||||
/**
|
||||
* OAuth 2.1 Scope Definitions for Autumn (Frontend)
|
||||
*
|
||||
* OAuth 2.1 Scope Definitions for Autumn
|
||||
*
|
||||
* Scopes follow the format: resource:action
|
||||
* - Resources: organisation, customers, features, plans, apiKeys
|
||||
* - Actions: create, read, update, delete, list
|
||||
*/
|
||||
|
||||
export type ResourceType =
|
||||
export type ResourceType =
|
||||
| "organisation"
|
||||
| "customers"
|
||||
| "features"
|
||||
| "plans"
|
||||
| "apiKeys";
|
||||
|
||||
export type ActionType = "create" | "read" | "update" | "delete" | "list";
|
||||
export type ScopeActionType = "create" | "read" | "update" | "delete" | "list";
|
||||
|
||||
export type ScopeString = `${ResourceType}:${ActionType}`;
|
||||
export type ScopeString = `${ResourceType}:${ScopeActionType}`;
|
||||
|
||||
/**
|
||||
* Standard OpenID Connect scopes (for compatibility)
|
||||
*/
|
||||
export const OPENID_SCOPES = [
|
||||
"openid",
|
||||
"profile",
|
||||
"email",
|
||||
"offline_access",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Legacy scopes (for backward compatibility with existing clients)
|
||||
* @deprecated Use resource:action format instead (e.g., apiKeys:read)
|
||||
*/
|
||||
export const LEGACY_SCOPES = [
|
||||
"apiKeys", // Old format, maps to all apiKeys:* permissions
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Custom resource-based scopes
|
||||
*/
|
||||
export const RESOURCE_SCOPES: ScopeString[] = [
|
||||
// Organisation (no list - user is already scoped to an org)
|
||||
"organisation:create",
|
||||
"organisation:read",
|
||||
"organisation:update",
|
||||
"organisation:delete",
|
||||
|
||||
// Customers
|
||||
"customers:create",
|
||||
"customers:read",
|
||||
"customers:update",
|
||||
"customers:delete",
|
||||
"customers:list",
|
||||
|
||||
// Features
|
||||
"features:create",
|
||||
"features:read",
|
||||
"features:update",
|
||||
"features:delete",
|
||||
"features:list",
|
||||
|
||||
// Plans
|
||||
"plans:create",
|
||||
"plans:read",
|
||||
"plans:update",
|
||||
"plans:delete",
|
||||
"plans:list",
|
||||
|
||||
// API Keys
|
||||
"apiKeys:create",
|
||||
"apiKeys:read",
|
||||
"apiKeys:update",
|
||||
"apiKeys:delete",
|
||||
"apiKeys:list",
|
||||
];
|
||||
|
||||
/**
|
||||
* All valid scopes in the system (OpenID + Legacy + Resource scopes)
|
||||
*/
|
||||
export const ALL_SCOPES = [
|
||||
...OPENID_SCOPES,
|
||||
...LEGACY_SCOPES,
|
||||
...RESOURCE_SCOPES,
|
||||
];
|
||||
|
||||
/**
|
||||
* Resource metadata for display purposes
|
||||
@@ -59,7 +125,7 @@ export const RESOURCE_METADATA: Record<
|
||||
* Action metadata for display purposes
|
||||
*/
|
||||
export const ACTION_METADATA: Record<
|
||||
ActionType,
|
||||
ScopeActionType,
|
||||
{
|
||||
verb: string;
|
||||
description: string;
|
||||
@@ -93,16 +159,11 @@ export const ACTION_METADATA: Record<
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Standard OpenID Connect scopes
|
||||
*/
|
||||
const OPENID_SCOPES = ["openid", "profile", "email", "offline_access"];
|
||||
|
||||
/**
|
||||
* Check if a scope is an OpenID scope (not a resource scope)
|
||||
*/
|
||||
export function isOpenIdScope(scope: string): boolean {
|
||||
return OPENID_SCOPES.includes(scope);
|
||||
return (OPENID_SCOPES as readonly string[]).includes(scope);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,7 +171,7 @@ export function isOpenIdScope(scope: string): boolean {
|
||||
*/
|
||||
export function parseScope(scope: string): {
|
||||
resource: ResourceType | null;
|
||||
action: ActionType | null;
|
||||
action: ScopeActionType | null;
|
||||
} {
|
||||
const parts = scope.split(":");
|
||||
if (parts.length !== 2) {
|
||||
@@ -120,18 +181,17 @@ export function parseScope(scope: string): {
|
||||
const [resource, action] = parts;
|
||||
return {
|
||||
resource: resource as ResourceType,
|
||||
action: action as ActionType,
|
||||
action: action as ScopeActionType,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Group scopes by resource (filters out OpenID scopes)
|
||||
*/
|
||||
export function groupScopesByResource(scopes: string[]): Map<
|
||||
ResourceType,
|
||||
ActionType[]
|
||||
> {
|
||||
const grouped = new Map<ResourceType, ActionType[]>();
|
||||
export function groupScopesByResource(
|
||||
scopes: string[]
|
||||
): Map<ResourceType, ScopeActionType[]> {
|
||||
const grouped = new Map<ResourceType, ScopeActionType[]>();
|
||||
|
||||
for (const scope of scopes) {
|
||||
// Skip OpenID scopes - they're not resource-based
|
||||
@@ -164,7 +224,7 @@ export function groupScopesByResource(scopes: string[]): Map<
|
||||
* - ["create", "read", "update"] -> "Create, read, and update"
|
||||
* - ["create", "read", "update", "delete"] -> "Create, read, update, and delete"
|
||||
*/
|
||||
export function formatActions(actions: ActionType[]): string {
|
||||
export function formatActions(actions: ScopeActionType[]): string {
|
||||
if (actions.length === 0) return "";
|
||||
if (actions.length === 1) return ACTION_METADATA[actions[0]].verb;
|
||||
|
||||
@@ -173,7 +233,9 @@ export function formatActions(actions: ActionType[]): string {
|
||||
(a, b) => ACTION_METADATA[a].order - ACTION_METADATA[b].order
|
||||
);
|
||||
|
||||
const verbs = sortedActions.map((action) => ACTION_METADATA[action].verb.toLowerCase());
|
||||
const verbs = sortedActions.map((action) =>
|
||||
ACTION_METADATA[action].verb.toLowerCase()
|
||||
);
|
||||
|
||||
if (actions.length === 2) {
|
||||
return `${verbs[0]} and ${verbs[1]}`;
|
||||
@@ -192,13 +254,15 @@ export function formatActions(actions: ActionType[]): string {
|
||||
*/
|
||||
export function formatResourcePermission(
|
||||
resource: ResourceType,
|
||||
actions: ActionType[]
|
||||
actions: ScopeActionType[]
|
||||
): string {
|
||||
const actionString = formatActions(actions);
|
||||
const resourceName = RESOURCE_METADATA[resource].namePlural.toLowerCase();
|
||||
|
||||
// Capitalize first letter
|
||||
return actionString.charAt(0).toUpperCase() + actionString.slice(1) + " " + resourceName;
|
||||
return (
|
||||
actionString.charAt(0).toUpperCase() + actionString.slice(1) + " " + resourceName
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -208,6 +272,34 @@ export function getResourceDescription(resource: ResourceType): string {
|
||||
return RESOURCE_METADATA[resource].description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a scope is valid
|
||||
*/
|
||||
export function isValidScope(scope: string): scope is ScopeString {
|
||||
return ALL_SCOPES.includes(scope as ScopeString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an array of scopes
|
||||
*/
|
||||
export function validateScopes(scopes: string[]): {
|
||||
valid: ScopeString[];
|
||||
invalid: string[];
|
||||
} {
|
||||
const valid: ScopeString[] = [];
|
||||
const invalid: string[] = [];
|
||||
|
||||
for (const scope of scopes) {
|
||||
if (isValidScope(scope)) {
|
||||
valid.push(scope);
|
||||
} else {
|
||||
invalid.push(scope);
|
||||
}
|
||||
}
|
||||
|
||||
return { valid, invalid };
|
||||
}
|
||||
|
||||
/**
|
||||
* Group and format scopes for display
|
||||
* Returns an array of objects with resource name and formatted permission string
|
||||
@@ -215,7 +307,7 @@ export function getResourceDescription(resource: ResourceType): string {
|
||||
export interface GroupedPermission {
|
||||
resource: ResourceType;
|
||||
resourceName: string;
|
||||
actions: ActionType[];
|
||||
actions: ScopeActionType[];
|
||||
formattedPermission: string;
|
||||
description: string;
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
useSession,
|
||||
} from "@/lib/auth-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { groupAndFormatScopes, type GroupedPermission } from "@/utils/scopeDefinitions";
|
||||
import { groupAndFormatScopes, type GroupedPermission } from "@autumn/shared";
|
||||
|
||||
interface ClientInfo {
|
||||
client_id: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ApiKey } from "@autumn/shared";
|
||||
import type { ColumnDef, Row } from "@tanstack/react-table";
|
||||
import { CalendarIcon, ShieldCheckIcon, TerminalIcon, UserIcon } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/v2/tooltips/Tooltip";
|
||||
import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils";
|
||||
import { APIKeyToolbar } from "./APIKeyToolbar";
|
||||
|
||||
@@ -32,7 +33,16 @@ export const createAPIKeyTableColumns = (): ColumnDef<ApiKey, unknown>[] => [
|
||||
header: "Name",
|
||||
accessorKey: "name",
|
||||
cell: ({ row }: { row: Row<ApiKey> }) => {
|
||||
return <div className="font-medium text-t1">{row.original.name}</div>;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="font-medium text-t1 truncate max-w-[150px]">
|
||||
{row.original.name}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{row.original.name}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/v2/dialogs/Dialog";
|
||||
import { getBackendErr } from "@/utils/genUtils";
|
||||
import { groupAndFormatScopes } from "@/utils/scopeDefinitions";
|
||||
import { groupAndFormatScopes } from "@autumn/shared";
|
||||
|
||||
interface OAuthConsent {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user