fix save user issue (#1028)

This commit is contained in:
Graham Steffaniak authored and GitHub committed 2025-07-29 18:02:51 -05:00
1 parent 120e5f3ca3
commit 482cded2d0
20 files changed
+344 -163

No files matched your search

+87 -59
View File
@@ -8,78 +8,104 @@ on:
- "beta/v*.*.*"
- "stable/v*.*.*"
jobs:
prime_docker_cache:
name: Prime docker cache
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Prime Cache
uses: docker/build-push-action@v6
with:
context: .
file: _docker/Dockerfile.primecache
push: false
load: false
cache-to: |
type=gha,mode=max,scope=${{ github.ref_name }}-playwright-base
build_frontend:
name: Build frontend
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/setup-node@v4
- name: Build frontend
working-directory: frontend
run: npm i && npm run build
- name: Upload frontend build artifact
uses: actions/upload-artifact@v4
with:
name: dist
path: frontend/dist
build_backend:
name: Build backend
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- name: Build backend
working-directory: backend
run: go build -o filebrowser .
- name: Upload backend build artifact
uses: actions/upload-artifact@v4
with:
name: filebrowser
path: backend/filebrowser
test_playwright:
name: Test Playwright - general
needs: [build_backend, build_frontend, prime_docker_cache]
name: Test Playwright - ${{ matrix.name }}
runs-on: ubuntu-latest
strategy:
matrix:
include:
- name: general
dockerfile: ./_docker/Dockerfile.playwright-general
- name: proxy
dockerfile: ./_docker/Dockerfile.playwright-proxy
- name: noauth
dockerfile: ./_docker/Dockerfile.playwright-noauth
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download frontend build
uses: actions/download-artifact@v4
with:
name: dist
path: frontend/dist
- name: Download backend build
uses: actions/download-artifact@v4
with:
name: filebrowser
path: backend/
- name: Make backend executable
run: chmod +x backend/filebrowser
- name: Copy frontend build to backend
run: |
mkdir -p backend/http/embed
cp -r frontend/dist/* backend/http/embed
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
- uses: actions/setup-node@v4
- working-directory: frontend
run: npm i && npm run build
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- working-directory: backend
run: go build -o filebrowser .
- name: Build
- name: Build and Test
id: build
uses: docker/build-push-action@v6
with:
context: .
file: ./_docker/Dockerfile.playwright-general
push: false
test_playwright_proxy:
name: Test Playwright - proxy
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
- uses: actions/setup-node@v4
- working-directory: frontend
run: npm i && npm run build
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- working-directory: backend
run: go build -o filebrowser .
- name: Build
uses: docker/build-push-action@v6
with:
context: .
file: ./_docker/Dockerfile.playwright-proxy
push: false
test_playwright_noauth:
name: Test Playwright - noauth
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
- uses: actions/setup-node@v4
- working-directory: frontend
run: npm i && npm run build
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- working-directory: backend
run: go build -o filebrowser .
- name: Build
uses: docker/build-push-action@v6
with:
context: .
file: ./_docker/Dockerfile.playwright-noauth
file: ${{ matrix.dockerfile }}
push: false
load: true
cache-from: |
type=gha,scope=${{ github.ref_name }}-playwright-base
type=gha,scope=main-playwright-base
cache-to: |
type=gha,mode=max,scope=${{ github.ref_name }}-playwright-base
push_pr_to_registry:
if: github.event.pull_request.head.repo.fork == false
name: Build docker image
@@ -112,3 +138,5 @@ jobs:
build-args: |
VERSION=${{ steps.meta.outputs.version }}
REVISION=${{ steps.meta.outputs.revision }}
cache-from: type=gha
cache-to: type=gha,mode=max
+6
View File
@@ -2,6 +2,12 @@
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
## v0.7.18-beta
**BugFixes**:
- fix lightBackground issue (#1021)
- fix user save issues (#1020, #1027)
## v0.7.17-beta
See an example of custom css styling that uses the reduce-rounded-corners.css by default and allows users to choose other themes. You can add your own themes as well that users can choose from in profile settings:
+10 -1
View File
@@ -1,8 +1,17 @@
FROM gtstef/playwright-base
WORKDIR /app
# Copy setup scripts first to leverage caching
COPY [ "./_docker/src/general/", "./" ]
WORKDIR /app/frontend
COPY [ "./frontend/", "./" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser*", "./"]
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
# Run tests
RUN ./filebrowser set -u testuser1,testuser1
RUN FILEBROWSER_NO_EMBEDED=true ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+9 -1
View File
@@ -1,8 +1,16 @@
FROM gtstef/playwright-base
WORKDIR /app
# Copy setup scripts first to leverage caching
COPY [ "./_docker/src/noauth/", "./" ]
WORKDIR /app/frontend
COPY [ "./frontend/", "./" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
# Run tests
RUN FILEBROWSER_NO_EMBEDED=true ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+3 -1
View File
@@ -5,7 +5,9 @@ WORKDIR /app/frontend
COPY [ "./frontend/", "./" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
RUN apt update && apt install nginx -y
RUN mv default.conf /etc/nginx/conf.d/default.conf
RUN sed -i 's/filebrowser/localhost/g' /etc/nginx/conf.d/default.conf
RUN nginx & ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
RUN nginx & FILEBROWSER_NO_EMBEDED=true ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+1
View File
@@ -0,0 +1 @@
FROM gtstef/playwright-base
@@ -20,7 +20,7 @@ export default defineConfig({
/* Retry on CI only */
retries: 2,
/* Opt out of parallel tests on CI. */
workers: 1, // required for now! todo parallel some tests
workers: 2,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "line",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
+4 -5
View File
@@ -85,10 +85,7 @@ func runCLI() bool {
}
username := userInfo[0]
password := userInfo[1]
ok := getStore(dbConfig)
if !ok {
logger.Fatal("could not load db info")
}
_ = getStore(dbConfig) // ignore bool check
user, err := store.Users.Get(username)
if err != nil {
newUser := users.User{
@@ -113,7 +110,9 @@ func runCLI() bool {
} else {
logger.Infof("Creating non-admin user: %s\n", username)
}
err = storage.CreateUser(newUser, asAdmin)
newUser.Permissions = settings.Config.UserDefaults.Permissions
newUser.Permissions.Admin = asAdmin
err = storage.CreateUser(newUser)
if err != nil {
logger.Errorf("could not create user: %v", err)
}
+2 -1
View File
@@ -234,10 +234,11 @@ func parseFields(user *users.User, fields []string, actorIsAdmin bool) ([]string
field := t.Field(i)
// which=all can't update password
switch strings.ToLower(field.Name) {
case "id", "username", "loginmethod", "password", "apikeys", "totpenabled", "totpsecret", "totpnonce":
case "id", "username", "password", "apikeys", "totpenabled", "totpsecret", "totpnonce":
// Skip these fields
continue
}
fields = append(fields, field.Name)
}
}
+1 -6
View File
@@ -113,7 +113,7 @@ func quickSetup(store *Storage) {
}
// create new user
func CreateUser(userInfo users.User, asAdmin bool) error {
func CreateUser(userInfo users.User) error {
newUser := &userInfo
if userInfo.LoginMethod == "password" {
if userInfo.Password == "" {
@@ -126,16 +126,11 @@ func CreateUser(userInfo users.User, asAdmin bool) error {
}
userInfo.Password = hashpass
}
userInfo.Permissions = settings.Config.UserDefaults.Permissions
// must have username
if userInfo.Username == "" {
return fmt.Errorf("username is required to create a user")
}
logger.Debugf("Creating user: %v %v", userInfo.Username, userInfo.Scopes)
settings.ApplyUserDefaults(newUser)
if asAdmin {
userInfo.Permissions = settings.AdminPerms()
}
// create new home directories
err := store.Users.Save(newUser, true, false)
if err != nil {
+4 -2
View File
@@ -79,7 +79,8 @@ func setupProxyUser(r *http.Request, data *requestContext, proxyUser string) (*u
err = storage.CreateUser(users.User{
LoginMethod: users.LoginMethodProxy,
Username: proxyUser,
}, false)
Permissions: settings.Config.UserDefaults.Permissions,
})
if err != nil {
return nil, err
}
@@ -194,7 +195,8 @@ func signupHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
},
LoginMethod: users.LoginMethodPassword,
}
err = storage.CreateUser(user, false)
user.Permissions = settings.Config.UserDefaults.Permissions
err = storage.CreateUser(user)
if err != nil {
logger.Debug(err.Error())
w.WriteHeader(http.StatusConflict)
+7 -2
View File
@@ -291,10 +291,15 @@ func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string,
if config.Auth.Methods.OidcAuth.AdminGroup == "" {
isAdmin = config.UserDefaults.Permissions.Admin
}
err = storage.CreateUser(users.User{
newUser := users.User{
LoginMethod: users.LoginMethodOidc,
Username: username,
}, isAdmin)
Permissions: settings.Config.UserDefaults.Permissions,
}
if isAdmin {
newUser.Permissions.Admin = true
}
err = storage.CreateUser(newUser)
if err != nil {
return http.StatusInternalServerError, err
}
+1 -1
View File
@@ -164,7 +164,7 @@ func usersPostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return http.StatusBadRequest, errors.ErrEmptyPassword
}
err = storage.CreateUser(req.User, req.User.Permissions.Admin)
err = storage.CreateUser(req.User)
if err != nil {
return http.StatusInternalServerError, err
}
+7
View File
@@ -36,6 +36,13 @@ export default {
},
},
watch: {
$route: {
handler(newRoute) {
// hide tooltip when route changes
this.tooltip.show = false;
this.tooltip.content = "";
},
},
tooltip: {
handler(newTooltip) {
if (newTooltip.show) {
@@ -8,12 +8,12 @@
class="button button--flat button--grey"
@click="closeHovers"
v-focus
:aria-label="$t('buttons.cancel')"
aria-label="Cancel"
:title="$t('buttons.cancel')"
>
{{ $t("buttons.cancel") }}
</button>
<button class="button button--flat" @click="deleteUser">
<button class="button button--flat" aria-label="Confirm Delete" @click="deleteUser">
{{ $t("buttons.delete") }}
</button>
</div>
@@ -1,60 +1,24 @@
import { test, expect } from '../test-setup'
test('create and delete testuser', async ({
page,
checkForErrors,
context
}) => {
await page.goto('/settings')
await expect(page).toHaveTitle("Graham's Filebrowser - Settings")
await page.locator('button[aria-label="Add New User"]').click()
await page.locator('#username').fill('testuser')
await page.locator('input[aria-label="Password1"]').fill('testpassword')
await page.locator('input[aria-label="Password2"]').fill('testpass')
// check that the invalid-field class is added properly
await expect(page.locator('input[aria-label="Password2"]')).toHaveClass(
'input input--block form-form invalid-form'
)
await page.locator('input[aria-label="Password2"]').fill('testpassword')
await page.locator('input[aria-label="Save User"]').click()
test("adjusting theme colors", async({ page, checkForErrors, context }) => {
await page.goto("/files/");
const originalPrimaryColor = await page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue('--primaryColor').trim();
});
await expect(originalPrimaryColor).toBe('#2196f3');
// click the edit button for testuser
const userRow = page.locator('tr.item', { hasText: 'testuser' })
const editLink = await userRow
.locator('td[aria-label="Edit User"] a')
.getAttribute('href')
await page.goto(editLink!)
checkForErrors()
})
test("two factor auth check", async ({ page, checkForErrors, context }) => {
// go to settings
await page.goto("/settings");
await expect(page).toHaveURL(/\/settings/);
// click the edit button for testuser
const userRow = page.locator('tr.item', { hasText: 'admin' })
const editLink = await userRow
.locator('td[aria-label="Edit User"] a')
.getAttribute('href')
await page.goto(editLink!)
// Toggle the two factor authentication switch
const twoFactorCheckbox = page.locator('.toggle-container:has-text("Two-Factor Authentication") input[type="checkbox"]');
const twoFactorToggle = page.locator('.toggle-container:has-text("Two-Factor Authentication") label.switch');
// Check if it's currently enabled
const isChecked = await twoFactorCheckbox.isChecked();
// Toggle it by clicking the label (since checkbox is hidden)
await twoFactorToggle.click();
// Verify it changed state
await expect(twoFactorCheckbox).toBeChecked();
await page.locator('button[aria-label="Generate Code"]').click();
// check for the otp url
await expect(page.locator('p[aria-label="otp-url"]')).toBeVisible();
// check that the otp-url is not empty
const otpUrl = await page.locator('p[aria-label="otp-url"]').textContent();
expect(otpUrl).not.toBe("");
// Verify the page title
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
await page.locator('i[aria-label="settings"]').click();
await expect(page).toHaveTitle("Graham's Filebrowser - Settings");
await page.locator('button', { hasText: 'violet' }).click();
const popup = page.locator('#popup-notification-content');
await popup.waitFor({ state: 'visible' });
await expect(popup).toHaveText('Settings updated!');
const newPrimaryColor = await page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue('--primaryColor').trim();
});
await expect(newPrimaryColor).toBe('#9b59b6');
// Check for console errors
checkForErrors();
});
@@ -20,26 +20,3 @@ test("sidebar links", async({ page, checkForErrors, context }) => {
// Check for console errors
checkForErrors();
});
test("adjusting theme colors", async({ page, checkForErrors, context }) => {
await page.goto("/files/");
const originalPrimaryColor = await page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue('--primaryColor').trim();
});
await expect(originalPrimaryColor).toBe('#2196f3');
// Verify the page title
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
await page.locator('i[aria-label="settings"]').click();
await expect(page).toHaveTitle("Graham's Filebrowser - Settings");
await page.locator('button', { hasText: 'violet' }).click();
const popup = page.locator('#popup-notification-content');
await popup.waitFor({ state: 'visible' });
await expect(popup).toHaveText('Settings updated!');
const newPrimaryColor = await page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue('--primaryColor').trim();
});
await expect(newPrimaryColor).toBe('#9b59b6');
// Check for console errors
checkForErrors();
});
@@ -0,0 +1,177 @@
import { test, expect } from "../test-setup";
import { Page } from "@playwright/test";
test('create, check settings, and delete testuser2', async ({
page,
checkForErrors,
}) => {
await page.goto('/settings')
await expect(page).toHaveTitle("Graham's Filebrowser - Settings")
await page.locator('button[aria-label="Add New User"]').click()
await page.locator('#username').fill('testuser2')
await page.locator('input[aria-label="Password1"]').fill('testpassword')
await page.locator('input[aria-label="Password2"]').fill('testpass')
// check that the invalid-field class is added properly
await expect(page.locator('input[aria-label="Password2"]')).toHaveClass(
'input input--block form-form invalid-form'
)
await page.locator('input[aria-label="Password2"]').fill('testpassword')
// Just create the user first
await page.locator('input[aria-label="Save User"]').click();
await page.waitForResponse((resp) => resp.url().includes('/api/users') && resp.status() === 201)
// We should be back on the settings page
await expect(page.locator('tr.item', { hasText: 'testuser2' })).toBeVisible();
// Now, click the edit button for testuser2
const userRow = page.locator('tr.item', { hasText: 'testuser2' })
const editLink = await userRow
.locator('td[aria-label="Edit User"] a')
.getAttribute('href')
await page.goto(editLink!)
// Now on the edit page, toggle the settings
const settingsToToggle = [
"Administrator",
"Prevent the user from changing the password",
"Edit files",
"Share files",
"Create and manage long-live API keys",
"Enable real-time connections and updates",
];
for (const settingName of settingsToToggle) {
const toggleContainer = page.locator(".toggle-container", { hasText: settingName });
const toggleSwitch = toggleContainer.locator("label.switch");
await toggleSwitch.click();
}
// Save the updated settings
await page.locator('input[aria-label="Save User"]').click();
// The app might show a notification and stay on the page. Let's reload to ensure we have the latest state.
await page.reload({ waitUntil: 'networkidle' });
for (const settingName of settingsToToggle) {
const checkbox = page.locator(`.toggle-container:has-text("${settingName}") input[type="checkbox"]`);
await expect(checkbox).toBeChecked();
}
// Delete the user
await page.locator('button[aria-label="Delete User"]').click();
await page.locator('button[aria-label="Confirm Delete"]').click();
// After deletion, we should be back on the settings page.
await expect(page.locator('tr.item', { hasText: 'testuser2' })).not.toBeVisible();
checkForErrors()
})
test("two factor auth check", async ({ page, checkForErrors, context }) => {
// go to settings
await page.goto("/settings");
await expect(page).toHaveURL(/\/settings/);
// click the edit button for testuser
const userRow = page.locator('tr.item', { hasText: 'admin' })
const editLink = await userRow
.locator('td[aria-label="Edit User"] a')
.getAttribute('href')
await page.goto(editLink!)
// Toggle the two factor authentication switch
const twoFactorCheckbox = page.locator('.toggle-container:has-text("Two-Factor Authentication") input[type="checkbox"]');
const twoFactorToggle = page.locator('.toggle-container:has-text("Two-Factor Authentication") label.switch');
// Check if it's currently enabled
const isChecked = await twoFactorCheckbox.isChecked();
// Toggle it by clicking the label (since checkbox is hidden)
await twoFactorToggle.click();
// Verify it changed state
await expect(twoFactorCheckbox).toBeChecked();
await page.locator('button[aria-label="Generate Code"]').click();
// check for the otp url
await expect(page.locator('p[aria-label="otp-url"]')).toBeVisible();
// check that the otp-url is not empty
const otpUrl = await page.locator('p[aria-label="otp-url"]').textContent();
expect(otpUrl).not.toBe("");
checkForErrors();
});
test.describe("User Settings Persistence", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/settings");
const userRow = page.locator("tr.item", { hasText: "testuser1" });
const editLink = await userRow
.locator('td[aria-label="Edit User"] a')
.getAttribute("href");
await page.goto(editLink!);
});
async function toggleSettingAndSave(page: Page, settingName: string) {
const toggleContainer = page.locator(".toggle-container", { hasText: settingName });
const toggleSwitch = toggleContainer.locator("label.switch");
await toggleSwitch.click();
await page.locator('input[aria-label="Save User"]').click();
await page.reload();
}
test('should persist "Prevent the user from changing the password" setting', async ({ page }) => {
const settingName = "Prevent the user from changing the password";
const checkbox = page.locator(`.toggle-container:has-text("${settingName}") input[type="checkbox"]`);
await expect(checkbox).not.toBeChecked();
await toggleSettingAndSave(page, settingName);
await expect(checkbox).toBeChecked();
});
test('should persist "Administrator" setting', async ({ page }) => {
const settingName = "Administrator";
const checkbox = page.locator(`.toggle-container:has-text("${settingName}") input[type="checkbox"]`);
await expect(checkbox).not.toBeChecked(); // Admin user should have this on
await toggleSettingAndSave(page, settingName);
await expect(checkbox).toBeChecked();
});
test('should persist "Edit files" setting', async ({ page }) => {
const settingName = "Edit files";
const checkbox = page.locator(`.toggle-container:has-text("${settingName}") input[type="checkbox"]`);
await expect(checkbox).not.toBeChecked();
await toggleSettingAndSave(page, settingName);
await expect(checkbox).toBeChecked();
await toggleSettingAndSave(page, settingName); // Revert
});
test('should persist "Share files" setting', async ({ page }) => {
const settingName = "Share files";
const checkbox = page.locator(`.toggle-container:has-text("${settingName}") input[type="checkbox"]`);
await expect(checkbox).not.toBeChecked();
await toggleSettingAndSave(page, settingName);
await expect(checkbox).toBeChecked();
});
test('should persist "Create and manage long-live API keys" setting', async ({ page }) => {
const settingName = "Create and manage long-live API keys";
const checkbox = page.locator(`.toggle-container:has-text("${settingName}") input[type="checkbox"]`);
await expect(checkbox).not.toBeChecked();
await toggleSettingAndSave(page, settingName);
await expect(checkbox).toBeChecked();
});
test('should persist "Enable real-time connections and updates" setting', async ({ page }) => {
const settingName = "Enable real-time connections and updates";
const checkbox = page.locator(`.toggle-container:has-text("${settingName}") input[type="checkbox"]`);
await expect(checkbox).not.toBeChecked();
await toggleSettingAndSave(page, settingName);
await expect(checkbox).toBeChecked();
});
test('should persist "allowed login method" setting', async ({ page }) => {
const loginMethodSelector = page.locator("#loginMethod");
await expect(loginMethodSelector).toHaveValue("password");
await loginMethodSelector.selectOption({ label: "Proxy" });
await page.locator('input[aria-label="Save User"]').click();
await page.reload();
await expect(page.locator("#loginMethod")).toHaveValue("proxy");
});
});
@@ -55,7 +55,6 @@ async function globalSetup() {
localStorage.setItem('shareHashFile', hash);
}, shareHashFile);
await context.storageState({ path: "./loginAuth.json" });
await browser.close();
}
+3 -2
View File
@@ -4,6 +4,7 @@
"allowJs": true,
"target": "ESNext",
"useDefineForClassFields": true,
"noEmit": true,
"module": "ESNext",
"moduleResolution": "Node10",
"strict": true,
@@ -18,8 +19,8 @@
"paths": {
"@/*": ["./src/*"]
},
"checkJs": false, // check later
"checkJs": true,
},
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.vue", "tests-playwright/general/auth.spec.ts"],
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue", "tests-playwright/**/*.ts", "../_docker/src/proxy/frontend/playwright.config.ts"],
"exclude": ["node_modules", "dist"]
}