mirror of
https://github.com/wassname/talk.git
synced 2026-07-29 11:28:24 +08:00
[CORL-687] Webhooks (#2738)
* feat: initial webhook impl * feat: added support for key rotation * feat: harmonized fetcher * feat: added expired secrets cleaning * feat: event system refactor * feat: added story event * feat: simplfiied webhook handler * feat: added ref's to locations where user events can be added * feat: added UI to support webhooks * fix: renaming some Webhook -> WebhookEndpoint * fix: review comments to adjuist flow * feat: added localizations * fix: linting, updated snapshots * fix: adapted for new fluent * fix: rearranged folders * fix: linting * feat: added webhooks documentation * feat: improved toc generation * feat: added some tests to webhooks * fix: chain transition hooks * feat: added tests around webhook ui * fix: renamed events * fix: adjusted circle markdown linting * fix: adjusted doctoc script call * review: review fixes * review: review comments * review: adjusted signing secret confirmation * review: adjusted styles to harmonize button usage * fix: updated snapshots and tests * review: move form out of webhooks Moved the form out of the webhooks by relocating the layout used for the route associated with the configure routes. * fix: fixed bugs and snapshots with tests * feat: revised slack message format to use block api * fix: fixed a small text bug Co-authored-by: Vinh <vinh@vinh.tech> Co-authored-by: Kim Gardner <kgardnr@gmail.com>
This commit is contained in:
co-authored by
Vinh
Kim Gardner
parent
34ba2da88d
commit
e42c2b925d
@@ -0,0 +1,2 @@
|
||||
export * from "./settings";
|
||||
export * from "./secret";
|
||||
@@ -0,0 +1,44 @@
|
||||
export interface Secret {
|
||||
/**
|
||||
* kid is the identifier for the key used when verifying tokens issued by the
|
||||
* provider.
|
||||
*/
|
||||
kid: string;
|
||||
|
||||
/**
|
||||
* secret is the actual underlying secret used to verify the tokens with.
|
||||
*/
|
||||
secret: string;
|
||||
|
||||
/**
|
||||
* createdAt is the date that the key was created at.
|
||||
*/
|
||||
createdAt: Date;
|
||||
|
||||
/**
|
||||
* rotatedAt is the time that the token was rotated out.
|
||||
*/
|
||||
rotatedAt?: Date;
|
||||
|
||||
/**
|
||||
* inactiveAt is the date that the token can no longer be used to validate
|
||||
* tokens.
|
||||
*/
|
||||
inactiveAt?: Date;
|
||||
}
|
||||
|
||||
export function isSecretExpired({ inactiveAt }: Secret, now = new Date()) {
|
||||
if (inactiveAt && inactiveAt <= now) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function filterExpiredSecrets(now = new Date()) {
|
||||
return (secret: Secret) => isSecretExpired(secret, now);
|
||||
}
|
||||
|
||||
export function filterActiveSecrets(now = new Date()) {
|
||||
return (secret: Secret) => !isSecretExpired(secret, now);
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
GQLSettings,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import { Secret } from "./secret";
|
||||
|
||||
export type LiveConfiguration = Omit<GQLLiveConfiguration, "configurable">;
|
||||
|
||||
export type EmailConfiguration = GQLEmailConfiguration;
|
||||
@@ -38,40 +40,11 @@ export type FacebookAuthIntegration = Omit<
|
||||
"callbackURL" | "redirectURL"
|
||||
>;
|
||||
|
||||
export interface SSOKey {
|
||||
/**
|
||||
* kid is the identifier for the key used when verifying tokens issued by the
|
||||
* provider.
|
||||
*/
|
||||
kid: string;
|
||||
|
||||
/**
|
||||
* secret is the actual underlying secret used to verify the tokens with.
|
||||
*/
|
||||
secret: string;
|
||||
|
||||
/**
|
||||
* createdAt is the date that the key was created at.
|
||||
*/
|
||||
createdAt: Date;
|
||||
|
||||
/**
|
||||
* rotatedAt is the time that the token was rotated out.
|
||||
*/
|
||||
rotatedAt?: Date;
|
||||
|
||||
/**
|
||||
* inactiveAt is the date that the token can no longer be used to validate
|
||||
* tokens.
|
||||
*/
|
||||
inactiveAt?: Date;
|
||||
}
|
||||
|
||||
export interface SSOAuthIntegration {
|
||||
enabled: boolean;
|
||||
allowRegistration: boolean;
|
||||
targetFilter: GQLAuthenticationTargetFilter;
|
||||
keys: SSOKey[];
|
||||
keys: Secret[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,24 +93,27 @@ export interface UpsertStoryInput {
|
||||
siteID: string;
|
||||
}
|
||||
|
||||
export interface UpsertStoryResult {
|
||||
story: Story;
|
||||
wasUpserted: boolean;
|
||||
}
|
||||
|
||||
export async function upsertStory(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
{ id = uuid.v4(), url, siteID }: UpsertStoryInput,
|
||||
now = new Date()
|
||||
) {
|
||||
): Promise<UpsertStoryResult> {
|
||||
// Create the story, optionally sourcing the id from the input, additionally
|
||||
// porting in the tenantID.
|
||||
const update: { $setOnInsert: Story } = {
|
||||
$setOnInsert: {
|
||||
id,
|
||||
url,
|
||||
siteID,
|
||||
tenantID,
|
||||
createdAt: now,
|
||||
commentCounts: createEmptyRelatedCommentCounts(),
|
||||
settings: {},
|
||||
},
|
||||
const story: Story = {
|
||||
id,
|
||||
url,
|
||||
tenantID,
|
||||
siteID,
|
||||
createdAt: now,
|
||||
commentCounts: createEmptyRelatedCommentCounts(),
|
||||
settings: {},
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -121,18 +124,26 @@ export async function upsertStory(
|
||||
url,
|
||||
tenantID,
|
||||
},
|
||||
update,
|
||||
{ $setOnInsert: story },
|
||||
{
|
||||
// Create the object if it doesn't already exist.
|
||||
upsert: true,
|
||||
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
// True to return the original document instead of the updated document.
|
||||
// This will ensure that when an upsert operation adds a new Story, it
|
||||
// should return null.
|
||||
returnOriginal: true,
|
||||
}
|
||||
);
|
||||
|
||||
return result.value || null;
|
||||
return {
|
||||
// The story will either be found (via `result.value`) or upserted (via
|
||||
// `story`).
|
||||
story: result.value || story,
|
||||
|
||||
// The story was upserted if the value isn't provided.
|
||||
wasUpserted: !result.value,
|
||||
};
|
||||
} catch (err) {
|
||||
// Evaluate the error, if it is in regards to violating the unique index,
|
||||
// then return a duplicate Story error.
|
||||
@@ -172,13 +183,18 @@ export interface FindOrCreateStoryInput {
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface FindOrCreateStoryResult {
|
||||
story: Story | null;
|
||||
wasUpserted: boolean;
|
||||
}
|
||||
|
||||
export async function findOrCreateStory(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
{ id, url }: FindOrCreateStoryInput,
|
||||
siteID: string | null,
|
||||
now = new Date()
|
||||
) {
|
||||
): Promise<FindOrCreateStoryResult> {
|
||||
if (id) {
|
||||
if (url && siteID) {
|
||||
// The URL was specified, this is an upsert operation.
|
||||
@@ -194,8 +210,14 @@ export async function findOrCreateStory(
|
||||
);
|
||||
}
|
||||
|
||||
// The URL and siteID were not specified, this is a lookup operation.
|
||||
return retrieveStory(mongo, tenantID, id);
|
||||
// The URL was not specified, this is a lookup operation.
|
||||
const story = await retrieveStory(mongo, tenantID, id);
|
||||
|
||||
// Return the result object.
|
||||
return {
|
||||
story,
|
||||
wasUpserted: false,
|
||||
};
|
||||
}
|
||||
|
||||
// The ID was not specified, this is an upsert operation. Check to see that
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
GQLStaffConfiguration,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import { SSOKey } from "../settings";
|
||||
import { Secret } from "../settings";
|
||||
import { Tenant } from "./tenant";
|
||||
|
||||
export const getDefaultReactionConfiguration = (
|
||||
@@ -39,12 +39,12 @@ export function generateRandomString(size: number, drift = 5) {
|
||||
.toString("hex");
|
||||
}
|
||||
|
||||
export function generateSSOKey(createdAt: Date): SSOKey {
|
||||
export function generateSecret(prefix: string, createdAt: Date): Secret {
|
||||
// Generate a new key. We generate a key of minimum length 32 up to 37 bytes,
|
||||
// as 16 was the minimum length recommended.
|
||||
//
|
||||
// Reference: https://security.stackexchange.com/a/96176
|
||||
const secret = generateRandomString(32, 5);
|
||||
const secret = prefix + "_" + generateRandomString(32, 5);
|
||||
const kid = generateRandomString(8, 3);
|
||||
|
||||
return { kid, secret, createdAt };
|
||||
@@ -67,3 +67,10 @@ export function hasFeatureFlag(
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getWebhookEndpoint(
|
||||
tenant: Pick<Tenant, "webhooks">,
|
||||
endpointID: string
|
||||
) {
|
||||
return tenant.webhooks.endpoints.find(e => e.id === endpointID) || null;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ import TIME from "coral-common/time";
|
||||
import { DeepPartial, Omit, Sub } from "coral-common/types";
|
||||
import { isBeforeDate } from "coral-common/utils";
|
||||
import { dotize } from "coral-common/utils/dotize";
|
||||
import { Settings } from "coral-server/models/settings";
|
||||
import logger from "coral-server/logger";
|
||||
import { Secret, Settings } from "coral-server/models/settings";
|
||||
import { I18n } from "coral-server/services/i18n";
|
||||
import { tenants as collection } from "coral-server/services/mongodb/collections";
|
||||
|
||||
@@ -18,12 +19,14 @@ import {
|
||||
GQLFEATURE_FLAG,
|
||||
GQLMODERATION_MODE,
|
||||
GQLSettings,
|
||||
GQLWEBHOOK_EVENT_NAME,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import {
|
||||
generateSSOKey,
|
||||
generateSecret,
|
||||
getDefaultReactionConfiguration,
|
||||
getDefaultStaffConfiguration,
|
||||
getWebhookEndpoint,
|
||||
} from "./helpers";
|
||||
|
||||
/**
|
||||
@@ -38,6 +41,49 @@ export interface TenantResource {
|
||||
readonly tenantID: string;
|
||||
}
|
||||
|
||||
export interface Endpoint {
|
||||
/**
|
||||
* id is the unique identifier for this specific endpoint.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* enabled when true will enable events to be sent to this endpoint.
|
||||
*/
|
||||
enabled: boolean;
|
||||
|
||||
/**
|
||||
* url is the URL that we will POST event data to.
|
||||
*/
|
||||
url: string;
|
||||
|
||||
/**
|
||||
* signingSecret is the secret used to sign the events sent out.
|
||||
*/
|
||||
signingSecrets: Secret[];
|
||||
|
||||
/**
|
||||
* all when true indicates that all events should trigger.
|
||||
*/
|
||||
all: boolean;
|
||||
|
||||
/**
|
||||
* events is the array of events that will trigger the delivery of an
|
||||
* event.
|
||||
*/
|
||||
events: GQLWEBHOOK_EVENT_NAME[];
|
||||
|
||||
/**
|
||||
* createdAt is the date that this endpoint was created.
|
||||
*/
|
||||
createdAt: Date;
|
||||
|
||||
/**
|
||||
* modifiedAt is the date that this Endpoint was last modified at.
|
||||
*/
|
||||
modifiedAt?: Date;
|
||||
}
|
||||
|
||||
export interface TenantSettings
|
||||
extends Pick<GQLSettings, "domain" | "organization"> {
|
||||
readonly id: string;
|
||||
@@ -51,6 +97,16 @@ export interface TenantSettings
|
||||
* featureFlags is the set of flags enabled on this Tenant.
|
||||
*/
|
||||
featureFlags?: GQLFEATURE_FLAG[];
|
||||
|
||||
/**
|
||||
* webhooks stores the configurations for this Tenant's webhook rules.
|
||||
*/
|
||||
webhooks: {
|
||||
/**
|
||||
* endpoints is all the configured endpoints that should receive events.
|
||||
*/
|
||||
endpoints: Endpoint[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,6 +168,9 @@ export async function createTenant(
|
||||
enabled: false,
|
||||
},
|
||||
editCommentWindowLength: 30 * TIME.SECOND,
|
||||
webhooks: {
|
||||
endpoints: [],
|
||||
},
|
||||
charCount: {
|
||||
enabled: false,
|
||||
},
|
||||
@@ -138,7 +197,7 @@ export async function createTenant(
|
||||
stream: true,
|
||||
},
|
||||
// TODO: [CORL-754] (wyattjoh) remove this in favor of generating this when needed
|
||||
keys: [generateSSOKey(now)],
|
||||
keys: [generateSecret("ssosec", now)],
|
||||
},
|
||||
oidc: {
|
||||
enabled: false,
|
||||
@@ -294,9 +353,11 @@ export async function updateTenant(
|
||||
{ id },
|
||||
// Only update fields that have been updated.
|
||||
{ $set },
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
{ returnOriginal: false }
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
|
||||
return result.value || null;
|
||||
@@ -309,7 +370,7 @@ export async function updateTenant(
|
||||
*/
|
||||
export async function createTenantSSOKey(mongo: Db, id: string, now: Date) {
|
||||
// Construct the new key.
|
||||
const key = generateSSOKey(now);
|
||||
const key = generateSecret("ssosec", now);
|
||||
|
||||
// Update the Tenant with this new key.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
@@ -466,3 +527,243 @@ export function retrieveAnnouncementIfEnabled(
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function rollTenantWebhookEndpointSecret(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
endpointID: string,
|
||||
inactiveAt: Date,
|
||||
now: Date
|
||||
) {
|
||||
// Create the new secret.
|
||||
const secret = generateSecret("whsec", now);
|
||||
|
||||
// Update the Tenant with this new secret.
|
||||
let result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{
|
||||
$push: { "webhooks.endpoints.$[endpoint].signingSecrets": secret },
|
||||
},
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
arrayFilters: [
|
||||
// Select the endpoint we're updating.
|
||||
{ "endpoint.id": endpointID },
|
||||
],
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Grab the endpoint we just modified.
|
||||
const endpoint = getWebhookEndpoint(result.value, endpointID);
|
||||
if (!endpoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the secrets we need to deactivate...
|
||||
const secretKIDsToDeprecate = endpoint.signingSecrets
|
||||
// By excluding the last one (the one we just pushed)...
|
||||
.splice(0, endpoint.signingSecrets.length - 1)
|
||||
// And only finding keys that have not been rotated yet.
|
||||
.filter(s => !s.rotatedAt)
|
||||
// And get their kid's.
|
||||
.map(s => s.kid);
|
||||
if (secretKIDsToDeprecate.length > 0) {
|
||||
logger.trace(
|
||||
{ kids: secretKIDsToDeprecate },
|
||||
"deprecating old signingSecrets"
|
||||
);
|
||||
|
||||
// Deactivate the old keys.
|
||||
result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{
|
||||
$set: {
|
||||
"webhooks.endpoints.$[endpoint].signingSecrets.$[signingSecret].inactiveAt": inactiveAt,
|
||||
"webhooks.endpoints.$[endpoint].signingSecrets.$[signingSecret].rotatedAt": now,
|
||||
},
|
||||
},
|
||||
{
|
||||
arrayFilters: [
|
||||
// Select the endpoint we're updating.
|
||||
{ "endpoint.id": endpointID },
|
||||
// Select any signing secrets with the given ids.
|
||||
{ "signingSecret.kid": { $in: secretKIDsToDeprecate } },
|
||||
],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export interface CreateTenantWebhookEndpointInput {
|
||||
url: string;
|
||||
all: boolean;
|
||||
events: GQLWEBHOOK_EVENT_NAME[];
|
||||
}
|
||||
|
||||
export async function createTenantWebhookEndpoint(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
input: CreateTenantWebhookEndpointInput,
|
||||
now: Date
|
||||
) {
|
||||
// Create the new endpoint.
|
||||
const endpoint: Endpoint = {
|
||||
...input,
|
||||
id: uuid(),
|
||||
enabled: true,
|
||||
signingSecrets: [generateSecret("whsec", now)],
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
// Update the Tenant with this new endpoint.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{ $push: { "webhooks.endpoints": endpoint } },
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
const tenant = await retrieveTenant(mongo, id);
|
||||
if (!tenant) {
|
||||
return {
|
||||
endpoint: null,
|
||||
tenant: null,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("update failed for an unexpected reason");
|
||||
}
|
||||
|
||||
return {
|
||||
endpoint,
|
||||
tenant: result.value,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UpdateTenantWebhookEndpointInput {
|
||||
enabled?: boolean;
|
||||
url?: string;
|
||||
all?: boolean;
|
||||
events?: GQLWEBHOOK_EVENT_NAME[];
|
||||
}
|
||||
|
||||
export async function updateTenantWebhookEndpoint(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
endpointID: string,
|
||||
update: UpdateTenantWebhookEndpointInput
|
||||
) {
|
||||
const $set = dotize(
|
||||
{ "webhooks.endpoints.$[endpoint]": update },
|
||||
{ embedArrays: true }
|
||||
);
|
||||
|
||||
// Check to see if there is any updates that will be made.
|
||||
if (isEmpty($set)) {
|
||||
// No updates need to be made, abort here and just return the tenant.
|
||||
return retrieveTenant(mongo, id);
|
||||
}
|
||||
|
||||
// Perform the actual update operation.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{ $set },
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
arrayFilters: [{ "endpoint.id": endpointID }],
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
const tenant = await retrieveTenant(mongo, id);
|
||||
if (!tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const endpoint = getWebhookEndpoint(tenant, endpointID);
|
||||
if (!endpoint) {
|
||||
throw new Error(
|
||||
`endpoint not found with id: ${endpointID} on tenant: ${id}`
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error("update failed for an unexpected reason");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export async function deleteEndpointSecrets(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
endpointID: string,
|
||||
kids: string[]
|
||||
) {
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{
|
||||
$pull: {
|
||||
"webhooks.endpoints.$[endpoint].signingSecrets": { kid: { $in: kids } },
|
||||
},
|
||||
},
|
||||
{ returnOriginal: false, arrayFilters: [{ "endpoint.id": endpointID }] }
|
||||
);
|
||||
if (!result.value) {
|
||||
const tenant = await retrieveTenant(mongo, id);
|
||||
if (!tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const endpoint = getWebhookEndpoint(tenant, endpointID);
|
||||
if (!endpoint) {
|
||||
throw new Error(
|
||||
`endpoint not found with id: ${endpointID} on tenant: ${id}`
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error("update failed for an unexpected reason");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export async function deleteTenantWebhookEndpoint(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
endpointID: string
|
||||
) {
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{
|
||||
$pull: {
|
||||
"webhooks.endpoints": { id: endpointID },
|
||||
},
|
||||
},
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
const tenant = await retrieveTenant(mongo, id);
|
||||
if (!tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw new Error("update failed for an unexpected reason");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@@ -576,7 +576,7 @@ export async function findOrCreateUser(
|
||||
const user = await findOrCreateUserInput(tenantID, input, now);
|
||||
|
||||
try {
|
||||
await collection(mongo).findOneAndUpdate(
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{
|
||||
tenantID,
|
||||
profiles: {
|
||||
@@ -588,12 +588,18 @@ export async function findOrCreateUser(
|
||||
},
|
||||
{ $setOnInsert: user },
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
// True to return the original document instead of the updated document.
|
||||
// This will ensure that when an upsert operation adds a new User, it
|
||||
// should return null.
|
||||
returnOriginal: true,
|
||||
upsert: true,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
user: result.value || user,
|
||||
wasUpserted: !result.value,
|
||||
};
|
||||
} catch (err) {
|
||||
// Evaluate the error, if it is in regards to violating the unique index,
|
||||
// then return a duplicate User error.
|
||||
@@ -607,8 +613,6 @@ export async function findOrCreateUser(
|
||||
|
||||
throw err;
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export type CreateUserInput = FindOrCreateUserInput;
|
||||
|
||||
Reference in New Issue
Block a user