feat(auth): added discord & github authentication methods (#18)
* feat(auth): added discord & github authentication methods * chore(format): formatted edited files * docs: add Discord and GitHub OAuth environment variables to README * feat: add Discord and GitHub OAuth client credentials to environment config * refactor: migrate social provider types to better-auth package * feat: return configured social provider names from auth endpoint * refactor: simplify socialProvidersPlugin by removing nested function and condensing return
This commit is contained in:
@@ -21,3 +21,8 @@ BETTER_AUTH_TRUSTED_ORIGINS=
|
|||||||
|
|
||||||
GOOGLE_CLIENT_ID=
|
GOOGLE_CLIENT_ID=
|
||||||
GOOGLE_CLIENT_SECRET=
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
DISCORD_CLIENT_ID=
|
||||||
|
DISCORD_CLIENT_SECRET=
|
||||||
|
GITHUB_CLIENT_ID=
|
||||||
|
GITHUB_CLIENT_SECRET=
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,10 @@ pnpm dev
|
|||||||
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | For Google login | `http://localhost:3000,http://localhost:3001` |
|
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | For Google login | `http://localhost:3000,http://localhost:3001` |
|
||||||
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
|
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
|
||||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
|
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
|
||||||
|
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
|
||||||
|
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
|
||||||
|
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
|
||||||
|
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
|
||||||
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
|
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
|
||||||
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
|
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
|
||||||
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
|
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { FaGoogle } from "react-icons/fa";
|
import { FaDiscord, FaGithub, FaGoogle } from "react-icons/fa";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import type { SocialProvider } from "better-auth/social-providers";
|
||||||
|
|
||||||
import { authClient } from "@kan/auth/client";
|
import { authClient } from "@kan/auth/client";
|
||||||
|
|
||||||
@@ -20,8 +22,9 @@ interface AuthProps {
|
|||||||
const EmailSchema = z.object({ email: z.string().email() });
|
const EmailSchema = z.object({ email: z.string().email() });
|
||||||
|
|
||||||
export function Auth({ setIsMagicLinkSent }: AuthProps) {
|
export function Auth({ setIsMagicLinkSent }: AuthProps) {
|
||||||
const [isLoginWithGooglePending, setIsLoginWithGooglePending] =
|
const [isLoginWithProviderPending, setIsLoginWithProviderPending] = useState<
|
||||||
useState(false);
|
null | SocialProvider
|
||||||
|
>(null);
|
||||||
const [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false);
|
const [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false);
|
||||||
const [loginError, setLoginError] = useState<string | null>(null);
|
const [loginError, setLoginError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -33,6 +36,11 @@ export function Auth({ setIsMagicLinkSent }: AuthProps) {
|
|||||||
resolver: zodResolver(EmailSchema),
|
resolver: zodResolver(EmailSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: socialProviders } = useQuery({
|
||||||
|
queryKey: ["social_providers"],
|
||||||
|
queryFn: () => authClient.getSocialProviders(),
|
||||||
|
});
|
||||||
|
|
||||||
const handleLoginWithEmail = async (email: string) => {
|
const handleLoginWithEmail = async (email: string) => {
|
||||||
setIsLoginWithEmailPending(true);
|
setIsLoginWithEmailPending(true);
|
||||||
setLoginError(null);
|
setLoginError(null);
|
||||||
@@ -52,18 +60,21 @@ export function Auth({ setIsMagicLinkSent }: AuthProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLoginWithGoogle = async () => {
|
const handleLoginWithProvider = async (
|
||||||
setIsLoginWithGooglePending(true);
|
provider: SocialProvider) => {
|
||||||
|
setIsLoginWithProviderPending(provider);
|
||||||
setLoginError(null);
|
setLoginError(null);
|
||||||
const { error } = await authClient.signIn.social({
|
const { error } = await authClient.signIn.social({
|
||||||
provider: "google",
|
provider,
|
||||||
callbackURL: "/boards",
|
callbackURL: "/boards",
|
||||||
});
|
});
|
||||||
|
|
||||||
setIsLoginWithGooglePending(false);
|
setIsLoginWithProviderPending(null);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
setLoginError("Failed to login with Google. Please try again.");
|
setLoginError(
|
||||||
|
`Failed to login with ${provider.at(0)?.toUpperCase() + provider.slice(1)}. Please try again.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -73,23 +84,53 @@ export function Auth({ setIsMagicLinkSent }: AuthProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
{socialProviders?.length !== 0 && (
|
||||||
<Button
|
<div className="space-y-2">
|
||||||
onClick={handleLoginWithGoogle}
|
{socialProviders?.includes("google") && (
|
||||||
isLoading={isLoginWithGooglePending}
|
<Button
|
||||||
iconLeft={<FaGoogle />}
|
onClick={() => handleLoginWithProvider("google")}
|
||||||
fullWidth
|
isLoading={isLoginWithProviderPending === "google"}
|
||||||
size="lg"
|
iconLeft={<FaGoogle />}
|
||||||
>
|
fullWidth
|
||||||
Continue with Google
|
size="lg"
|
||||||
</Button>
|
>
|
||||||
</div>
|
Continue with Google
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
</Button>
|
||||||
<div className="mb-[1.5rem] flex w-full items-center gap-4">
|
)}
|
||||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
{socialProviders?.includes("github") && (
|
||||||
<span className="text-sm text-light-900 dark:text-dark-900">or</span>
|
<Button
|
||||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
onClick={() => handleLoginWithProvider("github")}
|
||||||
|
isLoading={isLoginWithProviderPending === "github"}
|
||||||
|
iconLeft={<FaGithub />}
|
||||||
|
fullWidth
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
Continue with GitHub
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{socialProviders?.includes("discord") && (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleLoginWithProvider("discord")}
|
||||||
|
isLoading={isLoginWithProviderPending === "discord"}
|
||||||
|
iconLeft={<FaDiscord />}
|
||||||
|
fullWidth
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
Continue with Discord
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
{socialProviders?.length !== 0 && (
|
||||||
|
<div className="mb-[1.5rem] flex w-full items-center gap-4">
|
||||||
|
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||||
|
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||||
|
or
|
||||||
|
</span>
|
||||||
|
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Input
|
<Input
|
||||||
{...register("email", { required: true })}
|
{...register("email", { required: true })}
|
||||||
placeholder="Enter your email address"
|
placeholder="Enter your email address"
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ export const env = createEnv({
|
|||||||
STRIPE_SECRET_KEY: z.string().optional(),
|
STRIPE_SECRET_KEY: z.string().optional(),
|
||||||
GOOGLE_CLIENT_ID: z.string().optional(),
|
GOOGLE_CLIENT_ID: z.string().optional(),
|
||||||
GOOGLE_CLIENT_SECRET: z.string().optional(),
|
GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||||||
|
DISCORD_CLIENT_ID: z.string().optional(),
|
||||||
|
DISCORD_CLIENT_SECRET: z.string().optional(),
|
||||||
|
GITHUB_CLIENT_ID: z.string().optional(),
|
||||||
|
GITHUB_CLIENT_SECRET: z.string().optional(),
|
||||||
S3_ACCESS_KEY_ID: z.string().optional(),
|
S3_ACCESS_KEY_ID: z.string().optional(),
|
||||||
S3_SECRET_ACCESS_KEY: z.string().optional(),
|
S3_SECRET_ACCESS_KEY: z.string().optional(),
|
||||||
S3_REGION: z.string().optional(),
|
S3_REGION: z.string().optional(),
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ services:
|
|||||||
- POSTGRES_URL=${POSTGRES_URL}
|
- POSTGRES_URL=${POSTGRES_URL}
|
||||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
||||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
||||||
|
- DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
|
||||||
|
- DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET}
|
||||||
|
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID}
|
||||||
|
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET}
|
||||||
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}
|
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}
|
||||||
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
|
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
|
||||||
- S3_REGION=${S3_REGION}
|
- S3_REGION=${S3_REGION}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
import { createAuthMiddleware } from "better-auth/api";
|
import { createAuthEndpoint, createAuthMiddleware } from "better-auth/api";
|
||||||
import { apiKey } from "better-auth/plugins";
|
import { apiKey } from "better-auth/plugins";
|
||||||
import { magicLink } from "better-auth/plugins/magic-link";
|
import { magicLink } from "better-auth/plugins/magic-link";
|
||||||
import { env } from "next-runtime-env";
|
import { env } from "next-runtime-env";
|
||||||
@@ -11,6 +11,81 @@ import * as userRepo from "@kan/db/repository/user.repo";
|
|||||||
import * as schema from "@kan/db/schema";
|
import * as schema from "@kan/db/schema";
|
||||||
import { sendEmail } from "@kan/email";
|
import { sendEmail } from "@kan/email";
|
||||||
import { createStripeClient } from "@kan/stripe";
|
import { createStripeClient } from "@kan/stripe";
|
||||||
|
import { socialProviderList } from "better-auth/social-providers";
|
||||||
|
|
||||||
|
export const configuredProviders = socialProviderList.reduce<
|
||||||
|
Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
appBundleIdentifier?: string;
|
||||||
|
tenantId?: string;
|
||||||
|
requireSelectAccount?: boolean;
|
||||||
|
clientKey?: string;
|
||||||
|
issuer?: string;
|
||||||
|
}
|
||||||
|
>
|
||||||
|
>((acc, provider) => {
|
||||||
|
const id = process.env[`${provider.toUpperCase()}_CLIENT_ID`];
|
||||||
|
const secret = process.env[`${provider.toUpperCase()}_CLIENT_SECRET`];
|
||||||
|
if (id && id.length > 0 && secret && secret.length > 0) {
|
||||||
|
acc[provider] = { clientId: id, clientSecret: secret };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
provider === "apple" &&
|
||||||
|
Object.keys(acc).includes("apple") &&
|
||||||
|
acc[provider]
|
||||||
|
) {
|
||||||
|
const bundleId =
|
||||||
|
process.env[`${provider.toUpperCase()}_APP_BUNDLE_IDENTIFIER`];
|
||||||
|
if (bundleId && bundleId.length > 0) {
|
||||||
|
acc[provider].appBundleIdentifier = bundleId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
provider === "gitlab" &&
|
||||||
|
Object.keys(acc).includes("gitlab") &&
|
||||||
|
acc[provider]
|
||||||
|
) {
|
||||||
|
const issuer = process.env[`${provider.toUpperCase()}_ISSUER`];
|
||||||
|
if (issuer && issuer.length > 0) {
|
||||||
|
acc[provider].issuer = issuer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
provider === "microsoft" &&
|
||||||
|
Object.keys(acc).includes("microsoft") &&
|
||||||
|
acc[provider]
|
||||||
|
) {
|
||||||
|
acc[provider].tenantId = "common";
|
||||||
|
acc[provider].requireSelectAccount = true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
provider === "tiktok" &&
|
||||||
|
Object.keys(acc).includes("tiktok") &&
|
||||||
|
acc[provider]
|
||||||
|
) {
|
||||||
|
const key = process.env[`${provider.toUpperCase()}_CLIENT_KEY`];
|
||||||
|
if (key && key.length > 0) {
|
||||||
|
acc[provider].clientKey = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
export const socialProvidersPlugin = () => ({
|
||||||
|
id: "social-providers-plugin",
|
||||||
|
endpoints: {
|
||||||
|
getSocialProviders: createAuthEndpoint(
|
||||||
|
"/social-providers",
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
async (ctx) => ctx.json(ctx.context.socialProviders.map(p => p.name.toLowerCase())),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const initAuth = (db: dbClient) => {
|
export const initAuth = (db: dbClient) => {
|
||||||
return betterAuth({
|
return betterAuth({
|
||||||
@@ -26,12 +101,7 @@ export const initAuth = (db: dbClient) => {
|
|||||||
user: schema.users,
|
user: schema.users,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
socialProviders: {
|
socialProviders: configuredProviders,
|
||||||
google: {
|
|
||||||
clientId: process.env.GOOGLE_CLIENT_ID!,
|
|
||||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
user: {
|
user: {
|
||||||
additionalFields: {
|
additionalFields: {
|
||||||
stripeCustomerId: {
|
stripeCustomerId: {
|
||||||
@@ -43,6 +113,7 @@ export const initAuth = (db: dbClient) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
socialProvidersPlugin(),
|
||||||
// @todo: hasing is disabled due to a bug in the api key plugin
|
// @todo: hasing is disabled due to a bug in the api key plugin
|
||||||
apiKey({ disableKeyHashing: true }),
|
apiKey({ disableKeyHashing: true }),
|
||||||
magicLink({
|
magicLink({
|
||||||
|
|||||||
@@ -1,6 +1,25 @@
|
|||||||
|
import { BetterAuthClientPlugin } from "better-auth";
|
||||||
import { apiKeyClient, magicLinkClient } from "better-auth/client/plugins";
|
import { apiKeyClient, magicLinkClient } from "better-auth/client/plugins";
|
||||||
import { createAuthClient } from "better-auth/react";
|
import { BetterFetchOption, createAuthClient } from "better-auth/react";
|
||||||
|
|
||||||
|
import { socialProvidersPlugin } from "./auth";
|
||||||
|
|
||||||
|
const socialProvidersPluginClient = {
|
||||||
|
id: "social-providers-plugin",
|
||||||
|
$InferServerPlugin: {} as ReturnType<typeof socialProvidersPlugin>,
|
||||||
|
getActions: ($fetch) => {
|
||||||
|
return {
|
||||||
|
getSocialProviders: async (fetchOptions?: BetterFetchOption) => {
|
||||||
|
const res = $fetch("/social-providers", {
|
||||||
|
method: "GET",
|
||||||
|
...fetchOptions,
|
||||||
|
});
|
||||||
|
return res.then((res) => res.data as string[]);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
} satisfies BetterAuthClientPlugin;
|
||||||
|
|
||||||
export const authClient = createAuthClient({
|
export const authClient = createAuthClient({
|
||||||
plugins: [magicLinkClient(), apiKeyClient()],
|
plugins: [magicLinkClient(), apiKeyClient(), socialProvidersPluginClient],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
{
|
{
|
||||||
"extends": "@kan/tsconfig/internal-package.json",
|
"extends": "@kan/tsconfig/internal-package.json",
|
||||||
"compilerOptions": {},
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
"include": ["*.ts", "src"],
|
"include": ["*.ts", "src"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
45
turbo.json
45
turbo.json
@@ -3,28 +3,49 @@
|
|||||||
"ui": "tui",
|
"ui": "tui",
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"topo": {
|
"topo": {
|
||||||
"dependsOn": ["^topo"]
|
"dependsOn": [
|
||||||
|
"^topo"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"dependsOn": ["^build"],
|
"dependsOn": [
|
||||||
"outputs": [".cache/tsbuildinfo.json", "dist/**"]
|
"^build"
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
".cache/tsbuildinfo.json",
|
||||||
|
"dist/**"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"dev": {
|
"dev": {
|
||||||
"dependsOn": ["^dev"],
|
"dependsOn": [
|
||||||
|
"^dev"
|
||||||
|
],
|
||||||
"cache": false,
|
"cache": false,
|
||||||
"persistent": false
|
"persistent": false
|
||||||
},
|
},
|
||||||
"format": {
|
"format": {
|
||||||
"outputs": [".cache/.prettiercache"],
|
"outputs": [
|
||||||
|
".cache/.prettiercache"
|
||||||
|
],
|
||||||
"outputLogs": "new-only"
|
"outputLogs": "new-only"
|
||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
"dependsOn": ["^topo", "^build"],
|
"dependsOn": [
|
||||||
"outputs": [".cache/.eslintcache"]
|
"^topo",
|
||||||
|
"^build"
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
".cache/.eslintcache"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"typecheck": {
|
"typecheck": {
|
||||||
"dependsOn": ["^topo", "^build"],
|
"dependsOn": [
|
||||||
"outputs": [".cache/tsbuildinfo.json"]
|
"^topo",
|
||||||
|
"^build"
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
".cache/tsbuildinfo.json"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"clean": {
|
"clean": {
|
||||||
"cache": false
|
"cache": false
|
||||||
@@ -49,6 +70,10 @@
|
|||||||
"POSTGRES_URL",
|
"POSTGRES_URL",
|
||||||
"GOOGLE_CLIENT_ID",
|
"GOOGLE_CLIENT_ID",
|
||||||
"GOOGLE_CLIENT_SECRET",
|
"GOOGLE_CLIENT_SECRET",
|
||||||
|
"DISCORD_CLIENT_ID",
|
||||||
|
"DISCORD_CLIENT_SECRET",
|
||||||
|
"GITHUB_CLIENT_ID",
|
||||||
|
"GITHUB_CLIENT_SECRET",
|
||||||
"EMAIL_FROM",
|
"EMAIL_FROM",
|
||||||
"SMTP_HOST",
|
"SMTP_HOST",
|
||||||
"SMTP_PORT",
|
"SMTP_PORT",
|
||||||
@@ -78,4 +103,4 @@
|
|||||||
"VERCEL_URL",
|
"VERCEL_URL",
|
||||||
"npm_lifecycle_event"
|
"npm_lifecycle_event"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user